mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26: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.
|
# 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"
|
# BF_DATA_VOLUME_NAME default "betterframe-data"
|
||||||
# NODERED_DATA_VOLUME_NAME default "nodered-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"
|
version: "3.8"
|
||||||
|
|
||||||
|
|
@ -23,14 +23,6 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- 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:
|
volumes:
|
||||||
- betterframe-data:/var/lib/betterframe
|
- betterframe-data:/var/lib/betterframe
|
||||||
expose:
|
expose:
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,14 @@ default:
|
||||||
plugin: service-store
|
plugin: service-store
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
|
driver: postgres
|
||||||
sqlitePath: /var/lib/betterframe/betterframe.db
|
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) -----
|
# ----- Admin UI + API (includes secrets + auth config) -----
|
||||||
service-admin-http:
|
service-admin-http:
|
||||||
|
|
@ -45,6 +52,11 @@ default:
|
||||||
cookieName: betterframe_session
|
cookieName: betterframe_session
|
||||||
totpIssuer: BetterFrame
|
totpIssuer: BetterFrame
|
||||||
noderedUrl: http://127.0.0.1:1880
|
noderedUrl: http://127.0.0.1:1880
|
||||||
|
selfUrl: http://127.0.0.1:18080
|
||||||
|
systemdCredsDir: ""
|
||||||
|
firmwareSigningKey: ""
|
||||||
|
firmwareImportApiKey: ""
|
||||||
|
otaImportApiKey: ""
|
||||||
|
|
||||||
# ----- Kiosk-facing REST API -----
|
# ----- Kiosk-facing REST API -----
|
||||||
service-api-http:
|
service-api-http:
|
||||||
|
|
@ -59,6 +71,10 @@ default:
|
||||||
argon2TimeCost: 3
|
argon2TimeCost: 3
|
||||||
argon2Parallelism: 2
|
argon2Parallelism: 2
|
||||||
noderedUrl: http://127.0.0.1:1880
|
noderedUrl: http://127.0.0.1:1880
|
||||||
|
mqttUrl: ""
|
||||||
|
mqttUsername: ""
|
||||||
|
mqttPassword: ""
|
||||||
|
mqttTopicPrefix: betterframe
|
||||||
|
|
||||||
# ----- Live kiosk WebSocket channel -----
|
# ----- Live kiosk WebSocket channel -----
|
||||||
service-coordinator-ws:
|
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 { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
||||||
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
||||||
import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js";
|
import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.js";
|
||||||
import { envStr } from "../../shared/env-overrides.js";
|
|
||||||
import { serverVersion } from "../../shared/version.js";
|
import { serverVersion } from "../../shared/version.js";
|
||||||
import type { Repository } from "../service-store/repository.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"),
|
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.
|
// 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"),
|
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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -90,6 +97,8 @@ export interface AdminDeps {
|
||||||
firmware: FirmwareApi;
|
firmware: FirmwareApi;
|
||||||
osUpdates: OsUpdateApi;
|
osUpdates: OsUpdateApi;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
|
firmwareImportApiKey?: string;
|
||||||
|
otaImportApiKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Plugin -----------------------------------------------------------------
|
// ---- Plugin -----------------------------------------------------------------
|
||||||
|
|
@ -113,16 +122,15 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
|
|
||||||
async init(obs: Observable): Promise<void> {
|
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 = this.config.dataDir;
|
||||||
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
|
const noderedUrl = this.config.noderedUrl;
|
||||||
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
|
const selfUrl = this.config.selfUrl;
|
||||||
const selfUrl = envStr("BF_SELF_URL", this.config.selfUrl);
|
const cookieName = this.config.cookieName;
|
||||||
const cookieName = envStr("BF_COOKIE_NAME", this.config.cookieName);
|
const totpIssuer = this.config.totpIssuer;
|
||||||
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
|
|
||||||
|
|
||||||
const repo = getRepo();
|
const repo = getRepo();
|
||||||
const secrets = initSecrets(
|
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, {}) },
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
||||||
);
|
);
|
||||||
const auth = createAuth(repo, secrets, {
|
const auth = createAuth(repo, secrets, {
|
||||||
|
|
@ -143,7 +151,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
);
|
);
|
||||||
|
|
||||||
const firmware = initFirmware(
|
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, {}) },
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
||||||
);
|
);
|
||||||
const osUpdates = initOsUpdates({ dataDir });
|
const osUpdates = initOsUpdates({ dataDir });
|
||||||
|
|
@ -157,6 +165,8 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
firmware,
|
firmware,
|
||||||
osUpdates,
|
osUpdates,
|
||||||
dataDir,
|
dataDir,
|
||||||
|
firmwareImportApiKey: this.config.firmwareImportApiKey || undefined,
|
||||||
|
otaImportApiKey: this.config.otaImportApiKey || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const app = new H3();
|
const app = new H3();
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ function syntheticApiKeyUser(keyPrefix: string): User {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenMatchesEnv(token: string, envName: string): boolean {
|
function tokenMatchesExpected(token: string, expected: string | undefined): boolean {
|
||||||
const expected = process.env[envName]?.trim();
|
|
||||||
if (!expected || expected.length < 32 || token.length < 32) return false;
|
if (!expected || expected.length < 32 || token.length < 32) return false;
|
||||||
const a = createHash("sha256").update(token).digest();
|
const a = createHash("sha256").update(token).digest();
|
||||||
const b = createHash("sha256").update(expected).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);
|
const token = authz.slice(7);
|
||||||
if (
|
if (
|
||||||
(path === "/api/admin/firmware/import" || path === "/api/admin/os/import") &&
|
(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";
|
const label = path === "/api/admin/os/import" ? "ota-import" : "fw-import";
|
||||||
event.context.user = syntheticApiKeyUser(label);
|
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 { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
||||||
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
import { initFirmware, type FirmwareApi } from "../../shared/firmware.js";
|
||||||
import { initOsUpdates, type OsUpdateApi } from "../../shared/os-updates.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 { createRateLimiter } from "../../shared/rate-limit.js";
|
||||||
import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js";
|
import { initMqttBridge, type MqttBridge } from "../../shared/mqtt-bridge.js";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
|
@ -51,6 +50,11 @@ const ConfigSchema = av.object(
|
||||||
loginLockoutSeconds: av.int().min(1).default(900),
|
loginLockoutSeconds: av.int().min(1).default(900),
|
||||||
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
totpIssuer: av.string().minLength(1).default("BetterFrame"),
|
||||||
noderedUrl: av.string().minLength(1).default("http://127.0.0.1:1880"),
|
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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -91,10 +95,10 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(obs: Observable): Promise<void> {
|
async init(obs: Observable): Promise<void> {
|
||||||
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
|
const dataDir = this.config.dataDir;
|
||||||
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
|
const noderedUrl = this.config.noderedUrl;
|
||||||
const cookieName = envStr("BF_COOKIE_NAME", this.config.cookieName);
|
const cookieName = this.config.cookieName;
|
||||||
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
|
const totpIssuer = this.config.totpIssuer;
|
||||||
|
|
||||||
const repo = getRepo();
|
const repo = getRepo();
|
||||||
const secrets = initSecrets(
|
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, {}) },
|
{ info: (m) => obs.log.info(m as any, {}), warn: (m) => obs.log.warn(m as any, {}) },
|
||||||
);
|
);
|
||||||
const osUpdates = initOsUpdates({ dataDir });
|
const osUpdates = initOsUpdates({ dataDir });
|
||||||
const mqtt = initMqttBridge({
|
const mqtt = initMqttBridge(
|
||||||
info: (m) => obs.log.info(m as any, {}),
|
{
|
||||||
warn: (m) => obs.log.warn(m as any, {}),
|
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();
|
const app = new H3();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import { initSecrets } from "../../shared/secrets.js";
|
||||||
import { createAuth } from "../../shared/auth.js";
|
import { createAuth } from "../../shared/auth.js";
|
||||||
import { setCoordinator } from "../../shared/coordinator-registry.js";
|
import { setCoordinator } from "../../shared/coordinator-registry.js";
|
||||||
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
import { initNoderedBridge, type NoderedBridge } from "../../shared/nodered-bridge.js";
|
||||||
import { envStr } from "../../shared/env-overrides.js";
|
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -200,10 +199,10 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(obs: Observable): Promise<void> {
|
async init(obs: Observable): Promise<void> {
|
||||||
const dataDir = envStr("BF_DATA_DIR", this.config.dataDir);
|
const dataDir = this.config.dataDir;
|
||||||
const noderedUrl = envStr("BF_NODERED_URL", this.config.noderedUrl);
|
const noderedUrl = this.config.noderedUrl;
|
||||||
const cookieName = this.config.cookieName;
|
const cookieName = this.config.cookieName;
|
||||||
const totpIssuer = envStr("BF_TOTP_ISSUER", this.config.totpIssuer);
|
const totpIssuer = this.config.totpIssuer;
|
||||||
|
|
||||||
const repo = getRepo();
|
const repo = getRepo();
|
||||||
const secrets = initSecrets(
|
const secrets = initSecrets(
|
||||||
|
|
|
||||||
|
|
@ -35,18 +35,29 @@ import {
|
||||||
import { MIGRATIONS } from "./migrations.js";
|
import { MIGRATIONS } from "./migrations.js";
|
||||||
import { Repository } from "./repository.js";
|
import { Repository } from "./repository.js";
|
||||||
import { registerRepo } from "../../shared/plugin-registry.js";
|
import { registerRepo } from "../../shared/plugin-registry.js";
|
||||||
import { envStr } from "../../shared/env-overrides.js";
|
|
||||||
|
|
||||||
// ---- Config -----------------------------------------------------------------
|
// ---- Config -----------------------------------------------------------------
|
||||||
|
|
||||||
const ConfigSchema = av.object(
|
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"),
|
driver: av.enum_(["sqlite", "postgres"] as const).default("sqlite"),
|
||||||
/** sqlite-only: filesystem path to the .db file. */
|
/** sqlite-only: filesystem path to the .db file. */
|
||||||
sqlitePath: av.string().minLength(1).default("/var/lib/betterframe/betterframe.db"),
|
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(""),
|
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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
@ -104,15 +115,19 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(obs: Observable): Promise<void> {
|
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") {
|
if (driver === "postgres") {
|
||||||
const pgUrl = envStr("BF_PG_URL", this.config.pgUrl ?? "");
|
let pgUrl = this.config.pgUrl ?? "";
|
||||||
if (!pgUrl) throw new Error("BF_DB=postgres requires BF_PG_URL");
|
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(/:[^:@]+@/, ":***@") });
|
obs.log.info("connecting to postgres at {url}", { url: pgUrl.replace(/:[^:@]+@/, ":***@") });
|
||||||
|
|
||||||
const { PgAdapter } = await import("./pg-adapter.js");
|
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.
|
// Run PG migrations. Track version in schema_migrations table.
|
||||||
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
const { TENANT_MIGRATIONS } = await import("./migrations-pg.js");
|
||||||
|
|
@ -153,7 +168,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// SQLite path (default).
|
// 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 });
|
obs.log.info("opening sqlite at {path}", { path });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
* id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs
|
* id captures lastInsertRowid (caller must add `RETURNING id` to INSERTs
|
||||||
* that need it — same for SQLite path so the SQL strings are portable).
|
* 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";
|
import { Pool, type PoolClient } from "pg";
|
||||||
|
|
||||||
|
|
@ -18,10 +18,10 @@ export class PgAdapter implements DbAdapter {
|
||||||
private currentTxClient: PoolClient | null = null;
|
private currentTxClient: PoolClient | null = null;
|
||||||
private txDepth = 0;
|
private txDepth = 0;
|
||||||
|
|
||||||
constructor(connectionString: string) {
|
constructor(connectionString: string, poolMax: number = 10) {
|
||||||
this.pool = new Pool({
|
this.pool = new Pool({
|
||||||
connectionString,
|
connectionString,
|
||||||
max: Number(process.env["BF_PG_POOL_MAX"] ?? 10),
|
max: poolMax,
|
||||||
idleTimeoutMillis: 30_000,
|
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
|
* Key file lives at `${dataDir}/firmware-signing.key` (private, 0600) and
|
||||||
* `${dataDir}/firmware-signing.pub` (public, 0644). Both PEM-encoded.
|
* `${dataDir}/firmware-signing.pub` (public, 0644). Both PEM-encoded.
|
||||||
* If env var BF_FIRMWARE_SIGNING_KEY is set (PEM string), it overrides the
|
* If config.signingKeyPem is set (PEM string), it overrides the file —
|
||||||
* file — convenient for cloud deploys where the key comes from a secret
|
* convenient for cloud deploys where the key comes from a secret manager.
|
||||||
* manager.
|
|
||||||
*
|
*
|
||||||
* Storage for the firmware blobs themselves is `${dataDir}/firmware/`, one
|
* Storage for the firmware blobs themselves is `${dataDir}/firmware/`, one
|
||||||
* file per release, named `<sha256>.bin`. Hashing on insert dedupes binaries
|
* file per release, named `<sha256>.bin`. Hashing on insert dedupes binaries
|
||||||
|
|
@ -55,6 +54,10 @@ export interface FirmwareApi {
|
||||||
export interface FirmwareConfig {
|
export interface FirmwareConfig {
|
||||||
/** Server data dir (same as secrets dataDir, typically /var/lib/betterframe). */
|
/** Server data dir (same as secrets dataDir, typically /var/lib/betterframe). */
|
||||||
dataDir: string;
|
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 {
|
export interface FirmwareLog {
|
||||||
|
|
@ -72,7 +75,7 @@ export function initFirmware(config: FirmwareConfig, log: FirmwareLog): Firmware
|
||||||
mkdirSync(firmwareDir, { recursive: true, mode: 0o755 });
|
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 } {
|
function signBlob(bytes: Buffer): { sha256: string; signature: string } {
|
||||||
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
@ -137,31 +140,18 @@ function loadOrCreateKeyPair(
|
||||||
privPath: string,
|
privPath: string,
|
||||||
pubPath: string,
|
pubPath: string,
|
||||||
log: FirmwareLog,
|
log: FirmwareLog,
|
||||||
|
signingKeyOverride?: string,
|
||||||
): FirmwareKeyPair {
|
): FirmwareKeyPair {
|
||||||
// Env override for cloud / k8s — full private key PEM in a single var.
|
// Config override for cloud / k8s — full private key PEM passed via config.
|
||||||
// Coolify / shell env vars frequently mangle newlines (escaped `\n` instead
|
if (signingKeyOverride && signingKeyOverride.trim().length > 0) {
|
||||||
// of real LF, CRLF, or wrapping quotes). Try multiple normalisations before
|
try {
|
||||||
// giving up and falling through to the on-disk / generated path.
|
const parsed = createPrivateKey({ key: signingKeyOverride.trim(), format: "pem" });
|
||||||
const envKey = process.env["BF_FIRMWARE_SIGNING_KEY"];
|
|
||||||
if (envKey && envKey.trim().length > 0) {
|
|
||||||
const parsed = tryParsePrivateKey(envKey);
|
|
||||||
if (parsed) {
|
|
||||||
const pub = createPublicKey(parsed).export({ format: "pem", type: "spki" });
|
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) };
|
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)) {
|
if (existsSync(privPath) && existsSync(pubPath)) {
|
||||||
|
|
@ -180,71 +170,6 @@ function loadOrCreateKeyPair(
|
||||||
return { privateKey, publicKeyPem: pubPem };
|
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
|
* Standalone verifier used by anyone with just the public key (kiosk-side
|
||||||
* equivalent of `verifyBlob` lives in Rust — this is for server-side checks
|
* 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
|
* Generic MQTT telemetry bridge. Off by default — enable by setting
|
||||||
* `BF_MQTT_URL=mqtt://broker:1883` (or mqtts:// for TLS). Optional
|
* `mqttUrl` in the service-api-http config (e.g. `mqtt://broker:1883`
|
||||||
* `BF_MQTT_USERNAME`, `BF_MQTT_PASSWORD`, `BF_MQTT_TOPIC_PREFIX` (default
|
* or `mqtts://` for TLS). Optional `mqttUsername`, `mqttPassword`,
|
||||||
* "betterframe").
|
* `mqttTopicPrefix` (default "betterframe").
|
||||||
*
|
*
|
||||||
* Outbound topics:
|
* Outbound topics:
|
||||||
* <prefix>/<kiosk_id>/event/<topic> server-side events (camera.changed,
|
* <prefix>/<kiosk_id>/event/<topic> server-side events (camera.changed,
|
||||||
|
|
@ -39,13 +39,22 @@ const NOOP_BRIDGE: MqttBridge = {
|
||||||
end: () => {},
|
end: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function initMqttBridge(log: MqttBridgeLog): MqttBridge {
|
export interface MqttConfig {
|
||||||
const url = (process.env["BF_MQTT_URL"] ?? "").trim();
|
/** 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;
|
if (!url) return NOOP_BRIDGE;
|
||||||
|
|
||||||
const prefix = (process.env["BF_MQTT_TOPIC_PREFIX"] ?? "betterframe").replace(/\/+$/, "");
|
const prefix = (config.topicPrefix ?? "betterframe").replace(/\/+$/, "");
|
||||||
const username = process.env["BF_MQTT_USERNAME"];
|
const username = config.username;
|
||||||
const password = process.env["BF_MQTT_PASSWORD"];
|
const password = config.password;
|
||||||
|
|
||||||
let client: MqttClient | undefined;
|
let client: MqttClient | undefined;
|
||||||
let rpcHandlers: Array<(k: number, m: string, b: Record<string, unknown>) => void> = [];
|
let rpcHandlers: Array<(k: number, m: string, b: Record<string, unknown>) => void> = [];
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ import {
|
||||||
export interface SecretsConfig {
|
export interface SecretsConfig {
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
systemdCredsName?: string;
|
systemdCredsName?: string;
|
||||||
|
/** Systemd credentials directory (e.g. /run/credentials/betterframe.service). */
|
||||||
|
systemdCredsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SecretsLog {
|
export interface SecretsLog {
|
||||||
|
|
@ -105,7 +107,7 @@ function loadServerKey(config: SecretsConfig, log: SecretsLog): Buffer {
|
||||||
const credsName = config.systemdCredsName ?? "betterframe-secret";
|
const credsName = config.systemdCredsName ?? "betterframe-secret";
|
||||||
|
|
||||||
// 1. systemd-creds
|
// 1. systemd-creds
|
||||||
const credsDir = process.env["CREDENTIALS_DIRECTORY"];
|
const credsDir = config.systemdCredsDir;
|
||||||
if (credsDir) {
|
if (credsDir) {
|
||||||
const p = join(credsDir, credsName);
|
const p = join(credsDir, credsName);
|
||||||
if (existsSync(p)) {
|
if (existsSync(p)) {
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,14 @@
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
const BAKED_VERSION = (() => {
|
let cached: string | null = null;
|
||||||
try {
|
|
||||||
const v = readFileSync("/app/server/.bf-version", "utf8").trim();
|
|
||||||
return v && v !== "dev" ? v : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
export function serverVersion(): string {
|
export function serverVersion(): string {
|
||||||
return (
|
if (cached) return cached;
|
||||||
process.env.BF_SERVER_VERSION
|
try {
|
||||||
|| process.env.BF_BUILD_VERSION
|
const v = readFileSync("/app/server/.bf-version", "utf8").trim();
|
||||||
|| BAKED_VERSION
|
cached = v && v !== "dev" ? v : "dev";
|
||||||
|| process.env.COOLIFY_GIT_COMMIT
|
} catch {
|
||||||
|| process.env.SOURCE_COMMIT
|
cached = "dev";
|
||||||
|| "dev"
|
}
|
||||||
);
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue