mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
bab194a184
commit
49d730cf7f
13 changed files with 134 additions and 188 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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> = [];
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue