From ad6b7e336672254690f6564fadc0f3eb5e669169 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Sun, 28 Jun 2026 03:14:25 +0000 Subject: [PATCH] Add Host API and fix kiosk performance --- .gitmodules | 1 + CLAUDE.md | 5 + diskless.pkr.hcl | 59 +++- docs/TODO.md | 111 ++++++- docs/stig-deviations.md | 11 +- files/hostapi/README.md | 53 ++++ files/hostapi/fleet-hostapi.service | 52 ++++ files/hostapi/go.mod | 7 + files/hostapi/main.go | 400 +++++++++++++++++++++++++ files/kiosk/chromium-kiosk-policy.json | 8 +- files/kiosk/kiosk-session | 75 +++-- serving/kiosk-app | 2 +- 12 files changed, 747 insertions(+), 37 deletions(-) create mode 100644 files/hostapi/README.md create mode 100644 files/hostapi/fleet-hostapi.service create mode 100644 files/hostapi/go.mod create mode 100644 files/hostapi/main.go diff --git a/.gitmodules b/.gitmodules index 6a9e263..5515941 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "serving/kiosk-app"] path = serving/kiosk-app url = git@git.bitasylum.org:audiobuzz/fleet-kiosk-app.git + branch = restyle diff --git a/CLAUDE.md b/CLAUDE.md index 8930229..8f0c7a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,11 @@ files/etc/dracut.conf.d/90-diskless.conf no-hostonly + drivers + modules files/plymouth/themes/fleet/ Custom boot splash (scripted security checklist; driven by hook status tokens) files/kiosk/ weston+Chromium kiosk + chromium lockdown policy + (policy allows PNA to the host-info agent) +files/hostapi/ Go host-info agent (fleet-hostapi): read-only, + loopback :9099 /v1/host for the kiosk SPA + (HOST-API.md contract). Built in 2b-hostapi, + fapolicyd-trusted, golang removed in 2d. scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image scripts/build-ipxe-oci.sh sbsign snponly.efi -> FROM-scratch fleet-ipxe image diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index e7c5299..983441c 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -233,6 +233,13 @@ build { destination = "/tmp/" } + # Workstation host-info API (Go). Source staged to /tmp/hostapi; compiled + + # installed in the 2b-hostapi step. The kiosk SPA reads it at localhost:9099. + provisioner "file" { + source = "files/hostapi" + destination = "/tmp/" + } + # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to # /tmp/plymouth, installed + set default in the package step before dracut runs. provisioner "file" { @@ -418,8 +425,13 @@ build { # --use-pixman + chromium --disable-gpu — candidate to drop once the kiosk # is display-verified; see TODO #4.) "dnf install -y --setopt=tsflags=nodocs weston chromium mesa-dri-drivers mesa-libEGL mesa-libgbm dejavu-sans-fonts", - # Unprivileged, passwordless kiosk user; video/input as a seat fallback. + # Unprivileged, passwordless kiosk user. Groups: 'video' (KMS /dev/dri/card0 + # for weston) + 'input' (libinput) as a seat fallback; 'render' + # (/dev/dri/renderD128) so Chromium's GPU process can do hardware raster on + # real hardware (kiosk-session enables GPU when a real KMS driver is present; + # render nodes aren't logind-seat-ACL'd on EL9, so group membership is needed). "id kiosk >/dev/null 2>&1 || useradd -m -G video,input kiosk", + "getent group render >/dev/null 2>&1 && usermod -aG render kiosk || true", # Install the staged unit, weston config, and session wrapper. "install -D -m0755 /tmp/kiosk/kiosk-session /usr/local/bin/kiosk-session", "install -D -m0644 /tmp/kiosk/kiosk.service /etc/systemd/system/kiosk.service", @@ -452,6 +464,44 @@ build { ] } + # --- 2b-hostapi. Workstation host-info API (Go, loopback, read-only) ------ + # Compiles files/hostapi into /usr/local/bin/fleet-hostapi and enables it as a + # systemd service the kiosk SPA reads at http://localhost:9099/v1/host (the + # HOST-API.md contract). Build-time, image-GENERIC facts (image version + + # weston/chromium versions) are written to /etc/fleet-hostinfo.json; everything + # host-specific (kernel, hostname, IP, MAC, board, uptime) is read at RUNTIME, + # so NO per-host state lands in the image (invariant #1). The Go toolchain is + # installed here and removed in 2d hygiene; it never ships. + provisioner "shell" { + environment_vars = [ + "IMAGE_VERSION=${var.image_version}", + ] + inline = [ + "set -euxo pipefail", + "dnf -y install golang", + # Reproducible, dependency-free static build (the verity hash must be a pure + # function of the inputs — invariant #8). GOPROXY=off + GOTOOLCHAIN=local + # guarantee no network/toolchain fetch; -trimpath + -buildid= strip paths + # and the build id. Stdlib-only, so this needs no modules. NOTE: a golang + # package update changes the binary (hence the hash) — a reproducibility- + # sensitive input, like the STIG semanage store; build twice to confirm. + "install -d -m0755 /tmp/gocache", + "env -C /tmp/hostapi CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GOPROXY=off GOTOOLCHAIN=local GOFLAGS=-mod=mod GOCACHE=/tmp/gocache GOPATH=/tmp/gopath go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /usr/local/bin/fleet-hostapi .", + "chmod 0755 /usr/local/bin/fleet-hostapi", + "install -D -m0644 /tmp/hostapi/fleet-hostapi.service /etc/systemd/system/fleet-hostapi.service", + # Build-time facts file (image-generic; same for every host of a release). + # rpm '%%{...}' is doubled so Packer's HCL2 doesn't parse %{ as a directive. + "WESTON_VER=$(rpm -q --qf '%%{VERSION}' weston 2>/dev/null || echo '')", + "CHROMIUM_VER=$(rpm -q --qf '%%{VERSION}' chromium 2>/dev/null || rpm -q --qf '%%{VERSION}' chromium-browser 2>/dev/null || echo '')", + "printf '{\"image\":\"%s\",\"weston\":\"%s\",\"chromium\":\"%s\"}\\n' \"$IMAGE_VERSION\" \"$WESTON_VER\" \"$CHROMIUM_VER\" > /etc/fleet-hostinfo.json", + "chmod 0644 /etc/fleet-hostinfo.json", + "restorecon -R /usr/local/bin/fleet-hostapi /etc/systemd/system/fleet-hostapi.service /etc/fleet-hostinfo.json 2>/dev/null || true", + "systemctl enable fleet-hostapi.service", + "rm -rf /tmp/hostapi /tmp/gocache /tmp/gopath", + "echo '==> fleet-hostapi enabled (localhost:9099/v1/host)'", + ] + } + # --- 2c. Admin access: sudo-enabled key user + locked root -------------- # Fleet-generic (same user + key on every host => NOT per-host state). The # key comes from var.ssh_authorized_key (CI secret). If no key is supplied we @@ -701,9 +751,9 @@ build { # custom kiosk files: add them to fapolicyd's trust DB and rebuild it. weston # /chromium are RPM-installed so already trusted. (See docs/stig-deviations.md.) "if command -v fapolicyd-cli >/dev/null 2>&1; then", - " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", + " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", " fapolicyd-cli --update 2>/dev/null || true", - " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll) + rebuilt trust db'", + " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", "fi", ] } @@ -776,6 +826,9 @@ build { # already downloaded to the host) — slims the erofs + cuts attack surface. # Keep auditd (it's part of the hardened runtime). Best-effort. "dnf remove -y openscap-scanner scap-security-guide openscap-utils 2>/dev/null || true", + # The Go toolchain was only needed to compile fleet-hostapi (2b-hostapi); + # the static binary ships, the compiler does not. + "dnf remove -y golang 2>/dev/null || true", # The build-time STIG reports must not ship inside the read-only rootfs. "rm -f /root/stig-report.html /root/stig-arf.xml", "dnf clean all", diff --git a/docs/TODO.md b/docs/TODO.md index c87125e..dc85576 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -685,12 +685,26 @@ must be built around **never restarting the process that holds live connections. the same `/dev/disk/by-id/nvme-uuid.` host symlink. - **Bootstrap unchanged:** `docker compose up` with one version still works — the `/artifacts` image volume is seeded into `./releases/${IMAGE_VERSION}`. - - **BENCH-VERIFY:** (a) `load` a 2nd version while a host is connected to the - 1st → 1st's connection survives (no AER/reset on the running host); (b) - `unload` drain-gate actually blocks while CONNS>0; (c) serving-host reboot - reconciles all staged versions; (d) per-version UUID build is reproducible - (two builds, same version → identical `data_ns_uuid`/`hash_ns_uuid` + - `root_hash`). See `serving/spdk/README.md`. + - **PARTIALLY BENCH-VERIFIED 2026-06-28:** long-lived target boots a host end + to end — UKI served over HTTP → host boots → NVMe/TCP root connects to the + persistent `nvmf_tgt` (single version `spdk-permanence-02ffd7d`). Surfaced + + fixed several real bugs along the way: bootstrap seed must COPY not symlink + (a symlink into the version-pinned `/artifacts` re-pointed a stale release at + new data); `spdk-release` parses RPC JSON via **python3** (SPDK image has no + jq); legacy `verity.json` (no per-version UUIDs) falls back to the fixed pair; + mislabeled-dir guard (dir name vs verity.json `image_version`, case-insensitive); + dropped the redundant discovery-listener re-add (daemon logged "Listener + already exists"). **CI lowercases `image_version`** (gitea-main-ci vars step) — + a mixed-case branch (`SPDK-permanence`) had baked a capital + `fleet-SPDK-…​.efi` the lowercase boot URL couldn't fetch (iPXE "no such + file"); booted via a local retag workaround pending a lowercase rebuild. + - **STILL BENCH-VERIFY:** (a) `load` a 2nd version while a host is connected to + the 1st → 1st's connection survives (no AER/reset on the running host); (b) + `unload`/auto-reap drain-gate blocks while CONNS>0 and reaps after grace on a + LIVE host; (c) serving-host reboot reconciles all staged versions; (d) + per-version UUID build reproducible (two builds, same version → identical + `data_ns_uuid`/`hash_ns_uuid` + `root_hash`); (e) the full fix on a clean + LOWERCASE CI rebuild (no retag). See `serving/spdk/README.md`. - **Capacity:** each concurrently-served version costs target memory/hugepages + file-backed bdev overhead — plan for N live versions (current + previous + canary) during long rollouts (raise `HUGEPAGES`). @@ -864,6 +878,10 @@ smoke test. `docs/stig-deviations.md` #6 to reflect encrypted offload. (RELP+TLS via `omrelp`/`imrelp` is the alternative if guaranteed delivery is wanted alongside encryption.) +- **fleet-hostapi: scope CORS to the kiosk origin.** The host-info agent ships + with `FLEET_HOSTAPI_ALLOW_ORIGIN=*` (lab). For prod set it to the exact kiosk + origin (default `http://boot.fleet.lab`) via the unit's commented + `Environment=` line (or a drop-in). See #12. ## [ ] 9. (OPTIONAL / nice-to-have) Convert provisioning to Ansible where it pays off @@ -1024,3 +1042,84 @@ non-destructive TPM capability check (device, key persist, NV roundtrip, openssl TPM2 provider, CSR signed by the TPM key) to de-risk the TPM half before any of this. Expected gap: the image installs `tpm2-tss` but no openssl TPM2 **provider** (`tpm2-openssl`) — add it to the build once the self-test confirms. + +## [~] 12. Workstation host-info API (`fleet-hostapi`) for the kiosk + +**IMPLEMENTED 2026-06-28.** A small **Go, read-only, loopback-only** HTTP agent on +the workstation that reports host/OS/hardware facts to the kiosk SPA, per the +[HOST-API.md](../HOST-API.md) contract (`GET http://localhost:9099/v1/host`). +Source: `files/hostapi/` (stdlib-only, reproducible static build). Built + enabled +by provisioner **2b-hostapi**; toolchain removed in 2d; binary fapolicyd-trusted +in 2c-STIG-postfix (register #10). Chromium policy grants Private Network Access +scoped to the kiosk origin (`files/kiosk/chromium-kiosk-policy.json`). + +- **Per-host safety:** host-specific fields (kernel, hostname, IP, MAC, board, + uptime) are read at RUNTIME; only image-generic facts (image version + + weston/chromium versions) are baked, to `/etc/fleet-hostinfo.json` (invariant + #1 preserved). Deliberately does NOT expose DMI serial (root-only/per-host) or + any TPM/cert/identity material. +- **Hardening shipped:** loopback bind enforced in code; `DynamicUser`, no caps, + `ProtectSystem=strict`, `MemoryDenyWriteExecute`, `SystemCallFilter=@system-service`, + `IPAddressDeny=any` (loopback only). Runs unprivileged, reads world-readable + files only. + +**Lab posture LEFT AS-IS (decided 2026-06-28) — revisit for prod:** +- **CORS `Allow-Origin=*`** → scope to the kiosk origin (`http://boot.fleet.lab`). + Consolidated into #8. +- **SELinux:** runs in the default service domain (no custom policy module). + **BENCH-VERIFY** no AVC denials for `fleet-hostapi` on an enforcing booted host + (Wazuh/`fleet_audit` rule 100120 will surface any). If denials appear, decide: + write a confined SELinux policy module vs adjust the sandbox. +- **No auth** (justified: loopback + read-only + single-user appliance). Option if + the threat model tightens: shared token in `kiosk.env` + header check. +- **Info sensitivity:** exposes hostname/IP/MAC/board/versions to the kiosk. Drop + MAC/IP if that's later deemed too much. +- **Reproducibility:** the `golang` toolchain version is a reproducibility-sensitive + input (a package bump changes the binary → verity hash); covered by the + build-twice gate. Pin the go version only if drift becomes a problem. + +**BENCH-VERIFY (functional):** (a) service starts under enforcing SELinux + +fapolicyd (not blocked); (b) the kiosk SPA's System page fetches `/v1/host` +successfully through the PNA allowance (preflight + GET); (c) facts render +correctly on real hardware (board/CPU/GPU from DMI/DRM, not the virtio build VM). + +## [~] 13. Kiosk GPU compositing + Chromium flag ownership + +**GPU FIX APPLIED 2026-06-28.** The 820 (Intel HD620/i915) ran Chromium at ~150% +CPU with periodic crashes: `--disable-gpu` in our `kiosk-session` forced software +raster/compositing. Root cause confirmed from the live cmdline. Fixes in +`files/kiosk/kiosk-session` + `diskless.pkr.hcl`: +- **Runtime GPU probe** (fleet-generic image boots real HW *and* virtio VMs): a + real KMS driver (`i915`/`amdgpu`/…) + `/dev/dri/renderD128` → GPU path + (`--ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy`, weston + GL renderer); else software (`--disable-gpu` + `weston --use-pixman`) so the VMs + still render. No per-host state baked. +- **kiosk user added to `render`** (renderD128 isn't logind-seat-ACL'd on EL9; + `video` only covers card0/KMS for weston). +- **Merged the distro wrapper's `--disable-features` into ours** — Chromium takes + the LAST `--disable-features` (not the union), so ours was silently dropping the + wrapper's Lens/Gemini/Compose disables. + +**BENCH-VERIFY (on the 820):** GPU process alive (`chrome://gpu`), Compositor/ +ThreadPool threads near-idle + CPU drop, `gt_act_freq_mhz` rises under load, no +SELinux AVC for `dri_device_t` from the kiosk domain (Wazuh `fleet_audit` 100120 +will surface any — if present, add a scoped SELinux allow or confirm the kiosk +user's SELinux mapping is unconfined). Confirm VMs still render (software path). + +**DECISION OPEN — Chromium flag ownership (security).** The distro chromium +wrapper PREPENDS vendor defaults we don't control, including capability-broadening +ones a locked kiosk arguably shouldn't carry: `--enable-extensions`, +`--enable-user-scripts`, `--enable-plugins`, `--enable-printing`, +`--auto-ssl-client-auth`, `--enable-sync`. Today the managed policy neutralises +the security-relevant ones (sync off via `SyncDisabled`; sign-in/metrics/etc.), +but extensions/user-scripts/auto-ssl-client-auth are NOT policy-locked. Option to +**own the full flag set**: bypass/neutralise the wrapper (invoke the real binary, +or empty its default-flags source) and specify everything ourselves incl. the +VA-API `--enable-features` (now known: +`AllowQt,WaylandLinuxDrmSyncobj,WaylandPerSurfaceScale,WaylandUiScale,AcceleratedVideoDecodeLinuxGL,AcceleratedVideoDecodeLinuxZeroCopyGL,AcceleratedVideoEncoder`). +Tradeoff: full determinism + drop unwanted capabilities, vs we then track Chromium +feature renames ourselves instead of inheriting the distro's. Also fully dedupes +`--disable-features`/`--enable-sync`. Needs locating the wrapper's flag source on +the box first (so we don't lose VA-API or break its env setup). Pairs with policy +adds: `ExtensionInstallBlocklist=["*"]`, disable user scripts, restrict +AutoSelectCertificateForUrls. diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md index db28c8e..f6024f4 100644 --- a/docs/stig-deviations.md +++ b/docs/stig-deviations.md @@ -143,10 +143,13 @@ hotkey disabled (e.g. a physically exposed terminal), re-select both rule IDs. - **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). - **fapolicyd** kept **enabled** (NOT waived) — it's the real exec-allowlist guard for the writable RAM overlay, which dm-verity does **not** cover. The only - adjustment: the one non-RPM executable, `/usr/local/bin/kiosk-session`, is added - to fapolicyd's trust DB at build (provisioner 2c-STIG-postfix) so the kiosk runs; - everything else stays RPM-trust-only. (fapolicyd blocking that binary was the - original kiosk-won't-start cause.) + adjustment: the few non-RPM executables we ship are added to fapolicyd's trust DB + at build (provisioner 2c-STIG-postfix) so they run; everything else stays + RPM-trust-only. Trusted custom binaries: `/usr/local/bin/kiosk-session`, + `/usr/local/bin/fleet-sb-enroll`, `/usr/local/bin/fleet-hostapi` (the loopback + host-info agent, built from `files/hostapi`). Each is built into the immutable, + dm-verity-protected root, so trusting it doesn't weaken the overlay exec guard. + (fapolicyd blocking `kiosk-session` was the original kiosk-won't-start cause.) - **auditd** enabled; STIG audit rules applied; logs locally. - **USBGuard** enabled + enforcing (`block` target) with a HID/hub allowlist and `AuditBackend=LinuxAudit` (see #12) — blocks USB mass storage / unknown device diff --git a/files/hostapi/README.md b/files/hostapi/README.md new file mode 100644 index 0000000..0005aab --- /dev/null +++ b/files/hostapi/README.md @@ -0,0 +1,53 @@ +# fleet-hostapi — workstation host-info agent + +A tiny **read-only, loopback-only** Go HTTP service that runs on the diskless +workstation and reports facts about the machine the kiosk renders on. It is the +OS-side implementation of the contract in [`../../HOST-API.md`](../../HOST-API.md) +(authored alongside the kiosk-app SPA that consumes it). + +``` +GET http://localhost:9099/v1/host -> application/json (os, hardware, net, kiosk, agent) +GET http://localhost:9099/healthz -> 200 ok +``` + +## Properties (and why) + +- **Loopback only.** Binds `127.0.0.1`; refuses to start on any routable address. + Host facts must never reach the boot LAN. +- **Read only.** No endpoint mutates anything; it reads `/proc`, `/sys`, + `/etc/os-release`, and the build-time facts file. +- **No per-host state baked in** (invariant #1). Host-specific fields (kernel, + hostname, IP, MAC, board, uptime) are read at **runtime**. The only baked facts + are image-generic — image version + weston/chromium versions — written to + `/etc/fleet-hostinfo.json` by the build provisioner. +- **Stdlib only → reproducible build** (the verity hash is a security property). + Built `CGO_ENABLED=0 -trimpath -buildvcs=false -ldflags=-buildid=`; identical + bytes across builds with the same Go toolchain. +- **CORS + Private Network Access.** The SPA is cross-origin and the fetch is a + Chromium private-network request, so responses carry + `Access-Control-Allow-Private-Network: true`. The matching browser-side + allowance is in `files/kiosk/chromium-kiosk-policy.json` + (`InsecurePrivateNetworkRequestsAllowedForUrls`, scoped to the kiosk origin). + +## Build / install + +Done by the `2b-hostapi` provisioner in `diskless.pkr.hcl`: installs the Go +toolchain, builds the static binary to `/usr/local/bin/fleet-hostapi`, writes the +facts file, installs + enables `fleet-hostapi.service`, and adds the binary to +fapolicyd trust (2c-STIG-postfix). The toolchain is removed in 2d hygiene — only +the static binary ships. + +Local build: + +```bash +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -buildvcs=false \ + -ldflags '-s -w -buildid=' -o fleet-hostapi . +``` + +## Config (env, set in the unit) + +| Var | Default | Notes | +|---|---|---| +| `FLEET_HOSTAPI_ADDR` | `127.0.0.1:9099` | must be loopback | +| `FLEET_HOSTAPI_ALLOW_ORIGIN` | `*` | tighten to the kiosk origin in prod | +| `FLEET_HOSTAPI_FACTS` | `/etc/fleet-hostinfo.json` | build-time facts | diff --git a/files/hostapi/fleet-hostapi.service b/files/hostapi/fleet-hostapi.service new file mode 100644 index 0000000..2c41e0e --- /dev/null +++ b/files/hostapi/fleet-hostapi.service @@ -0,0 +1,52 @@ +[Unit] +Description=Fleet workstation host-info API (loopback, read-only) +Documentation=https://git.bitasylum.org/audiobuzz/diskless-workstation/HOST-API.md +# Needs networking up only to read the primary IP/MAC; the listener is loopback. +After=network-online.target +Wants=network-online.target + +[Service] +Type=exec +ExecStart=/usr/local/bin/fleet-hostapi +Environment=FLEET_HOSTAPI_ADDR=127.0.0.1:9099 +# Lab default: any origin may read (it's a loopback, read-only facts service on a +# single-purpose kiosk). Tighten to the exact kiosk origin in production, e.g. +# Environment=FLEET_HOSTAPI_ALLOW_ORIGIN=http://boot.fleet.lab +Environment=FLEET_HOSTAPI_ALLOW_ORIGIN=* +Environment=FLEET_HOSTAPI_FACTS=/etc/fleet-hostinfo.json +Restart=on-failure +RestartSec=2 + +# --- sandboxing (STIG-friendly; the agent only READS /proc, /sys, /etc) ------- +# Runs as a transient unprivileged user with no capabilities; everything it +# reads is world-readable, so it needs no privilege at all. +DynamicUser=yes +CapabilityBoundingSet= +AmbientCapabilities= +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=default +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +RestrictAddressFamilies=AF_INET AF_INET6 +# Kernel-enforced loopback-only egress/ingress (belt + suspenders to the bind). +IPAddressAllow=localhost +IPAddressDeny=any +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/files/hostapi/go.mod b/files/hostapi/go.mod new file mode 100644 index 0000000..7263174 --- /dev/null +++ b/files/hostapi/go.mod @@ -0,0 +1,7 @@ +// fleet-hostapi: the workstation host-info agent (HOST-API.md contract). +// Stdlib only — no external modules — so the build needs no network and is +// trivially reproducible (a hard requirement: the rootfs/verity hash must be a +// pure function of the inputs). Keep it dependency-free. +module fleet-hostapi + +go 1.21 diff --git a/files/hostapi/main.go b/files/hostapi/main.go new file mode 100644 index 0000000..ad75b3a --- /dev/null +++ b/files/hostapi/main.go @@ -0,0 +1,400 @@ +// fleet-hostapi — read-only workstation host-info agent for the kiosk. +// +// Implements the contract in HOST-API.md: a tiny HTTP service on the diskless +// workstation that reports facts about the machine the kiosk renders on. The +// kiosk SPA (served from the boot server, a different origin) fetches +// +// GET http://localhost:9099/v1/host -> application/json +// +// Design constraints (see CLAUDE.md invariants): +// - LOOPBACK ONLY. Never bind a routable address — this leaks host facts and +// must never reach the boot LAN. +// - READ ONLY. No endpoint mutates anything; the agent only reads /proc, /sys, +// /etc/os-release, and a build-time facts file. +// - NO PER-HOST STATE baked into the image. Everything host-specific (kernel, +// hostname, IP, MAC, board, uptime) is read at RUNTIME. The only baked facts +// are image-generic (image version + weston/chromium versions), written to a +// facts file by the build provisioner — same for every host of a release. +// - Stdlib only -> reproducible build (the verity hash is a security property). +// +// CORS + Private Network Access: the SPA is cross-origin AND the fetch is a +// Chromium "private network" request, so every response (incl. the OPTIONS +// preflight) carries Access-Control-Allow-Private-Network: true. +package main + +import ( + "bufio" + "encoding/json" + "flag" + "log" + "math" + "net" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +// agentVersion is overridable at build time via -ldflags "-X main.agentVersion=…". +var agentVersion = "1.0.0" + +// ---- response shape (HOST-API.md) — all fields optional, omit what's unknown -- + +type osInfo struct { + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + Kernel string `json:"kernel,omitempty"` + Image string `json:"image,omitempty"` +} + +type hwInfo struct { + CPU string `json:"cpu,omitempty"` + Cores int `json:"cores,omitempty"` + MemGB int `json:"memGB,omitempty"` + GPU string `json:"gpu,omitempty"` + Board string `json:"board,omitempty"` +} + +type netInfo struct { + Hostname string `json:"hostname,omitempty"` + IP string `json:"ip,omitempty"` + MAC string `json:"mac,omitempty"` +} + +type kioskInfo struct { + Uptime string `json:"uptime,omitempty"` + Weston string `json:"weston,omitempty"` + Chromium string `json:"chromium,omitempty"` +} + +type agentInfo struct { + Version string `json:"version,omitempty"` +} + +type hostResponse struct { + OS osInfo `json:"os"` + Hardware hwInfo `json:"hardware"` + Net netInfo `json:"net"` + Kiosk kioskInfo `json:"kiosk"` + Agent agentInfo `json:"agent"` +} + +// buildFacts is the image-generic, build-time facts file (written by the Packer +// provisioner). Distinct from anything per-host. +type buildFacts struct { + Image string `json:"image"` + Weston string `json:"weston"` + Chromium string `json:"chromium"` +} + +func main() { + addr := flag.String("addr", env("FLEET_HOSTAPI_ADDR", "127.0.0.1:9099"), "listen address (MUST be loopback)") + origin := flag.String("allow-origin", env("FLEET_HOSTAPI_ALLOW_ORIGIN", "*"), "Access-Control-Allow-Origin value") + factsPath := flag.String("facts", env("FLEET_HOSTAPI_FACTS", "/etc/fleet-hostinfo.json"), "build-time facts file") + flag.Parse() + + // Refuse to bind anything that isn't loopback — a hard guardrail against a + // misconfiguration exposing host facts on the boot LAN. + if host, _, err := net.SplitHostPort(*addr); err != nil || !isLoopback(host) { + log.Fatalf("refusing to listen on %q: bind address must be loopback (127.0.0.1/::1)", *addr) + } + + facts := loadFacts(*factsPath) + + mux := http.NewServeMux() + mux.HandleFunc("/v1/host", func(w http.ResponseWriter, r *http.Request) { + cors(w, *origin) + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(collect(facts)) + }) + // Liveness for the unit / debugging (no host facts). + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + }) + + srv := &http.Server{ + Addr: *addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("fleet-hostapi %s listening on %s (loopback, read-only)", agentVersion, *addr) + log.Fatal(srv.ListenAndServe()) +} + +func cors(w http.ResponseWriter, origin string) { + // Private Network Access: the kiosk page (boot-server origin, an insecure/ + // public context) fetching localhost is a "private network" request — the + // preflight needs this header or Chromium blocks it. See HOST-API.md. + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Private-Network", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Vary", "Origin") +} + +// collect gathers all host facts. Each source is best-effort: a failed read +// leaves its field empty (the SPA renders "—" for missing fields). +func collect(f buildFacts) hostResponse { + var resp hostResponse + + // os + rel := parseKV("/etc/os-release") + resp.OS.Name = firstNonEmpty(rel["NAME"], rel["ID"]) + resp.OS.Version = firstNonEmpty(rel["VERSION_ID"], rel["VERSION"]) + resp.OS.Kernel = kernelRelease() + resp.OS.Image = f.Image + + // hardware + resp.Hardware.CPU = cpuModel() + resp.Hardware.Cores = runtime.NumCPU() + resp.Hardware.MemGB = memGB() + resp.Hardware.GPU = gpuModel() + resp.Hardware.Board = boardModel() + + // net + resp.Net.Hostname, _ = os.Hostname() + resp.Net.IP, resp.Net.MAC = primaryIfaceAddr() + + // kiosk + resp.Kiosk.Uptime = uptimeHuman() + resp.Kiosk.Weston = f.Weston + resp.Kiosk.Chromium = f.Chromium + + resp.Agent.Version = agentVersion + return resp +} + +// ---- collectors ----------------------------------------------------------- + +func kernelRelease() string { + var u syscall.Utsname + if err := syscall.Uname(&u); err != nil { + return "" + } + return charsToString(u.Release[:]) +} + +func cpuModel() string { + f, err := os.Open("/proc/cpuinfo") + if err != nil { + return "" + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + // x86: "model name". arm64: "Hardware"/"Processor" if present. + for _, key := range []string{"model name", "Hardware", "Processor"} { + if strings.HasPrefix(line, key) { + if _, v, ok := strings.Cut(line, ":"); ok { + return strings.TrimSpace(v) + } + } + } + } + _ = sc.Err() // best-effort read; empty result on any error + return "" +} + +func memGB() int { + for _, line := range readLines("/proc/meminfo") { + if strings.HasPrefix(line, "MemTotal:") { + fields := strings.Fields(line) // MemTotal: kB + if len(fields) >= 2 { + if kb, err := strconv.ParseFloat(fields[1], 64); err == nil { + return int(math.Round(kb / 1024.0 / 1024.0)) + } + } + } + } + return 0 +} + +// boardModel: vendor + product from DMI (world-readable sysfs — NOT product_serial, +// which is root-only and per-host; we never read it). +func boardModel() string { + vendor := strings.TrimSpace(readFile("/sys/class/dmi/id/sys_vendor")) + product := strings.TrimSpace(readFile("/sys/class/dmi/id/product_name")) + if vendor == "" && product == "" { + vendor = strings.TrimSpace(readFile("/sys/class/dmi/id/board_vendor")) + product = strings.TrimSpace(readFile("/sys/class/dmi/id/board_name")) + } + return strings.TrimSpace(strings.TrimSpace(vendor + " " + product)) +} + +// gpuModel: best-effort from the first DRM card's PCI ids. No PCI-ID database — +// we map the few known vendors and otherwise emit the raw ids. Often empty on a +// headless/virtio build VM; that's fine (optional field). +func gpuModel() string { + for _, card := range []string{"card0", "card1"} { + base := "/sys/class/drm/" + card + "/device/" + vid := strings.TrimSpace(readFile(base + "vendor")) + did := strings.TrimSpace(readFile(base + "device")) + if vid == "" { + continue + } + name := map[string]string{ + "0x8086": "Intel", + "0x1002": "AMD", + "0x10de": "NVIDIA", + "0x1af4": "Red Hat (virtio-gpu)", + "0x1234": "QEMU", + "0x1a03": "ASPEED", + }[strings.ToLower(vid)] + ids := strings.TrimPrefix(strings.ToLower(vid), "0x") + if did != "" { + ids += ":" + strings.TrimPrefix(strings.ToLower(did), "0x") + } + if name != "" { + return name + " [" + ids + "]" + } + return "[" + ids + "]" + } + return "" +} + +// primaryIfaceAddr returns the preferred outbound IPv4 + the MAC of the +// interface that owns it. Uses a UDP "connect" (no packet is sent) to let the +// kernel pick the route's source address. +func primaryIfaceAddr() (ip string, mac string) { + c, err := net.Dial("udp", "192.0.2.1:9") // TEST-NET-1, never reachable; no traffic on UDP connect + if err != nil { + return "", "" + } + defer c.Close() + la, ok := c.LocalAddr().(*net.UDPAddr) + if !ok || la.IP == nil { + return "", "" + } + ip = la.IP.String() + // find the interface carrying this IP -> its MAC + ifaces, err := net.Interfaces() + if err != nil { + return ip, "" + } + for _, ifc := range ifaces { + addrs, _ := ifc.Addrs() + for _, a := range addrs { + if n, ok := a.(*net.IPNet); ok && n.IP.Equal(la.IP) { + return ip, ifc.HardwareAddr.String() + } + } + } + return ip, "" +} + +// uptimeHuman formats system uptime as "3h 12m". On a kiosk appliance the system +// boots straight into the kiosk, so system uptime ≈ kiosk-session uptime — +// reported here to avoid coupling to (and exec'ing) systemd for the unit time. +func uptimeHuman() string { + var si syscall.Sysinfo_t + if err := syscall.Sysinfo(&si); err != nil { + return "" + } + d := time.Duration(si.Uptime) * time.Second + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + mins := int(d.Minutes()) % 60 + switch { + case days > 0: + return strconv.Itoa(days) + "d " + strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m" + case hours > 0: + return strconv.Itoa(hours) + "h " + strconv.Itoa(mins) + "m" + default: + return strconv.Itoa(mins) + "m" + } +} + +// ---- helpers -------------------------------------------------------------- + +func loadFacts(path string) buildFacts { + var f buildFacts + b, err := os.ReadFile(path) + if err != nil { + return f // file absent at runtime -> image/weston/chromium just omitted + } + _ = json.Unmarshal(b, &f) + return f +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func isLoopback(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func parseKV(path string) map[string]string { + m := map[string]string{} + for _, line := range readLines(path) { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), `"`) + } + return m +} + +func readLines(path string) []string { + b := readFile(path) + if b == "" { + return nil + } + return strings.Split(strings.TrimRight(b, "\n"), "\n") +} + +func readFile(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(b) +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +// charsToString converts a NUL-terminated Utsname field (int8 or uint8 across +// arches) to a Go string. +func charsToString(ca []int8) string { + b := make([]byte, 0, len(ca)) + for _, c := range ca { + if c == 0 { + break + } + b = append(b, byte(c)) + } + return string(b) +} diff --git a/files/kiosk/chromium-kiosk-policy.json b/files/kiosk/chromium-kiosk-policy.json index 4d03e03..27e7613 100644 --- a/files/kiosk/chromium-kiosk-policy.json +++ b/files/kiosk/chromium-kiosk-policy.json @@ -31,5 +31,11 @@ "AutofillCreditCardEnabled": false, "PasswordManagerEnabled": false, "ShoppingListEnabled": false, - "EnableMediaRouter": false + "EnableMediaRouter": false, + + "_comment_pna": "Private Network Access: the kiosk page (insecure http boot-server origin) fetches the workstation host-info agent at http://localhost:9099 — a cross-origin PRIVATE-network request Chromium blocks by default. The agent sends Access-Control-Allow-Private-Network: true; this is the matching browser-side allowance, scoped to the kiosk ORIGIN ONLY (not '*'). MUST list the origin of kiosk_url (default http://boot.fleet.lab). The machine talking to itself; no internet egress (see HOST-API.md).", + "InsecurePrivateNetworkRequestsAllowedForUrls": [ + "http://boot.fleet.lab", + "http://localhost" + ] } diff --git a/files/kiosk/kiosk-session b/files/kiosk/kiosk-session index 59e46de..b1a8ec5 100644 --- a/files/kiosk/kiosk-session +++ b/files/kiosk/kiosk-session @@ -26,6 +26,50 @@ SOCK=wayland-kiosk BROWSER=$(command -v chromium-browser || command -v chromium || true) : "${BROWSER:?chromium not found (expected chromium-browser or chromium in PATH)}" +# GPU vs software path. This image is FLEET-GENERIC: it boots both real hardware +# (e.g. the 820 / Intel HD620 -> i915) AND the virtio build/test VMs (no real +# GPU). Probe for a real KMS render node and branch — real GPU gets hardware +# raster/compositing (CPU drops off software raster); VMs keep the software path +# so weston + Chromium still render there. Pure runtime probe; no per-host state +# is baked (invariant #1). +GPU=0 +case "$(basename "$(readlink -f /sys/class/drm/card0/device/driver 2>/dev/null)" 2>/dev/null)" in + i915|amdgpu|radeon|nouveau|xe) [ -e /dev/dri/renderD128 ] && GPU=1 ;; +esac + +# Lockdown flags (what the managed policy can't cover). The distro chromium +# wrapper PREPENDS its own defaults ahead of these — including its own +# --disable-features. Chromium does NOT union duplicate --disable-features (the +# LAST occurrence wins), and ours is last, so we MERGE the wrapper's disables +# (LensOverlay/Gemini/Compose/TabOrganization) into ours — otherwise they are +# silently dropped. We deliberately pass NO --enable-features, so the wrapper's +# VA-API enables (AcceleratedVideoDecodeLinuxGL, ...) stay in effect. +# Phone-home suppression (background networking, component/auto update, domain +# reliability, crash upload, pings, search-engine probe) + the comprehensive +# lockdown live in the managed policy (/etc/chromium/policies/managed/). +COMMON_FLAGS=( + --kiosk --noerrdialogs --disable-infobars --incognito --no-first-run + --disable-session-crashed-bubble --start-maximized + --ozone-platform=wayland + --disable-background-networking --disable-component-update + --disable-sync --disable-domain-reliability --disable-breakpad + --no-pings --no-default-browser-check --metrics-recording-only + --disable-search-engine-collection + "--disable-features=LensOverlay,OptimizationGuideOnDeviceModel,PromptAPIForGeminiNano,Compose,TabOrganization,Translate,OptimizationHints,OptimizationTargetPrediction,MediaRouter,AutofillServerCommunication,InterestFeedContentSuggestions" + --user-data-dir=/tmp/kiosk-profile --disk-cache-dir=/tmp/kiosk-cache +) + +if [ "$GPU" = 1 ]; then + # Real GPU: hardware raster/compositing. HD620 (gen7 Intel) is sometimes + # raster-blocklisted by Chromium, so override the blocklist. The GPU process + # opens /dev/dri/renderD128 -> the kiosk user must be in group 'render' (set + # at build, diskless.pkr.hcl). With --disable-gpu gone, the wrapper's VA-API + # --enable-features finally engage (they need a live GPU process). + GPU_FLAGS=(--ignore-gpu-blocklist --enable-gpu-rasterization --enable-zero-copy) +else + GPU_FLAGS=(--disable-gpu) +fi + # Background: wait (up to ~60s) for weston's socket, then keep Chromium running. ( for _ in $(seq 1 120); do @@ -34,30 +78,17 @@ BROWSER=$(command -v chromium-browser || command -v chromium || true) done export WAYLAND_DISPLAY="$SOCK" while :; do - # --disable-* / --no-pings etc.: stop Chromium phoning home to Google - # (background networking = variations/field-trials + GCM, component & - # auto updates, domain reliability, crash upload, hyperlink pings). The - # --disable-features list kills the Optimization Guide hints fetch - # (optimizationguide-pa.googleapis.com), Translate, Media Router discovery - # and Autofill server calls. The comprehensive lockdown (sign-in, metrics, - # safe browsing, search suggest, ...) is the managed policy at - # /etc/chromium/policies/managed/10-kiosk-lockdown.json; these flags cover - # what policy can't. A fixed-URL kiosk needs none of it. - "$BROWSER" \ - --kiosk --noerrdialogs --disable-infobars --incognito --no-first-run \ - --disable-session-crashed-bubble --start-maximized \ - --ozone-platform=wayland --disable-gpu \ - --disable-background-networking --disable-component-update \ - --disable-sync --disable-domain-reliability --disable-breakpad \ - --no-pings --no-default-browser-check --metrics-recording-only \ - --disable-search-engine-collection \ - --disable-features=Translate,OptimizationHints,OptimizationTargetPrediction,MediaRouter,AutofillServerCommunication,InterestFeedContentSuggestions \ - --user-data-dir=/tmp/kiosk-profile --disk-cache-dir=/tmp/kiosk-cache \ - "$KIOSK_URL" || true + "$BROWSER" "${COMMON_FLAGS[@]}" "${GPU_FLAGS[@]}" "$KIOSK_URL" || true sleep 2 done ) & # weston as the foreground process (owns the seat; service restarts if it dies). -# desktop-shell + explicit socket; --use-pixman = software rendering for VMs. -exec weston --use-pixman --socket="$SOCK" --config=/etc/xdg/weston/weston.ini +# desktop-shell + explicit socket. On real GPU use weston's GL renderer (enables +# the zero-copy dmabuf handoff from Chromium so its frames aren't CPU-copied); on +# VMs force --use-pixman (software) — there's no usable DRM/GL device there. +if [ "$GPU" = 1 ]; then + exec weston --socket="$SOCK" --config=/etc/xdg/weston/weston.ini +else + exec weston --use-pixman --socket="$SOCK" --config=/etc/xdg/weston/weston.ini +fi diff --git a/serving/kiosk-app b/serving/kiosk-app index f0d0677..538c7eb 160000 --- a/serving/kiosk-app +++ b/serving/kiosk-app @@ -1 +1 @@ -Subproject commit f0d0677950c7f26595693a98e3335190d3f39023 +Subproject commit 538c7eb87d67bc2839f55ed246de9f3740a2bc56