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) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-23 13:22:44 +02:00
parent bab194a184
commit 49d730cf7f
No known key found for this signature in database
13 changed files with 134 additions and 188 deletions

View file

@ -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:

View file

@ -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:

View file

@ -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<InstanceType<typeof Config>, typeof Event
async init(obs: Observable): Promise<void> {
// 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<InstanceType<typeof Config>, 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<InstanceType<typeof Config>, typeof Event
firmware,
osUpdates,
dataDir,
firmwareImportApiKey: this.config.firmwareImportApiKey || undefined,
otaImportApiKey: this.config.otaImportApiKey || undefined,
};
const app = new H3();

View file

@ -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);

View file

@ -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<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 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<InstanceType<typeof Config>, 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();

View file

@ -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<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 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(

View file

@ -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<InstanceType<typeof Config>, typeof Event
}
async init(obs: Observable): Promise<void> {
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<InstanceType<typeof Config>, 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 {

View file

@ -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,
});
}

View file

@ -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";
}

View file

@ -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 `<sha256>.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-----<body>-----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

View file

@ -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:
* <prefix>/<kiosk_id>/event/<topic> 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<string, unknown>) => void> = [];

View file

@ -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)) {

View file

@ -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;
}