From 49d730cf7f0c501b63b72603f58b9b8ba438ec88 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Sat, 23 May 2026 13:22:44 +0200 Subject: [PATCH] refactor: remove all process.env and envStr() from server code All runtime config now flows exclusively through BSB plugin config (this.config.*) or shared module parameters. No more env var overrides. Changes: - Delete shared/env-overrides.ts (envStr/envBool/envInt helpers) - version.ts: remove env var chain, keep only .bf-version file + "dev" - firmware.ts: replace BF_FIRMWARE_SIGNING_KEY env with config.signingKeyPem parameter, remove tryParsePrivateKey helper - secrets.ts: replace process.env.CREDENTIALS_DIRECTORY with config.systemdCredsDir - mqtt-bridge.ts: accept MqttConfig object instead of reading process.env - service-store: replace envStr calls with this.config.*, build pgUrl from config fields, add pgPoolMax config - pg-adapter.ts: accept poolMax constructor param instead of env var - service-admin-http: add firmwareSigningKey, firmwareImportApiKey, otaImportApiKey, systemdCredsDir config fields; pass to shared modules - middleware.ts: replace tokenMatchesEnv with tokenMatchesExpected using deps.firmwareImportApiKey/otaImportApiKey - service-api-http: add mqttUrl/mqttUsername/mqttPassword/mqttTopicPrefix config fields; pass to initMqttBridge - service-coordinator-ws: replace envStr calls with this.config.* - sec-config.yaml: add all new config fields with sensible defaults - docker-compose.coolify.yml: remove all BF_* env vars from server service Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.coolify.yml | 14 +-- sec-config.yaml | 16 +++ .../src/plugins/service-admin-http/index.ts | 28 +++-- .../plugins/service-admin-http/middleware.ts | 5 +- server/src/plugins/service-api-http/index.ts | 30 +++-- .../plugins/service-coordinator-ws/index.ts | 7 +- server/src/plugins/service-store/index.ts | 31 ++++-- .../src/plugins/service-store/pg-adapter.ts | 6 +- server/src/shared/env-overrides.ts | 26 ----- server/src/shared/firmware.ts | 105 +++--------------- server/src/shared/mqtt-bridge.ts | 25 +++-- server/src/shared/secrets.ts | 4 +- server/src/shared/version.ts | 25 ++--- 13 files changed, 134 insertions(+), 188 deletions(-) delete mode 100644 server/src/shared/env-overrides.ts diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml index 399473b..1864bbc 100644 --- a/docker-compose.coolify.yml +++ b/docker-compose.coolify.yml @@ -4,11 +4,11 @@ # # Point Coolify resource at this file instead of docker-compose.yml. # -# Same env / volume overrides as the standard compose: +# Volume name overrides: # BF_DATA_VOLUME_NAME default "betterframe-data" # NODERED_DATA_VOLUME_NAME default "nodered-data" -# BF_DATA_DIR, BF_SQLITE_PATH, BF_NODERED_URL, BF_SELF_URL, -# BF_FIRMWARE_SIGNING_KEY, BF_MQTT_* +# +# Server config comes from sec-config.yaml, not env vars. version: "3.8" @@ -23,14 +23,6 @@ services: restart: unless-stopped 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 - - BF_SERVER_VERSION=${BF_SERVER_VERSION:-${COOLIFY_GIT_COMMIT:-${SOURCE_COMMIT:-dev}}} - # PostgreSQL: set BF_DB=postgres to switch from SQLite. - - BF_DB=postgres - - BF_PG_URL=${BF_PG_URL:-postgres://${BF_PG_USER:-betterframe}:${BF_PG_PASSWORD:-betterframe}@postgres:5432/${BF_PG_DB:-betterframe}} volumes: - betterframe-data:/var/lib/betterframe expose: diff --git a/sec-config.yaml b/sec-config.yaml index d031199..efcea24 100644 --- a/sec-config.yaml +++ b/sec-config.yaml @@ -23,7 +23,14 @@ default: plugin: service-store enabled: true config: + driver: postgres sqlitePath: /var/lib/betterframe/betterframe.db + pgHost: postgres + pgPort: 5432 + pgDatabase: betterframe + pgUser: betterframe + pgPassword: betterframe + pgPoolMax: 10 # ----- Admin UI + API (includes secrets + auth config) ----- service-admin-http: @@ -45,6 +52,11 @@ default: cookieName: betterframe_session totpIssuer: BetterFrame noderedUrl: http://127.0.0.1:1880 + selfUrl: http://127.0.0.1:18080 + systemdCredsDir: "" + firmwareSigningKey: "" + firmwareImportApiKey: "" + otaImportApiKey: "" # ----- Kiosk-facing REST API ----- service-api-http: @@ -59,6 +71,10 @@ default: argon2TimeCost: 3 argon2Parallelism: 2 noderedUrl: http://127.0.0.1:1880 + mqttUrl: "" + mqttUsername: "" + mqttPassword: "" + mqttTopicPrefix: betterframe # ----- Live kiosk WebSocket channel ----- service-coordinator-ws: diff --git a/server/src/plugins/service-admin-http/index.ts b/server/src/plugins/service-admin-http/index.ts index 5360e59..75ff21d 100644 --- a/server/src/plugins/service-admin-http/index.ts +++ b/server/src/plugins/service-admin-http/index.ts @@ -21,7 +21,6 @@ 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 { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; -import { envStr } from "../../shared/env-overrides.js"; import { serverVersion } from "../../shared/version.js"; import type { Repository } from "../service-store/repository.js"; @@ -57,6 +56,14 @@ const ConfigSchema = av.object( 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"), + /** Systemd credentials directory. */ + systemdCredsDir: av.string().default(""), + /** PEM-encoded Ed25519 private key for firmware signing (cloud deploys). */ + firmwareSigningKey: av.string().default(""), + /** Bearer token for CI firmware import endpoint. */ + firmwareImportApiKey: av.string().default(""), + /** Bearer token for CI OTA import endpoint. */ + otaImportApiKey: av.string().default(""), }, { unknownKeys: "strip" }, ); @@ -90,6 +97,8 @@ export interface AdminDeps { firmware: FirmwareApi; osUpdates: OsUpdateApi; dataDir: string; + firmwareImportApiKey?: string; + otaImportApiKey?: string; } // ---- Plugin ----------------------------------------------------------------- @@ -113,16 +122,15 @@ export class Plugin extends BSBService, typeof Event async init(obs: Observable): Promise { // 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 dataDir = this.config.dataDir; + const noderedUrl = this.config.noderedUrl; + const selfUrl = this.config.selfUrl; + const cookieName = this.config.cookieName; + const totpIssuer = this.config.totpIssuer; const repo = getRepo(); const secrets = initSecrets( - { dataDir, systemdCredsName: this.config.systemdCredsName }, + { dataDir, systemdCredsName: this.config.systemdCredsName, systemdCredsDir: this.config.systemdCredsDir || undefined }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); const auth = createAuth(repo, secrets, { @@ -143,7 +151,7 @@ export class Plugin extends BSBService, typeof Event ); const firmware = initFirmware( - { dataDir }, + { dataDir, signingKeyPem: this.config.firmwareSigningKey || undefined }, { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); const osUpdates = initOsUpdates({ dataDir }); @@ -157,6 +165,8 @@ export class Plugin extends BSBService, typeof Event firmware, osUpdates, dataDir, + firmwareImportApiKey: this.config.firmwareImportApiKey || undefined, + otaImportApiKey: this.config.otaImportApiKey || undefined, }; const app = new H3(); diff --git a/server/src/plugins/service-admin-http/middleware.ts b/server/src/plugins/service-admin-http/middleware.ts index 8502116..40f2d96 100644 --- a/server/src/plugins/service-admin-http/middleware.ts +++ b/server/src/plugins/service-admin-http/middleware.ts @@ -37,8 +37,7 @@ function syntheticApiKeyUser(keyPrefix: string): User { }; } -function tokenMatchesEnv(token: string, envName: string): boolean { - const expected = process.env[envName]?.trim(); +function tokenMatchesExpected(token: string, expected: string | undefined): boolean { if (!expected || expected.length < 32 || token.length < 32) return false; const a = createHash("sha256").update(token).digest(); const b = createHash("sha256").update(expected).digest(); @@ -81,7 +80,7 @@ export function registerMiddleware(app: H3, deps: AdminDeps): void { const token = authz.slice(7); if ( (path === "/api/admin/firmware/import" || path === "/api/admin/os/import") && - (tokenMatchesEnv(token, "BF_FIRMWARE_IMPORT_API_KEY") || tokenMatchesEnv(token, "BF_OTA_IMPORT_API_KEY")) + (tokenMatchesExpected(token, deps.firmwareImportApiKey) || tokenMatchesExpected(token, deps.otaImportApiKey)) ) { const label = path === "/api/admin/os/import" ? "ota-import" : "fw-import"; event.context.user = syntheticApiKeyUser(label); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index ca13ee5..d36f4c0 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -23,7 +23,6 @@ import { generateBundle } from "../../shared/bundle.js"; import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js"; import { initFirmware, type FirmwareApi } from "../../shared/firmware.js"; import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js"; -import { envStr } from "../../shared/env-overrides.js"; import { createRateLimiter } from "../../shared/rate-limit.js"; import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js"; import { createHash } from "node:crypto"; @@ -51,6 +50,11 @@ const ConfigSchema = av.object( loginLockoutSeconds: av.int().min(1).default(900), totpIssuer: av.string().minLength(1).default("BetterFrame"), noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"), + /** MQTT broker URL (e.g. mqtt://broker:1883). Empty = disabled. */ + mqttUrl: av.string().default(""), + mqttUsername: av.string().default(""), + mqttPassword: av.string().default(""), + mqttTopicPrefix: av.string().default("betterframe"), }, { unknownKeys: "strip" }, ); @@ -91,10 +95,10 @@ 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 dataDir = this.config.dataDir; + const noderedUrl = this.config.noderedUrl; + const cookieName = this.config.cookieName; + const totpIssuer = this.config.totpIssuer; const repo = getRepo(); const secrets = initSecrets( @@ -122,10 +126,18 @@ export class Plugin extends BSBService, typeof Event { info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) }, ); const osUpdates = initOsUpdates({ dataDir }); - const mqtt = initMqttBridge({ - info: (m) => obs.log.info(m as any, {}), - warn: (m) => obs.log.warn(m as any, {}), - }); + const mqtt = initMqttBridge( + { + url: this.config.mqttUrl, + username: this.config.mqttUsername || undefined, + password: this.config.mqttPassword || undefined, + topicPrefix: this.config.mqttTopicPrefix, + }, + { + info: (m) => obs.log.info(m as any, {}), + warn: (m) => obs.log.warn(m as any, {}), + }, + ); const app = new H3(); diff --git a/server/src/plugins/service-coordinator-ws/index.ts b/server/src/plugins/service-coordinator-ws/index.ts index 356d5a4..240afae 100644 --- a/server/src/plugins/service-coordinator-ws/index.ts +++ b/server/src/plugins/service-coordinator-ws/index.ts @@ -28,7 +28,6 @@ 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 ----------------------------------------------------------------- @@ -200,10 +199,10 @@ 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 dataDir = this.config.dataDir; + const noderedUrl = this.config.noderedUrl; const cookieName = this.config.cookieName; - const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer); + const totpIssuer = this.config.totpIssuer; const repo = getRepo(); const secrets = initSecrets( diff --git a/server/src/plugins/service-store/index.ts b/server/src/plugins/service-store/index.ts index 967473b..db6f472 100644 --- a/server/src/plugins/service-store/index.ts +++ b/server/src/plugins/service-store/index.ts @@ -35,18 +35,29 @@ 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 ----------------------------------------------------------------- const ConfigSchema = av.object( { - /** Backend selector. Override at runtime via BF_DB env. */ + /** Backend selector: "sqlite" or "postgres". */ driver: av.enum_(["sqlite", "postgres"] as const).default("sqlite"), /** sqlite-only: filesystem path to the .db file. */ sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"), - /** postgres-only: full libpq URL. Override via BF_PG_URL env. */ + /** postgres: full libpq URL. Overrides individual pg* fields if non-empty. */ pgUrl: av.string().default(""), + /** postgres: host. */ + pgHost: av.string().default("postgres"), + /** postgres: port. */ + pgPort: av.int().min(1).max(65535).default(5432), + /** postgres: database name. */ + pgDatabase: av.string().default("betterframe"), + /** postgres: username. */ + pgUser: av.string().default("betterframe"), + /** postgres: password. */ + pgPassword: av.string().default("betterframe"), + /** postgres: connection pool max size. */ + pgPoolMax: av.int().min(1).max(1000).default(10), }, { unknownKeys: "strip" }, ); @@ -104,15 +115,19 @@ export class Plugin extends BSBService, typeof Event } async init(obs: Observable): Promise { - const driver = envStr("BF_DB", this.config.driver) as "sqlite" | "postgres"; + const driver = this.config.driver; if (driver === "postgres") { - const pgUrl = envStr("BF_PG_URL", this.config.pgUrl ?? ""); - if (!pgUrl) throw new Error("BF_DB=postgres requires BF_PG_URL"); + let pgUrl = this.config.pgUrl ?? ""; + if (!pgUrl) { + const u = encodeURIComponent(this.config.pgUser); + const p = encodeURIComponent(this.config.pgPassword); + pgUrl = `postgres://${u}:${p}@${this.config.pgHost}:${this.config.pgPort}/${this.config.pgDatabase}`; + } obs.log.info("connecting to postgres at {url}", { url: pgUrl.replace(/:[^:@]+@/, ":***@") }); const { PgAdapter } = await import("./pg-adapter.js"); - const adapter = new PgAdapter(pgUrl); + const adapter = new PgAdapter(pgUrl, this.config.pgPoolMax); // Run PG migrations. Track version in schema_migrations table. const { TENANT_MIGRATIONS } = await import("./migrations-pg.js"); @@ -153,7 +168,7 @@ export class Plugin extends BSBService, typeof Event }); } else { // SQLite path (default). - const path = envStr("BF_SQLITE_PATH", this.config.sqlitePath); + const path = this.config.sqlitePath; obs.log.info("opening sqlite at {path}", { path }); try { diff --git a/server/src/plugins/service-store/pg-adapter.ts b/server/src/plugins/service-store/pg-adapter.ts index 5bc2cef..d92bdd3 100644 --- a/server/src/plugins/service-store/pg-adapter.ts +++ b/server/src/plugins/service-store/pg-adapter.ts @@ -6,7 +6,7 @@ * id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs * that need it — same for SQLite path so the SQL strings are portable). * - * Pool size: PG default of 10 — bumpable via BF_PG_POOL_MAX env if needed. + * Pool size: default 10 — configurable via pgPoolMax in sec-config.yaml. */ import { Pool, type PoolClient } from "pg"; @@ -18,10 +18,10 @@ export class PgAdapter implements DbAdapter { private currentTxClient: PoolClient | null = null; private txDepth = 0; - constructor(connectionString: string) { + constructor(connectionString: string, poolMax: number = 10) { this.pool = new Pool({ connectionString, - max: Number(process.env["BF_PG_POOL_MAX"] ?? 10), + max: poolMax, idleTimeoutMillis: 30_000, }); } diff --git a/server/src/shared/env-overrides.ts b/server/src/shared/env-overrides.ts deleted file mode 100644 index 5f768c9..0000000 --- a/server/src/shared/env-overrides.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * 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"; -} diff --git a/server/src/shared/firmware.ts b/server/src/shared/firmware.ts index 82d62c1..75f417d 100644 --- a/server/src/shared/firmware.ts +++ b/server/src/shared/firmware.ts @@ -9,9 +9,8 @@ * * Key file lives at `${dataDir}/firmware-signing.key` (private, 0600) and * `${dataDir}/firmware-signing.pub` (public, 0644). Both PEM-encoded. - * If env var BF_FIRMWARE_SIGNING_KEY is set (PEM string), it overrides the - * file — convenient for cloud deploys where the key comes from a secret - * manager. + * If config.signingKeyPem is set (PEM string), it overrides the file — + * convenient for cloud deploys where the key comes from a secret manager. * * Storage for the firmware blobs themselves is `${dataDir}/firmware/`, one * file per release, named `.bin`. Hashing on insert dedupes binaries @@ -55,6 +54,10 @@ export interface FirmwareApi { export interface FirmwareConfig { /** Server data dir (same as secrets dataDir, typically /var/lib/betterframe). */ dataDir: string; + /** Optional PEM-encoded Ed25519 private key. If provided and non-empty, used + * instead of loading/generating from the filesystem. Convenient for cloud + * deploys where the key comes from a secret manager. */ + signingKeyPem?: string; } export interface FirmwareLog { @@ -72,7 +75,7 @@ export function initFirmware(config: FirmwareConfig, log: FirmwareLog): Firmware mkdirSync(firmwareDir, { recursive: true, mode: 0o755 }); } - let keyPair = loadOrCreateKeyPair(keyDir, privPath, pubPath, log); + let keyPair = loadOrCreateKeyPair(keyDir, privPath, pubPath, log, config.signingKeyPem); function signBlob(bytes: Buffer): { sha256: string; signature: string } { const sha256 = createHash("sha256").update(bytes).digest("hex"); @@ -137,31 +140,18 @@ function loadOrCreateKeyPair( privPath: string, pubPath: string, log: FirmwareLog, + signingKeyOverride?: string, ): FirmwareKeyPair { - // Env override for cloud / k8s — full private key PEM in a single var. - // Coolify / shell env vars frequently mangle newlines (escaped `\n` instead - // of real LF, CRLF, or wrapping quotes). Try multiple normalisations before - // giving up and falling through to the on-disk / generated path. - const envKey = process.env["BF_FIRMWARE_SIGNING_KEY"]; - if (envKey && envKey.trim().length > 0) { - const parsed = tryParsePrivateKey(envKey); - if (parsed) { + // Config override for cloud / k8s — full private key PEM passed via config. + if (signingKeyOverride && signingKeyOverride.trim().length > 0) { + try { + const parsed = createPrivateKey({ key: signingKeyOverride.trim(), format: "pem" }); const pub = createPublicKey(parsed).export({ format: "pem", type: "spki" }); - log.info("firmware: signing key loaded from BF_FIRMWARE_SIGNING_KEY env"); + log.info("firmware: signing key loaded from config"); return { privateKey: parsed, publicKeyPem: String(pub) }; + } catch { + log.warn("firmware: config signingKeyPem failed PEM parse, falling back to on-disk key"); } - // Diagnostic dump so the operator can spot common pitfalls (smart quotes, - // wrong key type, base64-of-binary instead of base64-of-PEM, etc). - const head = envKey.slice(0, 60).replace(/\n/g, "\\n"); - const tail = envKey.slice(-40).replace(/\n/g, "\\n"); - const hexFirst = Array.from(envKey.slice(0, 8)) - .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) - .join(" "); - log.warn( - `firmware: BF_FIRMWARE_SIGNING_KEY (${String(envKey.length)} chars) failed PEM parse. ` + - `head="${head}" tail="${tail}" hex0..7=${hexFirst}. ` + - `Falling back to on-disk key / fresh generation.`, - ); } if (existsSync(privPath) && existsSync(pubPath)) { @@ -180,71 +170,6 @@ function loadOrCreateKeyPair( return { privateKey, publicKeyPem: pubPem }; } -/** - * Try several normalisations of an env-supplied PEM string. Coolify / docker - * compose env passing routinely strips real newlines, wraps in quotes, - * injects smart-quote unicode, drops BOMs in front, or doubles up escapes. - */ -function tryParsePrivateKey(raw: string): KeyObject | null { - const candidates: string[] = []; - - // Always start with a "cleaned" baseline: strip BOM + smart quotes → - // ASCII quotes + trim. Most env-injection quirks land here. - const cleaned = raw - .replace(/^/, "") - .replace(/[“”]/g, '"') - .replace(/[‘’]/g, "'") - .trim(); - - candidates.push(cleaned); - - // \n / \r\n escape sequences → real newlines. - if (cleaned.includes("\\n")) { - candidates.push(cleaned.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n")); - } - // CRLF → LF. - if (cleaned.includes("\r")) candidates.push(cleaned.replace(/\r\n?/g, "\n")); - // Strip surrounding single / double quotes (one or more layers). - let unq = cleaned; - while (/^["'](.*)["']$/s.test(unq)) { - const m = unq.match(/^["'](.*)["']$/s)!; - unq = m[1]!; - } - if (unq !== cleaned) candidates.push(unq); - // Combination: stripped + escape-decoded. - if (unq.includes("\\n")) { - candidates.push(unq.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n")); - } - // Base64-encoded entire PEM (some platforms recommend this for safety). - if (/^[A-Za-z0-9+/=\s]+$/.test(cleaned)) { - try { - const decoded = Buffer.from(cleaned.replace(/\s+/g, ""), "base64").toString("utf8"); - if (decoded.includes("BEGIN")) candidates.push(decoded); - } catch { /* ignore */ } - } - // Recover from "BEGIN PRIVATE KEY----------END PRIVATE KEY" with no - // internal line breaks: re-inject 64-char-wide line breaks around the body. - for (const variant of [cleaned, unq]) { - if (/-----BEGIN [^-]+-----.*-----END [^-]+-----/.test(variant) - && !variant.includes("\n")) { - const pemMatch = variant.match(/-----BEGIN ([^-]+)-----(.*)-----END \1-----/s); - if (pemMatch) { - const header = pemMatch[1]!; - const body = pemMatch[2]!.replace(/\s+/g, ""); - const wrapped = body.match(/.{1,64}/g)?.join("\n") ?? body; - candidates.push(`-----BEGIN ${header}-----\n${wrapped}\n-----END ${header}-----\n`); - } - } - } - - for (const c of candidates) { - try { - return createPrivateKey({ key: c, format: "pem" }); - } catch { /* try next */ } - } - return null; -} - /** * Standalone verifier used by anyone with just the public key (kiosk-side * equivalent of `verifyBlob` lives in Rust — this is for server-side checks diff --git a/server/src/shared/mqtt-bridge.ts b/server/src/shared/mqtt-bridge.ts index ffd1d01..0c9c8a9 100644 --- a/server/src/shared/mqtt-bridge.ts +++ b/server/src/shared/mqtt-bridge.ts @@ -1,8 +1,8 @@ /** * Generic MQTT telemetry bridge. Off by default — enable by setting - * `BF_MQTT_URL=mqtt://broker:1883` (or mqtts:// for TLS). Optional - * `BF_MQTT_USERNAME`, `BF_MQTT_PASSWORD`, `BF_MQTT_TOPIC_PREFIX` (default - * "betterframe"). + * `mqttUrl` in the service-api-http config (e.g. `mqtt://broker:1883` + * or `mqtts://` for TLS). Optional `mqttUsername`, `mqttPassword`, + * `mqttTopicPrefix` (default "betterframe"). * * Outbound topics: * //event/ server-side events (camera.changed, @@ -39,13 +39,22 @@ const NOOP_BRIDGE: MqttBridge = { end: () => {}, }; -export function initMqttBridge(log: MqttBridgeLog): MqttBridge { - const url = (process.env["BF_MQTT_URL"] ?? "").trim(); +export interface MqttConfig { + /** MQTT broker URL (e.g. mqtt://broker:1883). Empty = disabled. */ + url: string; + username?: string; + password?: string; + /** Topic prefix for all MQTT messages. Default "betterframe". */ + topicPrefix: string; +} + +export function initMqttBridge(config: MqttConfig, log: MqttBridgeLog): MqttBridge { + const url = (config.url ?? "").trim(); if (!url) return NOOP_BRIDGE; - const prefix = (process.env["BF_MQTT_TOPIC_PREFIX"] ?? "betterframe").replace(/\/+$/, ""); - const username = process.env["BF_MQTT_USERNAME"]; - const password = process.env["BF_MQTT_PASSWORD"]; + const prefix = (config.topicPrefix ?? "betterframe").replace(/\/+$/, ""); + const username = config.username; + const password = config.password; let client: MqttClient | undefined; let rpcHandlers: Array<(k: number, m: string, b: Record) => void> = []; diff --git a/server/src/shared/secrets.ts b/server/src/shared/secrets.ts index 30a7510..b3ceadd 100644 --- a/server/src/shared/secrets.ts +++ b/server/src/shared/secrets.ts @@ -23,6 +23,8 @@ import { export interface SecretsConfig { dataDir: string; systemdCredsName?: string; + /** Systemd credentials directory (e.g. /run/credentials/betterframe.service). */ + systemdCredsDir?: string; } export interface SecretsLog { @@ -105,7 +107,7 @@ function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer { const credsName = config.systemdCredsName ?? "betterframe-secret"; // 1. systemd-creds - const credsDir = process.env["CREDENTIALS_DIRECTORY"]; + const credsDir = config.systemdCredsDir; if (credsDir) { const p = join(credsDir, credsName); if (existsSync(p)) { diff --git a/server/src/shared/version.ts b/server/src/shared/version.ts index c74aa99..41f7a77 100644 --- a/server/src/shared/version.ts +++ b/server/src/shared/version.ts @@ -1,21 +1,14 @@ import { readFileSync } from "node:fs"; -const BAKED_VERSION = (() => { - try { - const v = readFileSync("/app/server/.bf-version", "utf8").trim(); - return v && v !== "dev" ? v : null; - } catch { - return null; - } -})(); +let cached: string | null = null; export function serverVersion(): string { - return ( - process.env.BF_SERVER_VERSION - || process.env.BF_BUILD_VERSION - || BAKED_VERSION - || process.env.COOLIFY_GIT_COMMIT - || process.env.SOURCE_COMMIT - || "dev" - ); + if (cached) return cached; + try { + const v = readFileSync("/app/server/.bf-version", "utf8").trim(); + cached = v && v !== "dev" ? v : "dev"; + } catch { + cached = "dev"; + } + return cached; }