Commit Graph
237 Commits
Author SHA1 Message Date
Audiobuzz 20acdb0254 fleet-manager: add behind-proxy admin mode (passkey over plaintext behind a TLS proxy)
Decouples "passkey auth ON" from "the listener terminates TLS". A new
FLEET_MGR_ADMIN_BEHIND_PROXY=true keeps WebAuthn/session auth fully on but serves
plaintext, for running behind a trusted TLS-terminating ingress (Traefik now, a
k8s Ingress later) that owns the LE cert. WebAuthn only needs RP_ORIGIN to match
the browser's HTTPS origin, not our transport; session cookies stay Secure. The
listener must be bound private (loopback/mgmt) so only the proxy reaches it —
logged as a warning. Insecure (dev, no auth) and self-TLS (default) modes
unchanged.
2026-07-05 00:52:19 +00:00
Audiobuzz 3140d51d95 fleet-manager: document hardening status + remaining per-env items
DEPLOY.md gains a "Hardening status" section (done vs operator-to-close), and
TODO #14 records the deployment as CI-built + acme-cert + backup, with the open
items called out: network-segment the :9500/:9600 listeners to the mgmt net, run
server-pki.sh when the agent lands, choose :9500 vs a 443 proxy, and digest-pin
the images.
2026-07-05 00:36:01 +00:00
Audiobuzz f72cea6b17 fleet-manager: add backup.sh for the durable-state volumes
Snapshot fleet-data (inventory + admins.json + enroll.key — the admin-lockout
risk) and influx-data to timestamped tarballs via a throwaway alpine container
(read-only, live-safe), with BACKUP_KEEP retention. Documented in DEPLOY.md
Backups with a restore pointer.
2026-07-05 00:34:23 +00:00
Audiobuzz 487c62c53f fleet-manager: run the CI-built image (FLEET_MANAGER_VERSION), keep local build
Point the compose service at the registry image the new CI workflow pushes
(git.bitasylum.org/audiobuzz/fleet-manager:${FLEET_MANAGER_VERSION:-latest}) so
mgmt hosts `docker compose pull` instead of building at deploy time — matching
dnsmasq/spdk/kiosk. `build: .` stays for local dev. Add FLEET_MANAGER_VERSION to
.env.example.
2026-07-05 00:32:58 +00:00
Audiobuzz a8402a2127 ci: build + push the fleet-manager image (join the CI-built serving images)
Add .gitea/workflows/fleet-manager-image.yaml, modelled on dnsmasq-image.yaml:
buildah bud of serving/fleet-manager (multi-stage SPA+Go→distroless) on a
path filter, with the same branch/tag tagging scheme (semver on tags, main-<sha>
+ latest on main, <branch>-<sha> dev images). Wires it into the shared
conventions:
- gitea-main-ci.yaml paths-ignore: list the new workflow so a fleet-manager
  image change doesn't trigger the ~17-min UKI build (serving/** already covers
  the dir; the .gitea/ workflow file needed listing).
- registry-gc.yaml IMAGES: add fleet-manager so its dev-branch builds are GC'd.
2026-07-05 00:32:19 +00:00
Audiobuzz cf86e615ea fleet-manager: go fix — strings.Split -> strings.SplitSeq in newWebAuthn
`go fix` modernised the comma-origin loop to the Go 1.24+ iterator idiom
(strings.SplitSeq). No behaviour change.
2026-07-05 00:26:41 +00:00
Audiobuzz 2b3708c718 fleet-manager: register-admin link uses the first of multiple RP origins
Follow-up to comma-separated RP_ORIGIN support: the enrolment URL builder used
the raw origin string, which would embed the whole comma list. Take the first
origin for the link.
2026-07-05 00:24:10 +00:00
Audiobuzz 76446edd75 fleet-manager: allow multiple WebAuthn origins (comma-separated RP_ORIGIN)
Passkeys bind to the exact scheme://host:port the browser used, so a single
configured origin fails "Error validating origin" when reached via a different
form (e.g. :9500 direct vs a 443 proxy). Split FLEET_MGR_RP_ORIGIN on commas so
several exact origins can be allowed. Single-value config is unchanged.
2026-07-04 11:07:15 +00:00
Audiobuzz 643ba6be2d fleet-manager: actionable error when the enrol key is missing at startup
Flipping FLEET_MGR_ADMIN_INSECURE=false before bootstrapping an admin crash-looped
on a cryptic "enrol key: open /data/enroll.key: no such file or directory". The
key is created by `register-admin`, so on ErrNotExist say exactly that (and note
the insecure escape hatch) instead of surfacing the raw errno.
2026-07-04 10:55:24 +00:00
Audiobuzz 693cf94695 fleet-manager: point acme DNS-01 propagation check at a public resolver
Split-horizon DNS made lego's propagation check query the container's internal
resolver, which doesn't serve the public _acme-challenge TXT record, so the
challenge timed out. Add ACME_RESOLVERS (comma-separated host:port, default
8.8.8.8:53) → one --dns.resolvers flag each. Documented in .env + DEPLOY.md.
2026-07-04 10:24:42 +00:00
Audiobuzz d31c0f18eb fleet-manager: locate lego at /lego in the acme side-car
The goacme/lego image ships the binary at /lego, not on PATH, so the bare `lego`
call failed ("not found") once the entrypoint was overridden with the shell.
Detect it (command -v, then /lego fallback) and invoke via $LEGO.
2026-07-04 10:20:48 +00:00
Audiobuzz 510f4f6b25 fleet-manager: pass optional AWS_HOSTED_ZONE_ID to the acme side-car
lego auto-discovers the Route 53 zone via ListHostedZonesByName, so the zone ID
is optional — but pinning it disambiguates overlapping public/private zones and
lets the IAM policy drop the list permission. Thread it through compose + .env
+ DEPLOY.md (no script change; lego reads it from the environment).
2026-07-04 10:14:51 +00:00
Audiobuzz 9479b70b04 fleet-manager: add acme side-car for the admin-UI cert (LE DNS-01 via Route 53)
Auto-issue + auto-renew the admin-UI TLS cert with a long-running compose
side-car instead of a host cron. The `acme` service (goacme/lego image, running
acme/renew-loop.sh) obtains the cert over DNS-01 via the AWS Route 53 API,
writes fullchain/privkey into ./pki/admin/ — the SAME dir the manager reads
(./pki:/pki:ro) — and chowns them to the nonroot runtime uid so the read-only
container can load the key. The manager's certReloader hot-swaps on the mtime
bump, no restart. Loop renews within 30 days of expiry; ACME_STAGING=1 gives a
dry-run vs LE staging.

- docker-compose.yml: `acme` service + `acme-state` volume (lego account/cert
  store). ACME_DOMAIN reuses FLEET_MGR_RP_ID so cert host == passkey origin.
- .env.example: ACME_EMAIL + AWS creds (or instance role) + ACME_STAGING.
- DEPLOY.md §2c: rewritten to the side-car flow — scoped Route 53 IAM policy,
  bring-acme-up-first ordering, flip off insecure, and an origin-match note.

(The docker-compose.yml commit also carries the local harbor.bitasylum.org
influxdb image mirror edit.)
2026-07-04 10:13:51 +00:00
Audiobuzz bf546a80a6 fleet-manager ui: render a never-reported host's last_seen as "never"
A just-approved inventory record has a zero-value last_seen (Go 0001-01-01),
which parsed to a pre-epoch instant and slipped past the !t guard, rendering as
"739800d ago". Treat any at-or-before-epoch timestamp as "never" — the honest
state for a born-but-never-connected host (its health/version stay "—" until an
agent dials the gRPC plane).
2026-07-04 10:03:28 +00:00
Audiobuzz 0360c1c6d1 fleet-manager: fix nonroot data-volume ownership + document broker wiring
The distroless nonroot runtime (uid 65532) couldn't write /data/inventory.json
because a named volume is created root-owned — approve failed with permission
denied on the inventory birth. Pre-create /data owned by nonroot in the image so
a fresh volume inherits it via Docker copy-up; an already-created root-owned
volume still needs a one-time chown (documented).

DEPLOY.md:
- 2b now covers BOTH broker deployments — the main serving stack
  (enrol-pki/broker, ENROL_ADMIN_INSECURE=true by default → must flip to false)
  and the bench stack — with the mint-once-into-broker-dir, copy-CA-to-manager
  flow and an mTLS verify curl.
- Troubleshooting section: the data-volume permission-denied fix, the
  "authorise plane not configured" (recreate + single-instance) fix, and the
  "wrong version number" (broker plaintext) fix.
2026-07-04 09:56:09 +00:00
Audiobuzz 014f43cb5a fleet-manager: log a positive line when the authorise pillar enables
Every plane logged its state EXCEPT the broker success path, which was silent —
so "authorise pillar working" was indistinguishable from "not logged". Add an
ENABLED line matching the InfluxDB/agent/admin convention.
2026-07-04 09:25:10 +00:00
Audiobuzz ea29d0d12e fleet-manager: soft-disable optional planes when their PKI isn't staged yet
The compose wires the authorise (broker) and agent planes to fixed cert paths,
so a first bring-up that hasn't run the PKI steps yet crash-looped on the first
missing file (e.g. /pki/broker/clients/fleet-manager.crt) instead of booting.

Treat a configured-but-absent cert as "plane not yet provisioned": log a loud
WARNING and disable that plane, so the manager boots and each plane lights up
as its PKI appears. A malformed/unreadable cert still hard-fails (real
misconfig). The admin plane stays a hard requirement (FLEET_MGR_ADMIN_INSECURE
is its bench escape). Adds a missingFiles() helper; hub push path already
nil-guards a disabled agent plane.
2026-07-04 08:58:38 +00:00
Audiobuzz 2ef0c1747d fleet-manager: checkpoint 7 — deployment (compose, PKI, deploy runbook, design-doc updates)
Complete the deployment story for the server-side control plane:

- docker-compose.yml: private InfluxDB (published to loopback only) + a
  host-networked fleet-manager (reaches the loopback enrol-broker on
  127.0.0.1:9443 and binds a routable gRPC agent listener, like the broker).
- .env.example: RP origin (diskless-server.bitasylum.org), Influx token/DB
  password, cert/PKI paths; the Influx token wires influxdb + manager with
  one value.
- server-pki.sh: issue the agent-plane server cert from the fleet CA (step)
  and stage the fleet root as the client-verify CA.
- DEPLOY.md: end-to-end runbook — three-cert PKI (fleet-CA agent cert, broker
  admin-client cert, Let's Encrypt DNS-01 UI cert), register-admin passkey
  bootstrap, compose up, hardware-free smoke test, backups.
- .dockerignore: keep host node_modules/dist/pki/secrets out of the build
  context so the embedded SPA is the one built in-image.
- .gitignore: never commit .env / issued PKI / data.

Design docs marked as-built: fleet-management-design.md as-built note
(resolved forks — gRPC bidi transport, InfluxDB metrics, SPA graphs, passkey
admin auth, broker fold-in "alongside"); tpm-enrolment-design.md (broker
fold-in + agent transport fork resolved); TODO.md #14 → partly built, with
the on-host agent + per-host boot-version assignment (#6) called out as the
remaining work.
2026-07-04 08:44:31 +00:00
Audiobuzz f62c668ce3 fleet-manager: add Dockerfile (checkpoint 7 start)
Two-stage build: node builds the Svelte SPA to dist, then golang compiles the
binary with the SPA embedded (-tags embedspa). Committed .pb.go stubs mean no
protoc. Ships as a small static distroless/nonroot image; same binary serves the
register-admin subcommand. (Not build-verified here — no docker in the
devcontainer — but the go build -tags embedspa + npm build are both proven.)
2026-07-04 06:19:21 +00:00
Audiobuzz 12c4296f9d fleet-manager ui: label target-version pin as recorded intent (not yet enforced)
Per the option-A decision (deploy current design without per-host versioning):
add an honest note on the Lifecycle panel that kiosk-url/reboot/drain reconcile
via the agent but per-host boot-version assignment (dnsmasq cohort steering) is
not yet wired, so a version pin is stored + pushed but does not change what the
host boots. Avoids implying the pin takes effect.
2026-07-04 06:19:21 +00:00
Audiobuzz f71a55eab5 fleet-manager ui: target version as a dropdown (fleet-known + custom)
Replace the free-text target-version field with a select populated from versions
known across the fleet (each host's running + targeted version, newest first),
plus a 'custom...' escape to a text input for a version not yet seen. Avoids
typos on the pin. (Authoritative serving-side source is a follow-up.)
2026-07-04 06:08:15 +00:00
Audiobuzz 20b29d9990 fleet-manager ui: readability + reboot-now
- Contrast: lighten the muted/faint text tokens (AA on the hull panels) and make
  Monitor host-card data values bright ink against dim faint labels, so version/
  tmpfs/seen details read clearly on the blue-grey panels (was grey-on-grey).
- Lifecycle: reboot selector now offers none | now | at... — 'now' sets
  reboot_at to the current time (agent reboots on reconcile); still confirm-gated
  as a high-consequence action.
2026-07-04 06:05:29 +00:00
Audiobuzz fae3624c58 fleet-manager: checkpoint 6 — the admin SPA (fleet command deck)
The themed single-page admin UI (serving/fleet-manager/ui/), embedded into the
binary and served same-origin over the admin plane.

- Svelte 5 + Tailwind 4 + Vite; a sibling of serving/kiosk-app. Theme = LYSANDRA
  'restricted-violet' (high-security evolution of the kiosk's flight-deck DNA):
  amber deck, VIOLET reserved for privileged/destructive + auth surfaces, teal
  telemetry, red alert, clearance chrome (corner-reticle panels, scanlines, mono
  clearance labels, secure-link status bar).
- Passkey gate: Login (usernameless discoverable WebAuthn) + Enrol (from the
  register-admin one-time link) via navigator.credentials, with base64url<->buffer
  helpers and the go-webauthn response shapes.
- Three pillars over the JSON API: Monitor (fleet roster + version-drift + health
  + per-host detail with bespoke SVG telemetry charts), Authorise (pending queue:
  fingerprint cross-check, approve+bind in violet, deny), Lifecycle (desired-state
  form; drain/reboot confirm-gated). Session-aware fetch client (cookie + CSRF).
- go:embed behind the  build tag (spa_embed.go / spa_stub.go) so the
  default go build/test need no dist/; mounted at / (api/auth/healthz win).

Verified: npm build clean (71KB JS / 33KB CSS + self-hosted fonts); default +
-tags embedspa go builds; smoke test serves index + assets + API through the SPA
origin. Against an insecure+seeded backend the deck renders passkey-free for
theme work. dist/ + node_modules gitignored.
2026-07-04 05:54:41 +00:00
Audiobuzz c40dd5ac93 fleet-manager: checkpoint 5 — admin WebAuthn/passkey auth (replaces browser mTLS)
The human->UI hop now authenticates with a hardware passkey (YubiKey), not an
mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker
stays mTLS — service-to-service.)

- webauthn.go: credential store (registered admins + passkeys + a one-time-token
  jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup.
- cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed
  enrolment link over a shared enrol key; the browser completes the WebAuthn
  create ceremony. No anyone-can-register endpoint; single-use via the ledger.
- authapi.go: register/login ceremonies (usernameless discoverable login,
  userVerification required) + logout + whoami; short-lived in-memory ceremony
  store between begin/finish.
- session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie)
  + requireSession middleware gating /api/*, with a CSRF header check on mutations.
  adminName now comes from the session (audit attribution).
- main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH
  (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt
  DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org.
  /api/* mounted behind requireSession; /auth/* + /healthz public.

Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE
cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01
can't validate; DNS-01 proves control of bitasylum.org via TXT + supports
wildcards). External ACME client issues+renews to the cert path; manager reloads.

Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token
mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store
persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony
(virtual authenticator / real YubiKey) is a bench item.
2026-07-04 04:26:30 +00:00
Audiobuzz 9c86cd4c5a fleet-manager: checkpoint 4 — InfluxDB metrics gateway (write + Flux read)
The manager is the ONLY thing that talks to InfluxDB, both directions; hosts hold
no token and never reach it.

- influx.go: influxSink implements MetricsSink — each agent HealthSnapshot is
  written as a fleet_health point, identity (serial/hostnqn/starship) as TAGS
  from the AUTHENTICATED stream (a host can only write its own series), telemetry
  as fields. QueryField answers the SPA's read: a Flux query built with a strict
  FIELD WHITELIST + hex-serial guard + parsed-duration range, so no caller input
  is interpolated raw (injection-safe). Server-side influxdb-client-go/v2.
- admin.go: GET /api/hosts/{serial}/metrics?field=&range= -> time-series JSON;
  503 when InfluxDB isn't wired, 400 on a bad range, 404 for unknown host.
- main.go: sink created when FLEET_MGR_INFLUX_URL+_TOKEN set, fed to the agent
  hub; nil -> no-op sink so the manager runs without metrics.

Tests: snapshotPoint tags/fields/time, fluxQuery contents + %q quoting, and
QueryField rejects a non-hex serial + non-whitelisted field. build/test/vet/gofmt
clean. Live InfluxDB path validated in the compose bench (ckpt 6).
2026-07-04 03:59:18 +00:00
Audiobuzz df4fa95bdd fleet-manager: checkpoint 3 — authorise pillar (broker fold-in, births inventory)
The AUTHORISE pillar: the manager owns the enrolment queue UX, the enrol-broker
stays the state machine + step-ca signer (v1 'alongside' fold-in).

enrol-broker (minimal, backward-compatible change):
- admin approve/deny now return the decided Enrolment as JSON when the caller
  sends Accept: application/json. Browsers still redirect; other API callers
  (verify-serving.sh) still get 204. The cert PEM stays unexported (only the
  requesting host gets it via /status) — the JSON carries cert_serial, hostnqn,
  context, fingerprint, cn.

fleet-manager:
- broker.go: mTLS client for the broker admin plane (Pending/Approve/Deny),
  nil when unconfigured so the manager runs standalone.
- admin.go: GET /api/enrol/pending (proxy), POST /api/enrol/{id}/approve
  (drives broker, then BIRTHS the inventory record — identity from the returned
  serial/hostnqn/context, binding from the operator's optional body, enrolled
  with the HUMAN approver = the manager's admin mTLS CN), POST .../deny.
  503 when the broker isn't wired.

Test: httptest mock broker asserts the Accept:application/json approve, pending
proxy, inventory birth (identity+binding+enrolled), and the disabled-503 path.
Broker + manager both build/vet/test/gofmt clean.
2026-07-04 03:51:26 +00:00
Audiobuzz 80cbb833cc fleet-manager: checkpoint 2 — gRPC agent plane (bidi mTLS stream)
The steady-state host<->manager transport, resolving the design's OPEN FORK to
bespoke gRPC (not OTLP+Wazuh). One long-lived, host-dialed, mTLS bidi stream:
health/metrics up, desired-state/commands down — no polling, real-time control,
still no inbound host port.

- proto/fleet_agent.proto: FleetAgent.Session(stream AgentUp) -> stream ManagerDown.
  AgentUp = Hello | HealthSnapshot | CommandResult; ManagerDown = DesiredState |
  Command(reboot/drain/resync). int64 unix times (no well-known-type dep).
  Generated stubs committed (CI needs no protoc).
- agent.go: gRPC server with mTLS verified by the FLEET CA (the host's reused TPM
  identity cert — distinct from the admin plane's admin CA). Identity is the peer
  cert SERIAL, taken from the verified cert = inventory key, never self-asserted;
  an unenrolled serial is refused (enrolment gates the stream). Per-conn send
  channel decouples admin pushes from stream.Send. Stream registry (serial->conn)
  with reconnect eviction. On connect + on change, desired-state is pushed live;
  inventory stays authoritative so offline hosts reconcile on reconnect. Metrics
  go through a MetricsSink seam (no-op now; InfluxDB in ckpt 4).
- main.go: agent plane starts only when its cert/key/CA are set; admin can run
  standalone. Lifecycle PUT now live-pushes and reports pushed_live.

Full mTLS integration test (in-process fleet CA + host client cert): initial
desired-state on connect, Hello+snapshot -> inventory + online presence, live
desired-state push, and unenrolled-serial rejection. go build/test/vet/gofmt clean.

Deps (server-side only): google.golang.org/grpc + protobuf.
2026-07-04 03:40:55 +00:00
Audiobuzz 0572d21c07 fleet-manager: drop accidentally-committed binary + add .gitignore
The checkpoint-1 commit included the 9.1M compiled 'fleet-manager' binary. Remove
it from tracking and gitignore it (/fleet-manager + *.test), matching the
enrol-broker convention. Build artifacts don't belong in git.
2026-07-04 03:26:44 +00:00
Audiobuzz 915a15b6a1 fleet-manager: checkpoint 1 — service skeleton + inventory store + admin API
First slice of the server-side fleet control plane (serving/fleet-manager/), the
backend for the admin SPA (monitor / authorise / lifecycle). See
docs/fleet-management-design.md + tpm-enrolment-design.md §5.

This checkpoint (stdlib-only):
- Inventory record (identity/binding/enrolled/version/health/usage/desired),
  keyed by TPM cert serial = hostnqn key, per the design's 'inventory record born
  at enrolment'. JSON-file Store (atomic writes, mutex) behind an interface so a
  DB swap is a drop-in.
- Admin plane: mTLS server verified by the DEDICATED admin CA (never the fleet CA,
  so a host cert can't reach admin) + a plaintext dev mode. JSON API:
  GET /api/hosts, GET /api/hosts/{serial}, PUT /api/hosts/{serial}/desired
  (desired-state audited to the admin client-cert CN).
- Dev seed (FLEET_MGR_SEED) so the SPA has data before real enrolment/agent flow.

Smoke-tested: list/get/desired-PUT (incl. version-drift + drain), 404s, atomic
persistence. go build + go vet + gofmt clean.

Agent gRPC plane, broker-fold-in authorise, InfluxDB metrics, and the themed SPA
follow in checkpoints 2-6.
2026-07-04 03:25:54 +00:00
Audiobuzz e27f068aaf docs/stig-deviations #15: fapolicyd overlay watch VERIFIED enforcing (2026-07-04)
Bench pass on fapolicyd-tmpfs-12043d3 / comet-beacon-54: permissive=0, watch_fs has
overlay, / and /tmp both overlayfs; novel-hash /tmp binary DENIED (FANOTIFY resp=2,
obj_trust=0, exit=126); empty deny sweep = zero false denials (kiosk/weston/chromium/
NM-dispatcher all trusted, admin SSH+sudo work under staff_t). The exec guard went
from effectively OFF to enforcing on the whole overlay root, nothing legit broken.

Also records the scope caveat: integrity=none path-trust closes the UNTRUSTED-PATH
half (proven) but not the MODIFIED-TRUSTED-PATH half — an overlayfs copy-up of a
trusted path (e.g. /usr/bin/bash) lands a same-path tmpfs copy outside dm-verity that
path-trust still trusts. Closing it needs integrity=sha256 or an un-shadowable /usr
(TODO #15, preferred).
2026-07-04 02:52:04 +00:00
Audiobuzz 18aad0888c Merge origin/main (trim-rpm) into optimise-ram
Combines the image trim (#18: langpack/firmware slim, kiosk weak-deps, nss-mdns
KEEP+mask-avahi fix, final kiosk-stack build guard) with optimise-ram's overlay-RAM
work (#17 fapolicyd db_max_size cap, .pyc unchecked-hash recompile).

Conflict resolutions:
- diskless.pkr.hcl (2d hygiene tail): kept BOTH additions — the .pyc recompile
  runs first (the 'must run last after every dnf' mutation), then the read-only
  FINAL GUARD (rpm -q kiosk stack) as the absolute last step. The nss-mdns fix
  (mask avahi, keep the package) auto-merged above the conflict — no regression.
- docs/TODO.md: kept both #17 (fapolicyd db_max_size) and #18 (image trim), as the
  #18 stack-note predicted.
2026-07-04 02:49:55 +00:00
Audiobuzz bdc7e6b234 Merge remote-tracking branch 'origin/main' into fapolicyd-tmpfs 2026-07-04 12:11:05 +09:30
audiobuzz 2d9e813488 Merge pull request 'trim-rpm' (#12) from trim-rpm into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/12
2026-07-04 02:39:51 +00:00
Audiobuzz 68dee79832 docs/TODO #18: record nss-mdns/chromium regression + fix; kiosk re-verified, image 1.8 GiB → 755 MiB
The '#18 render should be safe' call on the nss-mdns drop (5cc72cc) was wrong: it
bricked the kiosk on trim-rpm-48dc3e1 (chromium hard-Requires nss-mdns, so the 2d
remove cascaded chromium + its GUI stack out → 500 MB browserless image, flashing
cursor). Fixed in 2bc2daf (keep nss-mdns, mask avahi daemon; final build guard
asserts the kiosk stack survived). Re-verified booting into the kiosk on the 820;
container image measured 1.8 GiB → 755 MiB (~58%).
2026-07-04 02:24:57 +00:00
Audiobuzz de57a7de11 Merge branch 'main' into optimise-ram 2026-07-03 21:53:18 +09:30
Audiobuzz 2bc2dafb1c kiosk: stop nss-mdns removal from cascading chromium out of the image
Root cause of the trim-rpm-48dc3e1 'flashing cursor, no kiosk' + mysteriously
small (~500 MB) image: the 2d hygiene step ran 'dnf remove -y nss-mdns', but the
EPEL chromium package HARD-Requires nss-mdns (Chromium uses .local mDNS hostnames
for WebRTC ICE candidates). So dnf removed chromium as a dependent package and
then reaped its orphaned GUI stack (avahi, at-spi2, atk, adwaita, chromium-common
...) — a ~300 MB drop. The shipped image had no browser, so kiosk-session died at
the ${BROWSER:?} guard before ever starting weston: a bare KMS cursor.

The earlier nss-mdns commit checked what nss-mdns NEEDS (avahi) but not what NEEDS
nss-mdns (chromium).

Fix:
- Keep nss-mdns + avahi (satisfy chromium's Requires); instead MASK the avahi
  mDNS daemon/socket so nothing listens on :5353 — that was the real
  attack-surface concern, and nss-mdns is only an NSS module (no daemon needed).
- Add a FINAL GUARD at the end of 2d: assert chromium/chromium-common/weston/mesa
  are still installed in the final image, else fail the BUILD. A missing browser
  must brick the build, never the fleet. Catches any future silent removal
  cascade, oscap package_*_removed rule, or autoremove.
2026-07-03 12:21:22 +00:00
Audiobuzz 12043d3444 stig: add overlay to fapolicyd watch_fs — actually guard the overlay root
Bench-verified the getattr allow (620a604) alone did NOT close the exec gap: with
the fapolicyd_tmpfs module installed (AVC gone, fapolicyd active, permissive=0), a
NOVEL non-RPM binary in /tmp still ran. Root cause: the diskless root is overlayfs
(stat -f / -> overlayfs), but the shipped watch_fs is
ext2,ext3,ext4,tmpfs,xfs,vfat,iso9660,btrfs — no 'overlay'. fapolicyd marks mounts
by fs type, so it never marked the overlay and intercepted no exec on / at all.
The exec guard we keep fapolicyd for was effectively OFF at runtime on every
diskless host.

Fix: the 2c fapolicyd step now appends 'overlay' to watch_fs (idempotent) so
fapolicyd watches the overlay root. Trust is path-based (integrity=none) so overlay
paths resolve against the rpmdb trust normally; the fapolicyd_tmpfs .te still
supports fapolicyd statfs'ing the overlay's tmpfs upper. Deviation #15 updated.

This turns app-allowlisting ON for the whole root at runtime for the first time.
BENCH-VERIFY HARD: boot + kiosk render + admin SSH + sudo + TPM enrolment all still
work (nothing legit untrusted), a novel /tmp binary is now BLOCKED (EPERM), no new
AVCs. enable_stig=false is the escape hatch if it regresses.
2026-07-03 11:56:13 +00:00
Audiobuzz 48dc3e11ae Merge remote-tracking branch 'origin/main' into trim-rpm 2026-07-03 20:14:51 +09:30
audiobuzz 8815fe0618 Merge pull request 'nvmeof-mounting' (#15) from nvmeof-mounting into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/15
2026-07-03 10:44:13 +00:00
Audiobuzz f882ddae1f docs/TODO #6: targeted-connect test-path bench-verified (0bfe872 mounts one image)
0bfe872 builds, deploys, and the host now mounts ONLY its own version's image
instead of connect-all'ing every served version — the targeted 'nvme connect -n
rd.nvmf.nqn' works. Each host = 1 controller on its own subsystem, which is what
lets old versions drain to 0 and spdk-loader auto-reap. Still to confirm: the reap
payoff (migrate a host, watch its old version drain + reap), and the secure path's
new step-6 connect over macsec0.
2026-07-03 10:43:24 +00:00
Audiobuzz 480d926a98 trim: fix SIGPIPE (exit 141) in the initramfs-largest-files diagnostic
853ae20's diagnostic did 'awk … | sort -rn | head -25'. head closes the pipe
after 25 lines, sort dies of SIGPIPE (141), and step 2's set -euxo pipefail turns
that into a build failure (the exact trap the lsinitrd '| grep -q' comment warns
about). The kiosk step's rpm-manifest '| head' is safe only because that step
runs plain set -e, no pipefail.

Fix with the codebase convention: sort into a file, then head the file — no live
pipe for head to break. (Bonus: the crash log confirms omit_drivers worked —
amdgpu/radeon/nouveau kmods are gone from the top-25; the new biggest initramfs
item is GenuineIntel.bin CPU microcode at 22 MB, a future trim candidate.)
2026-07-03 07:48:09 +00:00
audiobuzz 5ac03fa8b4 Merge pull request 'ci: registry-gc — read branches with the Actions token, not PACKAGE_TOKEN' (#14) from registry-gc into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/14
2026-07-03 07:45:39 +00:00
Audiobuzz 51d9dc0888 ci: registry-gc — read branches with the Actions token, not PACKAGE_TOKEN
The dry-run failed with HTTP 403 on the branches API: PACKAGE_TOKEN is a
package-scoped token (registry push/delete) and can't read repo branches. Split
by capability: branch reads now use the automatic Actions token (github.token,
repo read via 'permissions: contents: read'); package list + delete keep
PACKAGE_TOKEN. Also replaced the bare 'curl -f' failures with a get() helper that
reports the HTTP code + a token-scope hint, so an auth problem can't look like
'no results' (which would otherwise skew the reconcile).
2026-07-03 07:43:24 +00:00
audiobuzz 09a8fdd444 Merge pull request 'ci: add registry-gc — prune orphan container images for deleted branches' (#13) from registry-gc into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/13
2026-07-03 07:38:03 +00:00
Audiobuzz 0d725af716 ci: add registry-gc — prune orphan container images for deleted branches
Reconciliation workflow (schedule + workflow_dispatch): lists live branches and
every container version across the four images this repo builds (fleet-boot,
fleet-ipxe, fleet-dnsmasq, spdk-nvmf) and deletes dev/branch builds whose branch
no longer exists, via the Gitea Packages API. Not event-driven, so it catches
branches deleted any way and never depends on a 'delete' webhook.

Safety (per request — never touch release tags or latest):
- Only tags ending in the dev commit-sha suffix '-<7 hex>' are ever candidates.
  Release/version tags (1.2.3, v1.2.3, spdk v24.09, dnsmasq main-<sha>) and
  'latest' never carry that suffix, so they're structurally out of scope; there's
  also an explicit 'latest' skip. Verified by simulation across representative
  tags incl. spdk's <ver>-<branch>-<sha> form and pre-release semver.
- Keeps any image whose sanitized branch is still live (+ always 'main'); branch
  sanitization mirrors the build workflows.
- Fail-safe: aborts before deleting anything if the live-branch list can't be
  fetched (0 branches), so an API/auth blip can't trigger mass deletion.
- Conservative matching: a suffix collision only ever causes a KEEP.

DRY RUN by default: workflow_dispatch previews unless dry_run=false; scheduled
runs dry-run until armed via repo variable REGISTRY_GC_ENABLE=true (so a
scheduled dry-run can be eyeballed first). Uses the existing PACKAGE_TOKEN
(write:package). shell: bash (runner defaults to sh).
2026-07-03 07:33:46 +00:00
Audiobuzz 620a60401f stig: ship fapolicyd_tmpfs SELinux module — close overlay exec-guard gap
fapolicyd is the app-allowlisting exec guard for the writable RAM overlay
(dm-verity covers only the read-only erofs lower). It statfs's mounts to decide
which to watch, and tmpfs is in its default watch_fs. This image's root is
systemd.volatile=overlay, so statfs("/") returns the overlay upper — a tmpfs —
and the shipped policy has no 'fapolicyd_t tmpfs_t:filesystem getattr' (no
ordinary host roots on tmpfs). Under enforcing the getattr is DENIED (audit:
dev=tmpfs ino=1 name="/", permissive=0, seen 2026-07-03 on comet-beacon-54),
so fapolicyd can't fully mark the tmpfs mounts (/, /tmp, /dev/shm) and its guard
over the writable overlay is incomplete — a real exec-guard gap, not just SIEM
noise.

Ship a minimal local allow module (allow, not dontaudit — we want fapolicyd to
see the tmpfs), compiled + semodule -i'd in 2c-STIG right after the chrony one.
Registered as deviation #15.

BENCH-VERIFY: the AVC is gone on an enforcing boot AND fapolicyd blocks a non-RPM
executable dropped in /tmp (proves the guard now covers the overlay), trusted
kiosk/appliance binaries still run, and the verity hash is reproducible across
two builds (semodule rebuilds the policy store).
2026-07-03 07:18:01 +00:00
Audiobuzz 8e105c8bf1 docs/TODO #18: kiosk render bench-verified on VM + 820 (i915 firmware confirmed)
f59eb79 now fully bench-verified: the fleet SPA renders on both the VM
(virtio-gpu) and the 820 (i915), so the i915 DMC/GuC firmware loads on real
hardware and the firmware keep-list is validated end-to-end. The two later
commits (nss-mdns drop, omit_drivers + keep-list tightening) don't touch i915,
so render should be safe, but still need their own build+boot.
2026-07-03 06:58:52 +00:00
Audiobuzz bf0c61a025 docs/TODO #18: record f59eb79 built + booted successfully
The ~800 MB trim (glibc langpacks en-only + linux-firmware strip to the i915
keep-list, run pre-dracut) builds and boots — the boot/root path survives it
(firmware 678->147 MB, initramfs bundles only the keep-list). Notes still-open
checks: kiosk RENDER on real i915 hardware ('booted' != 'display up'), and that
the two later commits (nss-mdns drop 5cc72cc; omit_drivers + keep-list tightening
853ae20) are NOT in f59eb79 and need separate bench-verify.
2026-07-03 06:55:52 +00:00
Audiobuzz 853ae20e68 trim: omit unused GPU drivers + tighten firmware keep-list (TODO #18)
First trim build confirmed firmware 678 MB -> 147 MB, but the initramfs came
out 97 MB and is now kernel-MODULE-bound, not firmware: --no-hostonly +
plymouth's drm dep instmods every GPU driver (amdgpu.ko etc.) though the fleet
is i915-only.

- omit_drivers += amdgpu radeon nouveau (90-diskless.conf): drop the discrete-GPU
  kmods (amdgpu.ko is the biggest single module in the initramfs); keep i915 +
  virtio-gpu/bochs for the splash. Shrinks the initramfs/UKI pulled over PXE on
  every boot.
- Firmware keep-list tightened: drop mellanox/ (mlxsw_spectrum SWITCH-ASIC blobs,
  not NIC fw — mlx5 NIC fw is on-card) and narrow intel/ to just ice (drop
  ish/sof/ibt sensor-hub/audio/bluetooth, none loaded at boot per the audit).
- Add an 'initramfs largest files' diagnostic (sort lsinitrd.txt by size) so the
  next build shows what dominates the .img.

BENCH-VERIFY: kiosk still renders on the 820 (i915 kept); watch the new initramfs
largest-files log line for the post-omit size.
2026-07-03 06:36:32 +00:00
Audiobuzz 0bfe872828 nvmf: targeted per-version connect (rd.nvmf.nqn) instead of connect-all
Hosts connected with 'nvme connect-all', which attaches EVERY subsystem in the
discovery log. Each host then mounts only its own by-UUID verity namespaces but
leaves a live controller on every other served version's subsystem. Observed via
spdk-release list: more controllers than workstations, each box on every version
(1/3/3/1/3 across 5 versions for ~3 boxes). Beyond wasted connections
(O(hosts x versions)), it defeated the rolling-update drain/reap: no version ever
reached 0 connections while any host was up, so spdk-loader's auto-reap could
never fire and old versions accumulated without bound.

Fix — targeted connect to this build's subsystem only:
- extract-artifacts.sh bakes the per-version SUBNQN (already in verity.json
  .subnqn, matched by the serving side) into the signed UKI cmdline as
  rd.nvmf.nqn=. NQN is version identity, not per-host state, and the cmdline is
  already per-version + immutable-per-signed-UKI, so invariant #1 holds.
- nvmf-connect.sh (test path) uses 'nvme connect -n <nqn>'; legacy UKIs without
  rd.nvmf.nqn fall back to connect-all with a warning so they still boot.
- dot1x-macsec-up.sh (secure path) gains an explicit step-6 targeted connect over
  macsec0 (after it's addressed, per invariant #2), replacing the delegation to
  the dracut nvmf module's discovery connect-all.

Now each host = 1 controller on its own subsystem; versions drain to 0 when their
hosts migrate, so auto-reap works and connections scale O(hosts). Requires a
rebuild + re-sign (cmdline change, invariant #6). BENCH-VERIFY both paths;
confirm the dracut nvmf module isn't also connect-all-ing on the secure path.
CLAUDE.md invariant #2 + TODO #6 updated.
2026-07-03 06:14:08 +00:00