feat(server): env-var overrides for sec-config keys + docker healthchecks

This commit is contained in:
Mitchell R 2026-05-14 07:33:10 +02:00
parent 69cd0391b5
commit d1fd128ea0
6 changed files with 90 additions and 21 deletions

View file

@ -19,6 +19,15 @@ services:
dockerfile: deploy/docker/Dockerfile.server dockerfile: deploy/docker/Dockerfile.server
container_name: betterframe-server container_name: betterframe-server
restart: unless-stopped restart: unless-stopped
# Env overrides win over sec-config.yaml — Coolify / k8s inject these.
environment:
- TZ=UTC
- BF_DATA_DIR=/var/lib/betterframe
- BF_SQLITE_PATH=/var/lib/betterframe/betterframe.db
- BF_NODERED_URL=http://nodered:1880
- BF_SELF_URL=http://server:18080
# Optional: paste Ed25519 PEM private key here for firmware signing.
# - BF_FIRMWARE_SIGNING_KEY=
volumes: volumes:
- betterframe-data:/var/lib/betterframe - betterframe-data:/var/lib/betterframe
- ./sec-config.yaml:/app/server/sec-config.yaml:ro - ./sec-config.yaml:/app/server/sec-config.yaml:ro
@ -26,6 +35,12 @@ services:
- "18080" - "18080"
- "18081" - "18081"
- "18082" - "18082"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks: networks:
- betterframe - betterframe
@ -56,6 +71,12 @@ services:
- ./nodered-settings.js:/data/settings.js:ro - ./nodered-settings.js:/data/settings.js:ro
expose: expose:
- "1880" - "1880"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:1880/nrdp/auth/login >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
networks: networks:
- betterframe - betterframe

View file

@ -20,6 +20,7 @@ import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
import { createAuth, type AuthApi } from "../../shared/auth.js"; import { createAuth, type AuthApi } from "../../shared/auth.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
import { envStr } from "../../shared/env-overrides.js";
import type { Repository } from "../service-store/repository.js"; import type { Repository } from "../service-store/repository.js";
import { registerMiddleware } from "./middleware.js"; import { registerMiddleware } from "./middleware.js";
@ -103,10 +104,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
async init(obs: Observable): Promise<void> { async init(obs: Observable): Promise<void> {
// Init shared modules — no inter-plugin wiring needed // Init shared modules — no inter-plugin wiring needed.
// Env-var overrides for Coolify / 12-factor deploys (BF_* prefix).
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
const selfUrl = envStr("BF_SELF_URL", this.config.selfUrl);
const cookieName = envStr("BF_COOKIE_NAME", this.config.cookieName);
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
const repo = getRepo(); const repo = getRepo();
const secrets = initSecrets( const secrets = initSecrets(
{ dataDir: this.config.dataDir, systemdCredsName: this.config.systemdCredsName }, { dataDir, systemdCredsName: this.config.systemdCredsName },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const auth = createAuth(repo, secrets, { const auth = createAuth(repo, secrets, {
@ -117,17 +125,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory, argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost, argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism, argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer, totpIssuer,
cookieName: this.config.cookieName, cookieName,
}); });
const nodered = initNoderedBridge( const nodered = initNoderedBridge(
{ baseUrl: this.config.noderedUrl }, { baseUrl: noderedUrl },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const firmware = initFirmware( const firmware = initFirmware(
{ dataDir: this.config.dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
@ -135,7 +143,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
repo, repo,
auth, auth,
secrets, secrets,
cookieName: this.config.cookieName, cookieName,
nodered, nodered,
firmware, firmware,
}; };
@ -214,7 +222,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
// Auto-provision the Node-RED bf-server-config so the user doesn't have // 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 // to set server URL + API key manually. Best-effort with retries because
// Node-RED may still be starting. // Node-RED may still be starting.
void this.provisionNoderedBridge(repo, secrets, auth, nodered, obs); void this.provisionNoderedBridge(repo, secrets, auth, nodered, selfUrl, obs);
} }
async run(_obs: Observable): Promise<void> {} async run(_obs: Observable): Promise<void> {}
@ -224,6 +232,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
secrets: SecretsApi, secrets: SecretsApi,
auth: AuthApi, auth: AuthApi,
nodered: NoderedBridge, nodered: NoderedBridge,
selfUrl: string,
obs: Observable, obs: Observable,
): Promise<void> { ): Promise<void> {
let plaintext: string; let plaintext: string;
@ -241,12 +250,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
await new Promise((r) => setTimeout(r, delaysMs[attempt])); await new Promise((r) => setTimeout(r, delaysMs[attempt]));
obs.log.info("nodered: provisioning attempt {n} → {url}", { obs.log.info("nodered: provisioning attempt {n} → {url}", {
n: attempt + 1, n: attempt + 1,
url: this.config.selfUrl, url: selfUrl,
}); });
const result = await nodered.ensureServerConfig(this.config.selfUrl, plaintext); const result = await nodered.ensureServerConfig(selfUrl, plaintext);
if (result === "created") { if (result === "created") {
obs.log.info("nodered: provisioned bf-server-config at {url}", { obs.log.info("nodered: provisioned bf-server-config at {url}", {
url: this.config.selfUrl, url: selfUrl,
}); });
return; return;
} }

View file

@ -22,6 +22,7 @@ import { initiatePairing, claimPairing } from "../../shared/pairing.js";
import { generateBundle } from "../../shared/bundle.js"; import { generateBundle } from "../../shared/bundle.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
import { envStr } from "../../shared/env-overrides.js";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { Repository } from "../service-store/repository.js"; import type { Repository } from "../service-store/repository.js";
import type { AuthApi } from "../../shared/auth.js"; import type { AuthApi } from "../../shared/auth.js";
@ -87,9 +88,14 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
async init(obs: Observable): Promise<void> { async init(obs: Observable): Promise<void> {
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
const cookieName = envStr("BF_COOKIE_NAME", this.config.cookieName);
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
const repo = getRepo(); const repo = getRepo();
const secrets = initSecrets( const secrets = initSecrets(
{ dataDir: this.config.dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const auth = createAuth(repo, secrets, { const auth = createAuth(repo, secrets, {
@ -100,16 +106,16 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory, argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost, argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism, argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer, totpIssuer,
cookieName: this.config.cookieName, cookieName,
}); });
const codeTtl = this.config.codeTtlSeconds; const codeTtl = this.config.codeTtlSeconds;
const nodered = initNoderedBridge( const nodered = initNoderedBridge(
{ baseUrl: this.config.noderedUrl }, { baseUrl: noderedUrl },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const firmware = initFirmware( const firmware = initFirmware(
{ dataDir: this.config.dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );

View file

@ -27,6 +27,7 @@ import { initSecrets } from "../../shared/secrets.js";
import { createAuth } from "../../shared/auth.js"; import { createAuth } from "../../shared/auth.js";
import { setCoordinator } from "../../shared/coordinator-registry.js"; import { setCoordinator } from "../../shared/coordinator-registry.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { envStr } from "../../shared/env-overrides.js";
// ---- Config ----------------------------------------------------------------- // ---- Config -----------------------------------------------------------------
@ -112,13 +113,18 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
async init(obs: Observable): Promise<void> { async init(obs: Observable): Promise<void> {
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
const cookieName = envStr("BF_COOKIE_NAME", this.config.cookieName);
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
const repo = getRepo(); const repo = getRepo();
const secrets = initSecrets( const secrets = initSecrets(
{ dataDir: this.config.dataDir }, { dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
const nodered = initNoderedBridge( const nodered = initNoderedBridge(
{ baseUrl: this.config.noderedUrl }, { baseUrl: noderedUrl },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
); );
this.nodered = nodered; this.nodered = nodered;
@ -131,8 +137,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory, argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost, argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism, argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer, totpIssuer,
cookieName: this.config.cookieName, cookieName,
}); });
const httpServer = createServer((req, res) => { const httpServer = createServer((req, res) => {

View file

@ -35,6 +35,7 @@ import {
import { MIGRATIONS } from "./migrations.js"; import { MIGRATIONS } from "./migrations.js";
import { Repository } from "./repository.js"; import { Repository } from "./repository.js";
import { registerRepo } from "../../shared/plugin-registry.js"; import { registerRepo } from "../../shared/plugin-registry.js";
import { envStr } from "../../shared/env-overrides.js";
// ---- Config ----------------------------------------------------------------- // ---- Config -----------------------------------------------------------------
@ -99,7 +100,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
} }
async init(obs: Observable): Promise<void> { async init(obs: Observable): Promise<void> {
const path = this.config.sqlitePath; const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath);
obs.log.info("opening sqlite at {path}", { path }); obs.log.info("opening sqlite at {path}", { path });
// Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME) // Ensure parent dir exists (in dev BETTERFRAME_DATA_DIR may be in $HOME)

View file

@ -0,0 +1,26 @@
/**
* Tiny env-override helpers for Coolify / 12-factor deploys.
*
* sec-config.yaml stays the single declarative source of truth, but a handful
* of values benefit from runtime env-var injection (URL of the upstream
* Node-RED, the BF server's own public URL, paths to data dirs, etc.). These
* helpers are called inline from each plugin's init() so a missing env var
* simply falls back to the YAML value.
*/
export function envStr(key: string, fallback: string): string {
const v = process.env[key];
return v && v.length > 0 ? v : fallback;
}
export function envInt(key: string, fallback: number): number {
const v = process.env[key];
if (!v) return fallback;
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
export function envBool(key: string, fallback: boolean): boolean {
const v = process.env[key]?.toLowerCase();
if (v === undefined) return fallback;
return v === "1" || v === "true" || v === "yes" || v === "on";
}