mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 20:16:35 +00:00
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:
parent
5b380d4694
commit
122509de0d
3 changed files with 142 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue