mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +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
|
cookieName: betterframe_session
|
||||||
totpIssuer: BetterFrame
|
totpIssuer: BetterFrame
|
||||||
noderedUrl: http://nodered:1880
|
noderedUrl: http://nodered:1880
|
||||||
|
selfUrl: http://server:18080
|
||||||
|
|
||||||
service-api-http:
|
service-api-http:
|
||||||
plugin: service-api-http
|
plugin: service-api-http
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ const ConfigSchema = av.object(
|
||||||
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
||||||
cookieName: av.string().minLength(1).default("betterframe_session"),
|
cookieName: av.string().minLength(1).default("betterframe_session"),
|
||||||
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -198,10 +200,69 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
host: this.config.host,
|
host: this.config.host,
|
||||||
port: this.config.port,
|
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> {}
|
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> {
|
async dispose(): Promise<void> {
|
||||||
if (this.server) {
|
if (this.server) {
|
||||||
await this.server.close();
|
await this.server.close();
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,13 @@ export interface NoderedDashboard {
|
||||||
export interface NoderedBridge {
|
export interface NoderedBridge {
|
||||||
forward(topic: string, payload: Record<string, unknown>): void;
|
forward(topic: string, payload: Record<string, unknown>): void;
|
||||||
listDashboards(): Promise<NoderedDashboard[]>;
|
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 {
|
interface NoderedFlowNode {
|
||||||
|
|
@ -35,6 +42,8 @@ interface NoderedFlowNode {
|
||||||
name?: string;
|
name?: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
z?: string;
|
||||||
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -108,5 +117,76 @@ export function initNoderedBridge(config: NoderedConfig, log: NoderedLog): Noder
|
||||||
return [];
|
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