feat(managed-config): server-side scaffold for Pi-image device config

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.
This commit is contained in:
Mitchell R 2026-05-20 03:18:11 +02:00
parent 4e652c6fd1
commit dae5d0ce88
No known key found for this signature in database
11 changed files with 338 additions and 8 deletions

View file

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

View file

@ -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<Record<string, string>>(event);
const trim = (v: string | undefined) => (v ?? "").trim();
const cfg: Record<string, unknown> = {};
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<string, unknown> = { 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<Record<string, string>>(event);

View file

@ -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<string, unknown> = {
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

View file

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

View file

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

View file

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

View file

@ -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<typeof managedNetworkConfig>;
export type ManagedWifiConfig = av.Infer<typeof managedWifiConfig>;
export type ManagedConfig = av.Infer<typeof managedConfig>;

View file

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

View file

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

View file

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

View file

@ -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 (
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Managed Config (Pi image)</h2>
<div style="font-size:0.85rem; color:#666; margin-bottom:0.75rem">
<div>
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 ? <span style="color:#b06; margin-left:0.5rem">pending push</span> : null}
</div>
{k.managed_config_error
? <div style="color:#b00; margin-top:0.25rem">Last error: {k.managed_config_error}</div>
: null}
</div>
<form method="post" action={`/admin/kiosks/${k.id}/managed-config`}>
<div class="form-group">
<label for="mc_hostname">Hostname</label>
<input id="mc_hostname" name="hostname" type="text" class="form-input"
value={cfg.hostname ?? ""} placeholder="betterframe-kiosk" />
</div>
<div class="form-group">
<label for="mc_timezone">Timezone</label>
<input id="mc_timezone" name="timezone" type="text" class="form-input"
value={cfg.timezone ?? ""} placeholder="Etc/UTC" />
</div>
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Network</h3>
<div class="form-group">
<label for="mc_net_mode">Mode</label>
<select id="mc_net_mode" name="network_mode" class="form-input">
<option value="" selected={!net.mode}></option>
<option value="dhcp" selected={net.mode === "dhcp"}>DHCP</option>
<option value="static" selected={net.mode === "static"}>Static</option>
</select>
</div>
<div class="form-group">
<label for="mc_net_iface">Interface</label>
<input id="mc_net_iface" name="network_interface" type="text" class="form-input"
value={net.interface ?? ""} placeholder="eth0" />
</div>
<div class="form-group">
<label for="mc_net_ip">Static IP (CIDR)</label>
<input id="mc_net_ip" name="network_ip_cidr" type="text" class="form-input"
value={net.ip_cidr ?? ""} placeholder="192.168.1.50/24" />
</div>
<div class="form-group">
<label for="mc_net_gw">Gateway</label>
<input id="mc_net_gw" name="network_gateway" type="text" class="form-input"
value={net.gateway ?? ""} placeholder="192.168.1.1" />
</div>
<div class="form-group">
<label for="mc_net_dns">DNS (comma-separated)</label>
<input id="mc_net_dns" name="network_dns" type="text" class="form-input"
value={(net.dns ?? []).join(", ")} placeholder="1.1.1.1, 8.8.8.8" />
</div>
<div class="form-group">
<label for="mc_net_vlan">VLAN ID</label>
<input id="mc_net_vlan" name="network_vlan_id" type="number" min="1" max="4094"
class="form-input" value={net.vlan_id != null ? String(net.vlan_id) : ""} />
</div>
<h3 style="margin:1rem 0 0.5rem; font-size:0.95rem">Wi-Fi (optional)</h3>
<div class="form-group">
<label for="mc_wifi_ssid">SSID</label>
<input id="mc_wifi_ssid" name="wifi_ssid" type="text" class="form-input"
value={wifi.ssid ?? ""} />
</div>
<div class="form-group">
<label for="mc_wifi_psk">PSK</label>
<input id="mc_wifi_psk" name="wifi_psk" type="password" class="form-input"
placeholder={wifi.ssid ? "(unchanged — leave blank to keep)" : ""} />
<div style="font-size:0.75rem; color:#999; margin-top:0.2rem">
Encrypted with cluster key before storage. Leave blank to keep existing PSK.
</div>
</div>
<button type="submit" class="btn btn-primary">Save &amp; Push</button>
</form>
</div>
);
}
export function KioskEditPage(props: KioskEditProps) {
const k = props.kiosk;
return (
@ -1631,6 +1741,8 @@ export function KioskEditPage(props: KioskEditProps) {
</div>
</div>
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
</form>