diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml index dca4cfa..300ccfc 100644 --- a/deploy/docker/docker-compose.yml +++ b/deploy/docker/docker-compose.yml @@ -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 diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 964b2b5..bd44a7b 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -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, typeof Event } async init(obs: Observable): Promise { - // 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, 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, typeof Event repo, auth, secrets, - cookieName: this.config.cookieName, + cookieName, nodered, firmware, }; @@ -214,7 +222,7 @@ export class Plugin extends BSBService, 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 {} @@ -224,6 +232,7 @@ export class Plugin extends BSBService, typeof Event secrets: SecretsApi, auth: AuthApi, nodered: NoderedBridge, + selfUrl: string, obs: Observable, ): Promise { let plaintext: string; @@ -241,12 +250,12 @@ export class Plugin extends BSBService, 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; } diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 4f7f5b9..fc571aa 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -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, typeof Event } async init(obs: Observable): Promise { + 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, 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, {}) }, ); diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 6f4a3fd..7e2fde9 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -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, typeof Event } async init(obs: Observable): Promise { + 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, 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) => { diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index 58d21ec..c80f4f7 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -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, typeof Event } async init(obs: Observable): Promise { - 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) diff --git a/server/src/shared/env-overrides.ts b/server/src/shared/env-overrides.ts new file mode 100644 index 0000000..5f768c9 --- /dev/null +++ b/server/src/shared/env-overrides.ts @@ -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"; +}