feat(nodered): auto-provision bf-server-config on boot

Server mints a dedicated admin API key on first boot (persisted plaintext
encrypted in setup_state.extras) and POSTs a bf-server-config node into
Node-RED's flow graph via /nrdp/flows. Idempotent — skips if any
bf-server-config already exists, so user-owned configs win.

New admin-http config 'selfUrl' (defaults to http://127.0.0.1:18080)
tells Node-RED how to reach the BF server. Docker compose sets it to
http://server:18080 so requests stay inside the compose network.
This commit is contained in:
Mitchell R 2026-05-13 03:09:25 +02:00
parent 5b380d4694
commit 122509de0d
3 changed files with 142 additions and 0 deletions

View file

@ -36,6 +36,7 @@ default:
cookieName: betterframe_session
totpIssuer: BetterFrame
noderedUrl: http://nodered:1880
selfUrl: http://server:18080
service-api-http:
plugin: service-api-http

View file

@ -48,6 +48,8 @@ const ConfigSchema = av.object(
totpIssuer: av.string().minLength(1).default("BetterFrame"),
cookieName: av.string().minLength(1).default("betterframe_session"),
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
// URL Node-RED uses to reach this server. Native: localhost. Docker: container name.
selfUrl: av.string().minLength(1).default("http://127.0.0.1:18080"),
},
{ unknownKeys: "strip" },
);
@ -198,10 +200,69 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
host: this.config.host,
port: this.config.port,
});
// Auto-provision the Node-RED bf-server-config so the user doesn't have
// to set server URL + API key manually. Best-effort with retries because
// Node-RED may still be starting.
void this.provisionNoderedBridge(repo, secrets, auth, nodered, obs);
}
async run(_obs: Observable): Promise<void> {}
private async provisionNoderedBridge(
repo: Repository,
secrets: SecretsApi,
auth: AuthApi,
nodered: NoderedBridge,
obs: Observable,
): Promise<void> {
let plaintext: string;
try {
plaintext = await this.getOrMintNoderedApiKey(repo, secrets, auth);
} catch (err) {
obs.log.warn("nodered: mint key failed: {err}", { err: (err as Error).message });
return;
}
// Retry a few times — Node-RED may still be booting.
const delaysMs = [2000, 5000, 10000, 30000];
for (let attempt = 0; attempt < delaysMs.length; attempt += 1) {
await new Promise((r) => setTimeout(r, delaysMs[attempt]));
const result = await nodered.ensureServerConfig(this.config.selfUrl, plaintext);
if (result === "created") {
obs.log.info("nodered: provisioned bf-server-config at {url}", {
url: this.config.selfUrl,
});
return;
}
if (result === "exists") {
obs.log.info("nodered: bf-server-config already present, skipping");
return;
}
// failed — retry
}
obs.log.warn("nodered: provisioning bf-server-config gave up after retries");
}
private async getOrMintNoderedApiKey(
repo: Repository,
secrets: SecretsApi,
auth: AuthApi,
): Promise<string> {
const KEY = "nodered_api_key";
const stored = repo.getSetupExtra(KEY);
if (typeof stored === "string" && stored.length > 0) {
return secrets.decryptString(stored, "nodered_api_key");
}
const { plaintext } = await auth.createApiKey({
name: "node-red-bridge",
scopes: ["admin"],
expiresAt: null,
});
repo.setSetupExtra(KEY, secrets.encryptString(plaintext, "nodered_api_key"));
return plaintext;
}
async dispose(): Promise<void> {
if (this.server) {
await this.server.close();

View file

@ -26,6 +26,13 @@ export interface NoderedDashboard {
export interface NoderedBridge {
forward(topic: string, payload: Record<string, unknown>): void;
listDashboards(): Promise<NoderedDashboard[]>;
/**
* Idempotently provision a `bf-server-config` node in Node-RED's flow graph
* carrying the BetterFrame server URL + admin API key. Skips if any
* `bf-server-config` node already exists (assume user owns it). Best-effort;
* caller should retry on transient failure (Node-RED may still be booting).
*/
ensureServerConfig(serverUrl: string, apiKey: string): Promise<"created" | "exists" | "failed">;
}
interface NoderedFlowNode {
@ -35,6 +42,8 @@ interface NoderedFlowNode {
name?: string;
hidden?: boolean;
disabled?: boolean;
z?: string;
[k: string]: unknown;
}
/**
@ -108,5 +117,76 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder
return [];
}
},
async ensureServerConfig(
serverUrl: string,
apiKey: string,
): Promise<"created" | "exists" | "failed"> {
try {
return await provisionServerConfig(base, timeoutMs, serverUrl, apiKey);
} catch (err) {
log.warn(`nodered ensureServerConfig failed: ${(err as Error).message}`);
return "failed";
}
},
};
}
const BF_SERVER_CONFIG_ID = "bfsrv-default";
async function provisionServerConfig(
base: string,
timeoutMs: number,
serverUrl: string,
apiKey: string,
): Promise<"created" | "exists" | "failed"> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
// GET current flows + revision
const getResp = await fetch(`${base}/nrdp/flows`, {
method: "GET",
headers: { accept: "application/json" },
signal: ctrl.signal,
});
if (!getResp.ok) throw new Error(`GET /flows HTTP ${String(getResp.status)}`);
// Node-RED returns either an array (legacy) or {flows, rev} (current).
const raw = (await getResp.json()) as NoderedFlowNode[] | { flows: NoderedFlowNode[]; rev?: string };
const flows: NoderedFlowNode[] = Array.isArray(raw) ? raw : (raw.flows ?? []);
const rev: string | undefined = Array.isArray(raw) ? undefined : raw.rev;
// Skip if ANY bf-server-config already exists — user owns it.
if (flows.some((n) => n.type === "bf-server-config")) {
return "exists";
}
const newNode: NoderedFlowNode = {
id: BF_SERVER_CONFIG_ID,
type: "bf-server-config",
name: "BetterFrame (auto)",
server_url: serverUrl.replace(/\/+$/, ""),
// Credentials get peeled off by Node-RED's runtime and stored encrypted
// into flows_cred.json. The node body keeps only non-credential fields.
credentials: { api_key: apiKey },
};
const body: Record<string, unknown> = {
flows: [...flows, newNode],
};
if (rev) body.rev = rev;
const postResp = await fetch(`${base}/nrdp/flows`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
"node-red-deployment-type": "nodes",
},
body: JSON.stringify(body),
signal: ctrl.signal,
});
if (!postResp.ok) throw new Error(`POST /flows HTTP ${String(postResp.status)}`);
return "created";
} finally {
clearTimeout(t);
}
}