diff --git a/deploy/docker/sec-config.yaml b/deploy/docker/sec-config.yaml index 4eda271..c242f03 100644 --- a/deploy/docker/sec-config.yaml +++ b/deploy/docker/sec-config.yaml @@ -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 diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 6c78f6f..388f64c 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -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, 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 {} + private async provisionNoderedBridge( + repo: Repository, + secrets: SecretsApi, + auth: AuthApi, + nodered: NoderedBridge, + obs: Observable, + ): Promise { + 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 { + 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 { if (this.server) { await this.server.close(); diff --git a/server/src/shared/nodered-bridge.ts b/server/src/shared/nodered-bridge.ts index 4cb6aa4..0f1eae6 100644 --- a/server/src/shared/nodered-bridge.ts +++ b/server/src/shared/nodered-bridge.ts @@ -26,6 +26,13 @@ export interface NoderedDashboard { export interface NoderedBridge { forward(topic: string, payload: Record): void; listDashboards(): Promise; + /** + * 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 = { + 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); + } +}