From dae5d0ce8846f942d8b2355b390f1660f5432b30 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Wed, 20 May 2026 03:18:11 +0200 Subject: [PATCH] feat(managed-config): server-side scaffold for Pi-image device config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiosks running our pre-built image (managed_image=true at pairing) can have their hostname, timezone, network (DHCP/static + VLAN), and Wi-Fi configured from the admin UI. Pull-model: server stores desired-state JSON, kiosk heartbeat returns pending_config when version exceeds applied_version, kiosk echoes applied_version back. Wi-Fi PSK encrypted with the cluster key so ciphertext at rest is shipped to the kiosk without per-kiosk re-encryption. Server side only — kiosk Rust applier (betterframe-apply-config helper + rollback timer) and pair-initiate marker file are next. ci(pi-gen): use action's image-path output for asset upload pi-gen writes the .img.xz into pi-gen-action's own working dir, not our repo deploy/. Glob never matched. Use steps.pigen.outputs.image-path directly — no glob needed. --- .github/workflows/build.yml | 13 +- .../service-admin-http/routes-admin.ts | 75 ++++++++++++ server/src/plugins/service-api-http/index.ts | 49 +++++++- server/src/plugins/service-store/mappers.ts | 6 + .../src/plugins/service-store/migrations.ts | 14 +++ .../src/plugins/service-store/repository.ts | 6 +- server/src/schemas/wire/managed-config.ts | 55 +++++++++ server/src/schemas/wire/pairing.ts | 3 + server/src/shared/pairing.ts | 5 +- server/src/shared/types.ts | 8 ++ server/src/web-templates/admin-pages.tsx | 112 ++++++++++++++++++ 11 files changed, 338 insertions(+), 8 deletions(-) create mode 100644 server/src/schemas/wire/managed-config.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ef4cb6..00f1104 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -179,6 +179,7 @@ jobs: deploy/pi-gen/stage-betterframe-client/prerun.sh - name: Build Pi image (pi-gen) + id: pigen uses: usimd/pi-gen-action@v1.11.0 with: image-name: betterframe-client-${{ inputs.version }} @@ -194,12 +195,16 @@ jobs: # Surface pi-gen's stdout/stderr (default suppressed). verbose-output: true - - name: List pi-gen output - run: ls -la deploy/ || true + - name: Show pi-gen output path + run: | + echo "image-path: ${{ steps.pigen.outputs.image-path }}" + ls -la "$(dirname '${{ steps.pigen.outputs.image-path }}')" || true + # pi-gen writes the .img.xz under its own checkout (inside pi-gen-action's + # working dir), not our repo deploy/. The action exposes the exact path + # via the `image-path` output — use it directly instead of globbing. - name: Upload image to GitHub Release uses: softprops/action-gh-release@v3 with: tag_name: ${{ inputs.tag }} - files: | - deploy/*.img.xz + files: ${{ steps.pigen.outputs.image-path }} diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index ec1209c..cf3603b 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -1318,6 +1318,81 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); + // Managed-image device config — admin pushes hostname/timezone/network/wifi + // for kiosks running our pre-built Pi image. Builds a ManagedConfig object, + // encrypts the wifi PSK with the cluster key (so it can be stored at rest + // and delivered as ciphertext that the kiosk decrypts on-device with the + // cluster key it received at pairing), then bumps managed_config_version + // so the next heartbeat ships it to the kiosk. + app.post("/admin/kiosks/:id/managed-config", async (event) => { + const id = Number(getRouterParam(event, "id")); + const kiosk = deps.repo.getKioskById(id); + if (!kiosk) throw new Error("kiosk not found"); + if (!kiosk.managed_image) throw new Error("kiosk is not running a managed image"); + const body = await readBody>(event); + + const trim = (v: string | undefined) => (v ?? "").trim(); + const cfg: Record = {}; + const hostname = trim(body?.["hostname"]); + if (hostname) cfg["hostname"] = hostname; + const timezone = trim(body?.["timezone"]); + if (timezone) cfg["timezone"] = timezone; + + const netMode = trim(body?.["network_mode"]); + if (netMode === "dhcp" || netMode === "static") { + const net: Record = { mode: netMode }; + const iface = trim(body?.["network_interface"]); + if (iface) net["interface"] = iface; + if (netMode === "static") { + const ipCidr = trim(body?.["network_ip_cidr"]); + if (ipCidr) net["ip_cidr"] = ipCidr; + const gw = trim(body?.["network_gateway"]); + if (gw) net["gateway"] = gw; + const dnsRaw = trim(body?.["network_dns"]); + if (dnsRaw) { + net["dns"] = dnsRaw.split(",").map((s) => s.trim()).filter(Boolean); + } + } + const vlanRaw = trim(body?.["network_vlan_id"]); + if (vlanRaw) { + const vlanId = Number(vlanRaw); + if (Number.isInteger(vlanId) && vlanId >= 1 && vlanId <= 4094) { + net["vlan_id"] = vlanId; + } + } + cfg["network"] = net; + } + + // Wifi: load existing first so blank PSK = "keep current". Re-encrypt PSK + // only when the operator actually typed one. + const ssid = trim(body?.["wifi_ssid"]); + const pskPlaintext = body?.["wifi_psk"] ?? ""; + if (ssid) { + const prev = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : null; + let pskCiphertext: string | null = prev?.wifi?.psk_ciphertext ?? null; + if (pskPlaintext) { + pskCiphertext = deps.secrets.encryptString(pskPlaintext, "cluster"); + } + if (pskCiphertext) { + cfg["wifi"] = { ssid, psk_ciphertext: pskCiphertext }; + } + } + + deps.repo.updateKiosk(id, { + managed_config_json: JSON.stringify(cfg), + managed_config_version: kiosk.managed_config_version + 1, + managed_config_error: null, + } as any); + + audit(deps.repo, event as any, "kiosk.managed_config.update", { + resource_type: "kiosk", + resource_id: String(id), + metadata: { version: kiosk.managed_config_version + 1 }, + }); + + return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); + }); + app.post("/admin/kiosks/:id/labels", async (event) => { const kioskId = Number(getRouterParam(event, "id")); const body = await readBody>(event); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 27a0eee..43af9d3 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -215,12 +215,14 @@ function registerPairingRoutes( proposed_name?: string; hardware_model?: string; capabilities?: string[]; + managed_image?: boolean; }>(event); const result = initiatePairing(repo, { proposedName: body?.proposed_name ?? null, hardwareModel: body?.hardware_model ?? null, capabilities: body?.capabilities ?? [], + managedImage: body?.managed_image === true, codeTtlSeconds: codeTtl, }); @@ -303,6 +305,11 @@ function registerKioskRoutes( fan_pwm?: number | null; local_key?: string | null; local_port?: number | null; + // Managed-image kiosk echoes back the version it last applied, and the + // last apply error (if any). Server uses these to decide whether to + // include pending_config in the response. + managed_config_applied_version?: number; + managed_config_error?: string | null; }>(event); // Capture the kiosk's LAN-side IP from the heartbeat connection so admin @@ -323,6 +330,22 @@ function registerKioskRoutes( local_last_ip: remoteIp, }); + // Managed-config echo: kiosk reports the version it has successfully + // applied. Persist for the admin UI to render. Error string clears on a + // successful apply (kiosk omits it). verifyKioskKey returns just {id}; + // re-read the full row to check the managed_image flag. + const kioskFull = repo.getKioskById(kiosk.id); + if (kioskFull?.managed_image && typeof body?.managed_config_applied_version === "number") { + const patch: Record = { + managed_config_applied_version: body.managed_config_applied_version, + managed_config_applied_at: new Date().toISOString(), + }; + if (body.managed_config_error !== undefined) { + patch["managed_config_error"] = body.managed_config_error ?? null; + } + repo.updateKiosk(kiosk.id, patch as any); + } + // Mirror to MQTT bridge (no-op when BF_MQTT_URL unset). mqtt.publishTelemetry(kiosk.id, { kiosk_app_version: body?.kiosk_app_version, @@ -377,7 +400,31 @@ function registerKioskRoutes( } } - return { ok: true, now: new Date().toISOString() }; + // Re-read kiosk so we see the freshly-persisted applied_version above when + // computing whether the server still has a newer config to deliver. + const fresh = repo.getKioskById(kiosk.id); + let pendingConfig: { version: number; config: unknown } | undefined; + if ( + fresh?.managed_image + && fresh.managed_config_version > fresh.managed_config_applied_version + && fresh.managed_config_json + ) { + try { + pendingConfig = { + version: fresh.managed_config_version, + config: JSON.parse(fresh.managed_config_json), + }; + } catch { + // Corrupt JSON — leave pendingConfig undefined; admin UI will show + // the error. Don't break heartbeat. + } + } + + return { + ok: true, + now: new Date().toISOString(), + ...(pendingConfig ? { pending_config: pendingConfig } : {}), + }; }); // Event forwarding diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index babdc34..b6c24e2 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -265,6 +265,12 @@ export function rowToKiosk(r: Row): Kiosk { local_key: sn(r["local_key"]), local_port: nn(r["local_port"]), local_last_ip: sn(r["local_last_ip"]), + managed_image: b(r["managed_image"]), + managed_config_json: sn(r["managed_config_json"]), + managed_config_version: n(r["managed_config_version"] ?? 0), + managed_config_applied_version: n(r["managed_config_applied_version"] ?? 0), + managed_config_applied_at: sn(r["managed_config_applied_at"]), + managed_config_error: sn(r["managed_config_error"]), created_at: s(r["created_at"]), }; } diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 3c74ba2..0b471db 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -781,4 +781,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ `CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)`, `CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, ts DESC)`, `CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_type, actor_id, ts DESC)`, + + // ---- Managed-image device config ----------------------------------------- + // For kiosks running our pre-built Pi image (managed_image=1), admins can + // push hostname / timezone / network / wifi config. Kiosk pulls on heartbeat + // when server's version > applied_version, applies via a privileged helper, + // echoes applied_version back. managed_config_error captures last failure. + (db: DatabaseSync) => { + addColumnIfNotExists(db, "kiosks", "managed_image", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists(db, "kiosks", "managed_config_json", "TEXT"); + addColumnIfNotExists(db, "kiosks", "managed_config_version", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists(db, "kiosks", "managed_config_applied_version", "INTEGER NOT NULL DEFAULT 0"); + addColumnIfNotExists(db, "kiosks", "managed_config_applied_at", "TEXT"); + addColumnIfNotExists(db, "kiosks", "managed_config_error", "TEXT"); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index f96a0b2..cc1f474 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -971,11 +971,12 @@ export class Repository { key_prefix: string; capabilities?: string[]; hardware_model?: string | null; + managed_image?: boolean; }): Kiosk { const result = this.prep( `INSERT INTO kiosks - (name, key_hash, key_prefix, capabilities, hardware_model, paired_at) - VALUES (?, ?, ?, ?, ?, ?)`, + (name, key_hash, key_prefix, capabilities, hardware_model, paired_at, managed_image) + VALUES (?, ?, ?, ?, ?, ?, ?)`, ).run( input.name, input.key_hash, @@ -983,6 +984,7 @@ export class Repository { J(input.capabilities ?? []), input.hardware_model ?? null, isoNow(), + input.managed_image ? 1 : 0, ); const id = Number(result.lastInsertRowid); void this.notify("kiosks", "create", id); diff --git a/server/src/schemas/wire/managed-config.ts b/server/src/schemas/wire/managed-config.ts new file mode 100644 index 0000000..987daf1 --- /dev/null +++ b/server/src/schemas/wire/managed-config.ts @@ -0,0 +1,55 @@ +/** + * Wire schema for the managed-image device config: pushed from server to + * kiosks running our pre-built Pi OS image. Kiosks pull on heartbeat + * (response includes `pending_config` when server-side version exceeds + * `applied_version`), apply via a privileged helper, echo `applied_version` + * back on the next heartbeat. + * + * Wifi PSKs are encrypted with the cluster_key delivered at pairing time, + * so the server can store ciphertext at rest and ship it to the kiosk + * without a per-kiosk re-encryption step. + */ +import * as av from "@anyvali/js"; + +export const NETWORK_MODES = ["dhcp", "static"] as const; + +export const managedNetworkConfig = av.object( + { + mode: av.enum_(NETWORK_MODES), + // Interface name as exposed by NetworkManager (e.g. "eth0", "wlan0"). + // Optional — helper defaults to the primary wired interface. + interface: av.optional(av.string().minLength(1).maxLength(32)), + // IPv4 CIDR for static mode. Ignored when mode=dhcp. + ip_cidr: av.optional(av.string().pattern("^[0-9.]+/[0-9]+$")), + gateway: av.optional(av.string().minLength(1).maxLength(64)), + dns: av.optional(av.array(av.string().minLength(1).maxLength(64))), + // 802.1Q VLAN id; helper creates a virtual interface when set. + vlan_id: av.optional(av.int().min(1).max(4094)), + }, + { unknownKeys: "reject" }, +); + +export const managedWifiConfig = av.object( + { + ssid: av.string().minLength(1).maxLength(64), + // PSK encrypted with cluster_key via shared/secrets.encryptString. + // Helper decrypts in-process before handing to NetworkManager. + psk_ciphertext: av.string().minLength(1).maxLength(512), + }, + { unknownKeys: "reject" }, +); + +export const managedConfig = av.object( + { + hostname: av.optional(av.string().minLength(1).maxLength(64)), + // IANA tz name, e.g. "Etc/UTC", "America/New_York". + timezone: av.optional(av.string().minLength(1).maxLength(64)), + network: av.optional(managedNetworkConfig), + wifi: av.optional(managedWifiConfig), + }, + { unknownKeys: "reject" }, +); + +export type ManagedNetworkConfig = av.Infer; +export type ManagedWifiConfig = av.Infer; +export type ManagedConfig = av.Infer; diff --git a/server/src/schemas/wire/pairing.ts b/server/src/schemas/wire/pairing.ts index 6e2c111..1bcf206 100644 --- a/server/src/schemas/wire/pairing.ts +++ b/server/src/schemas/wire/pairing.ts @@ -27,6 +27,9 @@ export const pairInitiateRequest = av.object( capabilities: av.array(av.enum_(KIOSK_CAPABILITIES)), os_version: av.optional(av.string().maxLength(128)), kiosk_app_version: av.optional(av.string().maxLength(64)), + // True iff the kiosk runs our pre-built Pi OS image and ships the + // betterframe-apply-config helper. Gates the admin Managed Config UI. + managed_image: av.optional(av.bool()), }, { unknownKeys: "reject" }, ); diff --git a/server/src/shared/pairing.ts b/server/src/shared/pairing.ts index 596042e..39d83ed 100644 --- a/server/src/shared/pairing.ts +++ b/server/src/shared/pairing.ts @@ -28,6 +28,8 @@ export interface PairingInitiateInput { proposedName: string | null; hardwareModel: string | null; capabilities: string[]; + /** True iff kiosk runs our pre-built Pi image with the apply-config helper. */ + managedImage?: boolean; codeTtlSeconds: number; } @@ -56,7 +58,7 @@ export function initiatePairing( kiosk_hardware_model: input.hardwareModel, kiosk_capabilities: input.capabilities, expires_at: expiresAt, - extras: {}, + extras: input.managedImage ? { managed_image: true } : {}, }); return { code, expiresAt }; @@ -159,6 +161,7 @@ export async function confirmPairing( key_prefix: kioskKeyPrefix, capabilities: pc.kiosk_capabilities, hardware_model: pc.kiosk_hardware_model, + managed_image: pc.extras?.["managed_image"] === true, }); repo.createDisplayForKiosk(kiosk.id, { diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index a5c0a28..a591a34 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -219,6 +219,14 @@ export interface Kiosk { local_key: string | null; local_port: number | null; local_last_ip: string | null; + // Managed-image device config. Only meaningful when managed_image=true; for + // BYO-OS kiosks these fields stay at defaults and the admin UI hides them. + managed_image: boolean; + managed_config_json: string | null; // serialized ManagedConfig payload + managed_config_version: number; // server-side, bumps on each save + managed_config_applied_version: number; // echoed by kiosk after successful apply + managed_config_applied_at: string | null; + managed_config_error: string | null; created_at: string; } diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index df57205..76f8f0d 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1375,6 +1375,116 @@ export function renderKioskLabels( ); } +/** + * Managed-image device config editor. Only rendered when the kiosk reported + * managed_image=true at pairing. Server pushes the resulting JSON on the + * next heartbeat; kiosk applies it and echoes the version back, so we show + * "version N applied at …" plus the last error (if any) so the operator can + * see whether their change actually landed. + */ +function ManagedConfigCard(props: { kiosk: Kiosk }) { + const k = props.kiosk; + let cfg: { + hostname?: string; + timezone?: string; + network?: { + mode?: string; + interface?: string; + ip_cidr?: string; + gateway?: string; + dns?: string[]; + vlan_id?: number; + }; + wifi?: { ssid?: string }; + } = {}; + if (k.managed_config_json) { + try { cfg = JSON.parse(k.managed_config_json); } catch { /* ignore */ } + } + const net = cfg.network ?? {}; + const wifi = cfg.wifi ?? {}; + const pending = k.managed_config_version > k.managed_config_applied_version; + return ( +
+

Managed Config (Pi image)

+
+
+ Version: {String(k.managed_config_version)} + {" · Applied: "}{String(k.managed_config_applied_version)} + {k.managed_config_applied_at ? <> ({formatTime(k.managed_config_applied_at)}) : null} + {pending ? pending push… : null} +
+ {k.managed_config_error + ?
Last error: {k.managed_config_error}
+ : null} +
+
+
+ + +
+
+ + +
+ +

Network

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +

Wi-Fi (optional)

+
+ + +
+
+ + +
+ Encrypted with cluster key before storage. Leave blank to keep existing PSK. +
+
+ + +
+
+ ); +} + export function KioskEditPage(props: KioskEditProps) { const k = props.kiosk; return ( @@ -1631,6 +1741,8 @@ export function KioskEditPage(props: KioskEditProps) { + {k.managed_image ? : null} +