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
container_name: betterframe-server
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:
- betterframe-data:/var/lib/betterframe
- ./sec-config.yaml:/app/server/sec-config.yaml:ro
@ -26,6 +35,12 @@ services:
- "18080"
- "18081"
- "18082"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:18080/healthz || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
networks:
- betterframe
@ -56,6 +71,12 @@ services:
- ./nodered-settings.js:/data/settings.js:ro
expose:
- "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:
- betterframe

View file

@ -20,6 +20,7 @@ import { initSecrets, type SecretsApi } from "../../shared/secrets.js";
import { createAuth, type AuthApi } from "../../shared/auth.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.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 { registerMiddleware } from "./middleware.js";
@ -103,10 +104,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}
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 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, {}) },
);
const auth = createAuth(repo, secrets, {
@ -117,17 +125,17 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer,
cookieName: this.config.cookieName,
totpIssuer,
cookieName,
});
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, {}) },
);
const firmware = initFirmware(
{ dataDir: this.config.dataDir },
{ dataDir },
{ 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,
auth,
secrets,
cookieName: this.config.cookieName,
cookieName,
nodered,
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
// 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);
void this.provisionNoderedBridge(repo, secrets, auth, nodered, selfUrl, obs);
}
async run(_obs: Observable): Promise<void> {}
@ -224,6 +232,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
secrets: SecretsApi,
auth: AuthApi,
nodered: NoderedBridge,
selfUrl: string,
obs: Observable,
): Promise<void> {
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]));
obs.log.info("nodered: provisioning attempt {n} → {url}", {
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") {
obs.log.info("nodered: provisioned bf-server-config at {url}", {
url: this.config.selfUrl,
url: selfUrl,
});
return;
}

View file

@ -22,6 +22,7 @@ import { initiatePairing, claimPairing } from "../../shared/pairing.js";
import { generateBundle } from "../../shared/bundle.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
import { envStr } from "../../shared/env-overrides.js";
import { createHash } from "node:crypto";
import type { Repository } from "../service-store/repository.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> {
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 secrets = initSecrets(
{ dataDir: this.config.dataDir },
{ dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
);
const auth = createAuth(repo, secrets, {
@ -100,16 +106,16 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer,
cookieName: this.config.cookieName,
totpIssuer,
cookieName,
});
const codeTtl = this.config.codeTtlSeconds;
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, {}) },
);
const firmware = initFirmware(
{ dataDir: this.config.dataDir },
{ dataDir },
{ 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 { setCoordinator } from "../../shared/coordinator-registry.js";
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
import { envStr } from "../../shared/env-overrides.js";
// ---- Config -----------------------------------------------------------------
@ -112,13 +113,18 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}
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 secrets = initSecrets(
{ dataDir: this.config.dataDir },
{ dataDir },
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
);
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, {}) },
);
this.nodered = nodered;
@ -131,8 +137,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
argon2Memory: this.config.argon2Memory,
argon2TimeCost: this.config.argon2TimeCost,
argon2Parallelism: this.config.argon2Parallelism,
totpIssuer: this.config.totpIssuer,
cookieName: this.config.cookieName,
totpIssuer,
cookieName,
});
const httpServer = createServer((req, res) => {

View file

@ -35,6 +35,7 @@ import {
import { MIGRATIONS } from "./migrations.js";
import { Repository } from "./repository.js";
import { registerRepo } from "../../shared/plugin-registry.js";
import { envStr } from "../../shared/env-overrides.js";
// ---- Config -----------------------------------------------------------------
@ -99,7 +100,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
}
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 });
// 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";
}