Merge pull request 'app' (#4) from app into main

Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/4
This commit is contained in:
audiobuzz
2026-06-19 23:52:58 +00:00
57 changed files with 3858 additions and 130 deletions
+4 -3
View File
@@ -49,9 +49,10 @@ env:
# per-environment DNS name (e.g. nvme.fleet.lab) resolved at boot so one UKI # per-environment DNS name (e.g. nvme.fleet.lab) resolved at boot so one UKI
# works across environments — the serving stack's dnsmasq resolves it. # works across environments — the serving stack's dnsmasq resolves it.
NVMET_TRADDR: "192.168.50.10" NVMET_TRADDR: "192.168.50.10"
# URL the fullscreen Chromium kiosk loads at boot. Defaults to the test page # URL the fullscreen Chromium kiosk loads at boot. Defaults to the kiosk SPA
# served by the boot HTTP server (serving/http/kiosk) at /kiosk/; boot.fleet.lab # (serving/kiosk-app) reverse-proxied by the boot HTTP server at /kiosk/;
# resolves to the serving host via the stack's dnsmasq. Empty disables the kiosk. # boot.fleet.lab resolves to the serving host via the stack's dnsmasq. Empty
# disables the kiosk.
KIOSK_URL: "http://boot.fleet.lab/kiosk/" KIOSK_URL: "http://boot.fleet.lab/kiosk/"
# Plymouth boot splash. "false" => verbose console boot (rhgb quiet NOT baked; # Plymouth boot splash. "false" => verbose console boot (rhgb quiet NOT baked;
# plymouth.enable=0 instead) — kept OFF in CI while the network-manager port is # plymouth.enable=0 instead) — kept OFF in CI while the network-manager port is
+1
View File
@@ -0,0 +1 @@
reports
+17 -4
View File
@@ -34,6 +34,8 @@ files/kiosk/ weston+Chromium kiosk + chromium lockd
scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline
scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image
serving/ Lab serving stack: dnsmasq + nginx + SPDK serving/ Lab serving stack: dnsmasq + nginx + SPDK
serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA
(kiosk endpoint; nginx reverse-proxies /kiosk/)
.gitea/workflows/ Gitea CI: lint -> packer build -> push OCI .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI
docs/ Design PDF and supporting notes docs/ Design PDF and supporting notes
``` ```
@@ -152,9 +154,17 @@ instead of working around it.
build; the supplicant config carries both options. build; the supplicant config carries both options.
- erofs module availability on the target RHEL kernel (fallback: - erofs module availability on the target RHEL kernel (fallback:
journal-less ext4 in `extract-artifacts.sh`). journal-less ext4 in `extract-artifacts.sh`).
- STIG remediation: (a) verify the SSG datastream name on the target distro - STIG remediation: (a) datastream **must match the distro**. The provisioner
(`ssg-rhel9-ds.xml` vs `ssg-rl9-ds.xml` — the provisioner tries both) and that maps `/etc/os-release` `$ID` → datastream (Rocky→`ssg-rl9-ds.xml`,
the `...content_profile_stig` profile id exists; (b) remediation must not break RHEL→`ssg-rhel9-ds.xml`, al9/cs9/ol9). **Do NOT run the RHEL9 datastream on
Rocky** — its CPE dictionary has no Rocky entry, so every rule returns
`notapplicable`, the scan evaluates **0** rules, remediation does nothing, and
the report is *falsely clean*. (Seen 2026-06-18: a build picked rhel9 via
`ls … | head -1` and produced 462 notapplicable + 1068 notselected, **0
pass/fail**.) Two guards now exist: the provisioner aborts the build if 0 rules
are evaluated, and `stig-deviations.sh` flags an INVALID SCAN + exits 2. Also
confirm the `...content_profile_stig` profile id exists in the chosen
datastream; (b) remediation must not break
boot/login (NM/NVMe, key-only sshd, kiosk, SELinux enforcing) — `enable_stig=false` boot/login (NM/NVMe, key-only sshd, kiosk, SELinux enforcing) — `enable_stig=false`
is the escape hatch; (c) **reproducibility**: build twice and confirm identical is the escape hatch; (c) **reproducibility**: build twice and confirm identical
`root_hash` in verity.json — if it drifts, oscap wrote something `root_hash` in verity.json — if it drifts, oscap wrote something
@@ -174,7 +184,10 @@ instead of working around it.
into the initramfs (`lsinitrd | grep -E 'fleet.script|script.so'`), and (c) it into the initramfs (`lsinitrd | grep -E 'fleet.script|script.so'`), and (c) it
renders (text via `Image.Text` needs the label plugin + a font in the early renders (text via `Image.Text` needs the label plugin + a font in the early
initramfs). If blank, the splash silently no-ops but boot continues; ESC shows initramfs). If blank, the splash silently no-ops but boot continues; ESC shows
the log. Untested in this workspace (needs a display). the log. **RENDER-VERIFIED 2026-06-18** (test-path build): the scripted theme
draws and the handshake checklist updates from the hook status tokens. Still
unverified on the **secure path** (the auth/macsec rows reaching `done` — see
#8 bench items).
- `systemd.verity_root_options=hash-offset` is **NOT supported** by Rocky 9.8 - `systemd.verity_root_options=hash-offset` is **NOT supported** by Rocky 9.8
systemd-veritysetup (verified: "unknown option hash-offset=" → "Failed to load systemd-veritysetup (verified: "unknown option hash-offset=" → "Failed to load
verity superblock"). Resolved by the documented fallback — data and hash are verity superblock"). Resolved by the documented fallback — data and hash are
+188 -9
View File
@@ -136,6 +136,18 @@ variable "enable_stig" {
default = true default = true
} }
variable "debug_shell" {
type = bool
description = "DEBUG/LAB ONLY. true: enable systemd's debug-shell.service — a passwordless root /bin/sh on tty2 (Ctrl+Alt+F2) that bypasses login/PAM entirely. (Placed on tty2, with the login getty there masked, because constrained consoles often expose only tty1-6; tty1 stays the kiosk.) Use to get into a hardened image when login is broken (locked root + key-only accounts). This is a deliberate security hole; NEVER ship it. Default false."
default = true
}
variable "audit_host" {
type = string
description = "Remote rsyslog/audit collector the image forwards auditd (via audisp-syslog) + the journal (rsyslog imjournal) to. Host-generic DNS name resolved per-environment by the serving dnsmasq to the collector (serving/audit-collector); matches serving/.env AUDIT_HOST. The diskless root is a RAM-only overlay, so local logs die on reboot — this ships them off-box. Empty = local-only (no remote offload)."
default = "audit.fleet.lab"
}
variable "sb_key" { variable "sb_key" {
type = string type = string
description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM."
@@ -160,8 +172,8 @@ source "qemu" "diskless" {
disk_size = "12G" disk_size = "12G"
format = "qcow2" format = "qcow2"
accelerator = "kvm" accelerator = "kvm"
memory = 4096 memory = 8192
cpus = 2 cpus = 4
headless = true headless = true
vnc_bind_address = "0.0.0.0" vnc_bind_address = "0.0.0.0"
qemuargs = [["-cpu", "host"], qemuargs = [["-cpu", "host"],
@@ -284,6 +296,15 @@ build {
# ---- Diskless adaptations ------------------------------------------ # ---- Diskless adaptations ------------------------------------------
# Per-host machine-id is generated at boot into the overlay # Per-host machine-id is generated at boot into the overlay
"truncate -s 0 /etc/machine-id", "truncate -s 0 /etc/machine-id",
# No per-host hostname in the image (image stays fleet-generic): empty
# /etc/hostname so the static hostname is unset and NetworkManager sets the
# transient hostname from the DHCP lease — DHCP option 12 (the dnsmasq
# fleet-namer assigns a creative name per MAC, server-side) or a reverse-DNS
# PTR lookup. hostname-mode=default makes that explicit so a stricter global
# config can't switch it to 'none' and leave hosts unnamed.
"truncate -s 0 /etc/hostname",
"install -D -m0644 /tmp/etc-stage/NetworkManager/conf.d/20-hostname.conf /etc/NetworkManager/conf.d/20-hostname.conf",
"restorecon /etc/NetworkManager/conf.d/20-hostname.conf",
# Read-only erofs/verity root; runtime state on a tmpfs overlay provided # Read-only erofs/verity root; runtime state on a tmpfs overlay provided
# by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh). # by systemd.volatile=overlay (set in the cmdline by extract-artifacts.sh).
# RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module. # RHEL/Rocky dracut has no 'overlayfs'/'rootovl' module.
@@ -354,6 +375,10 @@ build {
# Install the staged unit, weston config, and session wrapper. # Install the staged unit, weston config, and session wrapper.
"install -D -m0755 /tmp/kiosk/kiosk-session /usr/local/bin/kiosk-session", "install -D -m0755 /tmp/kiosk/kiosk-session /usr/local/bin/kiosk-session",
"install -D -m0644 /tmp/kiosk/kiosk.service /etc/systemd/system/kiosk.service", "install -D -m0644 /tmp/kiosk/kiosk.service /etc/systemd/system/kiosk.service",
# Dedicated PAM stack for the autologin session (PAMName=kiosk). Decouples
# the passwordless kiosk user from the STIG-hardened login/system-auth
# account phase (faillock + aging) that would otherwise deny the session.
"install -D -m0644 /tmp/kiosk/kiosk.pam /etc/pam.d/kiosk",
"install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini", "install -D -m0644 /tmp/kiosk/weston.ini /etc/xdg/weston/weston.ini",
# Chromium managed policy: stops the browser phoning home to Google # Chromium managed policy: stops the browser phoning home to Google
# (sign-in, variations, optimization-guide, component/auto update, metrics, # (sign-in, variations, optimization-guide, component/auto update, metrics,
@@ -368,10 +393,12 @@ build {
"printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env", "printf 'KIOSK_URL=%s\\n' \"$KIOSK_URL\" > /etc/kiosk/kiosk.env",
"chmod 0644 /etc/kiosk/kiosk.env", "chmod 0644 /etc/kiosk/kiosk.env",
# Label the new files so they survive into the enforcing erofs. # Label the new files so they survive into the enforcing erofs.
"restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/xdg/weston /etc/kiosk /etc/chromium /etc/chromium-browser", "restorecon -R /usr/local/bin/kiosk-session /etc/systemd/system/kiosk.service /etc/pam.d/kiosk /etc/xdg/weston /etc/kiosk /etc/chromium /etc/chromium-browser",
# Boot graphical and start the kiosk (replaces the tty1 getty). # Enable the kiosk on multi-user.target (its [Install] WantedBy) so it
# starts on a normal boot and takes tty1 from the getty (Conflicts=). No
# set-default graphical.target: the kiosk needs no display-manager target,
# and relying on graphical.target left it dead at boot.
"systemctl enable kiosk.service", "systemctl enable kiosk.service",
"systemctl set-default graphical.target",
"rm -rf /tmp/kiosk", "rm -rf /tmp/kiosk",
"echo \"==> kiosk enabled: $KIOSK_URL\"", "echo \"==> kiosk enabled: $KIOSK_URL\"",
] ]
@@ -408,7 +435,10 @@ build {
# the main config) overrides the ks's PermitRootLogin yes. Takes effect on # the main config) overrides the ks's PermitRootLogin yes. Takes effect on
# the next boot only (sshd isn't restarted), so the live build session is # the next boot only (sshd isn't restarted), so the live build session is
# unaffected. # unaffected.
"printf 'PermitRootLogin no\\nPasswordAuthentication no\\nKbdInteractiveAuthentication no\\n' > /etc/ssh/sshd_config.d/50-fleet-hardening.conf", # Banner: show the fleet (starship) login notice pre-auth. The banner file
# itself is written by the '2e. Fleet login banner' step below (after STIG,
# so it overrides the DoD banner). Set here too so it works with enable_stig=false.
"printf 'PermitRootLogin no\\nPasswordAuthentication no\\nKbdInteractiveAuthentication no\\nBanner /etc/issue.net\\n' > /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
"chmod 0644 /etc/ssh/sshd_config.d/50-fleet-hardening.conf", "chmod 0644 /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
"restorecon /etc/ssh/sshd_config.d/50-fleet-hardening.conf", "restorecon /etc/ssh/sshd_config.d/50-fleet-hardening.conf",
# Guarantee sshd is enabled (it's our ONLY way in once root is locked and # Guarantee sshd is enabled (it's our ONLY way in once root is locked and
@@ -422,6 +452,35 @@ build {
] ]
} }
# --- 2c-debug. Break-glass root shell (DEBUG/LAB ONLY) -------------------
# debug_shell=true enables systemd's debug-shell.service: a passwordless root
# /bin/sh on tty9 (Ctrl+Alt+F9) that does NOT go through login/PAM — so you can
# get into a hardened image even when root is locked, the accounts are key-only,
# and login/sshd are broken. This is a deliberate security hole for diagnosing
# boot/login regressions; never ship it. The symlink into sysinit.target.wants
# guarantees it starts early regardless of the unit's [Install] section.
provisioner "shell" {
environment_vars = ["DEBUG_SHELL=${var.debug_shell}"]
inline = [
"set -e",
"if [ \"$DEBUG_SHELL\" != \"true\" ]; then echo 'debug_shell=false -> no debug shell baked.'; exit 0; fi",
"echo '############################################################'",
"echo '## WARNING: debug_shell=true — PASSWORDLESS ROOT on tty2. ##'",
"echo '## DEBUG/LAB IMAGE ONLY. Do NOT deploy to production. ##'",
"echo '############################################################'",
"systemctl enable debug-shell.service 2>/dev/null || true",
"mkdir -p /etc/systemd/system/sysinit.target.wants",
"ln -sf /usr/lib/systemd/system/debug-shell.service /etc/systemd/system/sysinit.target.wants/debug-shell.service",
# Default debug-shell is tty9 (outside the getty range) — but constrained
# consoles (noVNC/SPICE) often only expose tty1-6. Move it to tty2, which
# IS reachable, and mask the login getty there so they don't fight over the
# VT. Result: Ctrl+Alt+F2 = passwordless root shell. (tty1 stays the kiosk.)
"mkdir -p /etc/systemd/system/debug-shell.service.d",
"printf '[Service]\\nTTYPath=/dev/tty2\\n' > /etc/systemd/system/debug-shell.service.d/tty.conf",
"systemctl mask getty@tty2.service autovt@tty2.service 2>/dev/null || true",
]
}
# --- 2c-STIG. DISA STIG hardening (OpenSCAP + SSG), diskless/UKI/kiosk tailored # --- 2c-STIG. DISA STIG hardening (OpenSCAP + SSG), diskless/UKI/kiosk tailored
# Runs after admin (so it hardens the full system incl. the admin/sshd config) # Runs after admin (so it hardens the full system incl. the admin/sshd config)
# and BEFORE hygiene 2d (so 2d can strip the scanner tooling + reports). The # and BEFORE hygiene 2d (so 2d can strip the scanner tooling + reports). The
@@ -440,15 +499,31 @@ build {
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'enable_stig=false -> skipping STIG hardening.'; mkdir -p /root; echo '<html><body>STIG hardening disabled for this build (enable_stig=false).</body></html>' > /root/stig-report.html; : > /root/stig-arf.xml; exit 0; fi", "if [ \"$ENABLE_STIG\" != \"true\" ]; then echo 'enable_stig=false -> skipping STIG hardening.'; mkdir -p /root; echo '<html><body>STIG hardening disabled for this build (enable_stig=false).</body></html>' > /root/stig-report.html; : > /root/stig-arf.xml; exit 0; fi",
"echo '==> Installing OpenSCAP + SCAP Security Guide'", "echo '==> Installing OpenSCAP + SCAP Security Guide'",
"dnf install -y openscap-scanner scap-security-guide", "dnf install -y openscap-scanner scap-security-guide",
# SSG ships rhel9 content (usable on Rocky); some rebuilds name it rl9. # Pick the datastream that MATCHES THIS DISTRO. The RHEL9 datastream's CPE
"DS=$(ls /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml /usr/share/xml/scap/ssg/content/ssg-rl9-ds.xml 2>/dev/null | head -1)", # dictionary has no Rocky entry, so running it on Rocky makes EVERY rule
"[ -n \"$DS\" ] || { echo 'ERROR: SSG rhel9 datastream not found' >&2; exit 1; }", # 'notapplicable' -> the scan evaluates nothing and the report is falsely
# clean. SSG ships a Rocky-specific datastream (ssg-rl9-ds.xml); map by
# /etc/os-release ID (al9=AlmaLinux, cs9=CentOS Stream, ol9=Oracle).
". /etc/os-release",
"case \"$ID\" in rocky) SSG=rl9;; almalinux) SSG=al9;; centos) SSG=cs9;; ol|oracle) SSG=ol9;; rhel|redhat) SSG=rhel9;; *) SSG=rhel9;; esac",
"DS=/usr/share/xml/scap/ssg/content/ssg-$SSG-ds.xml",
"[ -f \"$DS\" ] || DS=/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml", # last-resort fallback
"[ -f \"$DS\" ] || { echo \"ERROR: no SSG datastream found for $ID ($DS)\" >&2; exit 1; }",
"echo \"==> Using SSG datastream $DS for $ID $VERSION_ID\"",
"install -D -m0644 /tmp/stig/diskless-tailoring.xml /etc/stig/diskless-tailoring.xml", "install -D -m0644 /tmp/stig/diskless-tailoring.xml /etc/stig/diskless-tailoring.xml",
"echo \"==> Remediating against tailored STIG profile using $DS (this changes a lot)\"", "echo \"==> Remediating against tailored STIG profile using $DS (this changes a lot)\"",
# --remediate returns nonzero when rules remain failed (expected) — tolerate. # --remediate returns nonzero when rules remain failed (expected) — tolerate.
"oscap xccdf eval --remediate --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true", "oscap xccdf eval --remediate --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true",
# Clean post-remediation eval = the report/ARF of record (no --remediate). # Clean post-remediation eval = the report/ARF of record (no --remediate).
"oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true", "oscap xccdf eval --profile xccdf_org.ssgproject.content_profile_stig_diskless --tailoring-file /etc/stig/diskless-tailoring.xml --results-arf /root/stig-arf.xml --report /root/stig-report.html \"$DS\" || true",
# GUARD: a scan with 0 evaluated rules (everything notapplicable/notselected)
# means the datastream/CPE didn't match this host — NO hardening happened and
# the report would be falsely 'clean'. Fail the build rather than ship an
# unhardened image masquerading as STIG-compliant. (enable_stig=false is the
# intentional way to skip hardening.)
"evaluated=$(grep -oE '<result>(pass|fixed|fail)</result>' /root/stig-arf.xml | wc -l)",
"[ \"$evaluated\" -gt 0 ] || { echo 'ERROR: STIG scan evaluated 0 rules (all notapplicable/notselected) — SSG datastream/CPE mismatch for this distro. Hardening did NOT run; aborting.' >&2; exit 1; }",
"echo \"==> STIG scan evaluated $evaluated rules (pass/fixed/fail)\"",
"rm -rf /tmp/stig", "rm -rf /tmp/stig",
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'", "echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
] ]
@@ -467,6 +542,110 @@ build {
destination = "${var.artifact_dir}/${var.image_version}/stig-arf.xml" destination = "${var.artifact_dir}/${var.image_version}/stig-arf.xml"
} }
# --- 2c-STIG-postfix. Undo the STIG settings that lock out this architecture.
# Runs AFTER remediation so STIG can't re-break it. Two fixes:
# (a) APPLIANCE ACCOUNTS: STIG enables pam_faillock (account phase) + password
# aging/inactivity. The kiosk + fleet-admin accounts are PASSWORDLESS /
# key-only, so the hardened account phase rejects them (kiosk session never
# opens; sshd account phase denies). Exempt just those two accounts.
# (b) fapolicyd: STIG's app-allowlisting blocks non-RPM executables, killing
# the kiosk (ExecStart=/usr/local/bin/kiosk-session). Keep fapolicyd (it's
# the exec-guard for the writable overlay that dm-verity can't cover) and
# trust the custom kiosk binary instead of disabling it.
# Both justified in docs/stig-deviations.md (#10).
provisioner "shell" {
environment_vars = [
"ENABLE_STIG=${var.enable_stig}",
"ADMIN_USER=${var.admin_user}",
]
inline = [
"set -e",
"if [ \"$ENABLE_STIG\" != \"true\" ]; then echo '2c-STIG-postfix: enable_stig=false -> nothing to undo.'; exit 0; fi",
# Exempt each appliance account from password aging/inactivity/expiry and
# clear any faillock tally (these accounts have NO password to age).
"for u in \"$ADMIN_USER\" kiosk; do",
" { [ -n \"$u\" ] && id \"$u\" >/dev/null 2>&1; } || continue",
" echo \"==> exempting key-only account '$u' from STIG password aging + faillock\"",
" chage -M -1 -m 0 -I -1 -E -1 \"$u\"",
" chage -d \"$(date +%Y-%m-%d)\" \"$u\"",
" command -v faillock >/dev/null 2>&1 && faillock --user \"$u\" --reset || true",
" { [ -d \"/home/$u/.ssh\" ] && restorecon -R \"/home/$u/.ssh\"; } || true",
"done",
# sshd: bypass the hardened ACCOUNT stack (faillock/aging) for the key-only
# admin only, inserted as the FIRST account rule so a valid key can't be
# denied by pam_acct_mgmt. Other users keep the full STIG account policy.
"if [ -n \"$ADMIN_USER\" ] && id \"$ADMIN_USER\" >/dev/null 2>&1; then",
" grep -q \"pam_succeed_if.so user = $ADMIN_USER\" /etc/pam.d/sshd || sed -i \"0,/^account/ s//account sufficient pam_succeed_if.so user = $ADMIN_USER quiet\\n&/\" /etc/pam.d/sshd",
" restorecon /etc/pam.d/sshd",
" echo \"==> /etc/pam.d/sshd: account-stack bypass ensured for $ADMIN_USER\"",
"fi",
# fapolicyd (STIG app-allowlisting) blocks execution of any non-RPM file.
# The kiosk's ExecStart=/usr/local/bin/kiosk-session is exactly that, so
# fapolicyd kills the kiosk. KEEP fapolicyd (it's the real exec-guard for the
# writable RAM overlay, which dm-verity does NOT cover) and instead TRUST the
# 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; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
" fapolicyd-cli --update 2>/dev/null || true",
" echo '==> fapolicyd: trusted custom kiosk binaries + rebuilt trust db'",
"fi",
]
}
# --- 2e. Fleet login banner (starship-themed) ----------------------------
# Replaces the DoD/USG warning banner STIG writes (banner_etc_issue*) with the
# fleet (ISV AURELIAN) notice. Runs AFTER the STIG step so it overrides what
# remediation wrote; the sshd 'Banner /etc/issue.net' directive is set in 2c.
# Keeps a (themed) monitored/authorized-only notice — that warning is the
# banner's actual security purpose; only the wording changes. This makes the
# STIG banner-content rules fail by design (documented: stig-deviations.md #11).
provisioner "file" {
source = "files/banner"
destination = "/tmp/"
}
provisioner "shell" {
inline = [
"set -eux",
# /etc/issue = console getty (pre-login); issue.net = SSH pre-auth banner;
# motd = post-login. Same fleet notice in all three.
"install -D -m0644 /tmp/banner/fleet-issue /etc/issue",
"install -D -m0644 /tmp/banner/fleet-issue /etc/issue.net",
"install -D -m0644 /tmp/banner/fleet-issue /etc/motd",
"restorecon /etc/issue /etc/issue.net /etc/motd",
"rm -rf /tmp/banner",
]
}
# --- 2f. Remote log / audit offload (sender) -----------------------------
# Forward auditd (via the audisp-syslog plugin, on LOCAL6) AND the journal
# (rsyslog's stock imjournal) to the remote collector (serving/audit-collector).
# The diskless root is a RAM-only overlay so local logs vanish on reboot; this
# ships them off-box (and gives remote visibility into boot/login failures).
# Host-generic: targets ${audit_host} (DNS, resolved per environment). Empty
# audit_host => local-only. Implements the remote-offload half of TODO #2 /
# docs/stig-deviations.md #6. Disk-assisted queue so a network blip won't drop
# records (action.resumeRetryCount=-1 retries forever).
provisioner "shell" {
environment_vars = ["AUDIT_HOST=${var.audit_host}"]
inline = [
"set -eux",
"if [ -z \"$AUDIT_HOST\" ]; then echo 'audit_host empty -> local-only logging, no remote offload.'; exit 0; fi",
"dnf install -y rsyslog",
# auditd -> syslog on LOCAL6 (the collector routes LOCAL6 to a per-host
# audit.log; see serving/audit-collector/rsyslog.conf).
"if [ -f /etc/audit/plugins.d/syslog.conf ]; then sed -i -e 's/^active.*/active = yes/' -e 's/^args.*/args = LOG_INFO LOG_LOCAL6/' /etc/audit/plugins.d/syslog.conf; fi",
# rsyslog -> collector over TCP, disk-assisted queue.
"cat > /etc/rsyslog.d/60-fleet-remote.conf <<EOF",
"*.* action(type=\"omfwd\" target=\"$AUDIT_HOST\" port=\"514\" protocol=\"tcp\" queue.type=\"LinkedList\" queue.fileName=\"fleetfwdq\" queue.maxDiskSpace=\"256m\" queue.saveOnShutdown=\"on\" action.resumeRetryCount=\"-1\")",
"EOF",
"chmod 0644 /etc/rsyslog.d/60-fleet-remote.conf",
"rsyslogd -N1",
"systemctl enable rsyslog.service",
"restorecon /etc/rsyslog.d/60-fleet-remote.conf /etc/audit/plugins.d/syslog.conf 2>/dev/null || true",
]
}
# --- 2d. Final image hygiene: strip caches/logs after the LAST dnf step -- # --- 2d. Final image hygiene: strip caches/logs after the LAST dnf step --
# MUST be the last provisioner. The `dnf clean all` in step 2 runs BEFORE the # MUST be the last provisioner. The `dnf clean all` in step 2 runs BEFORE the
# kiosk (2b) and admin (2c) provisioners, and 2b enables EPEL + installs # kiosk (2b) and admin (2c) provisioners, and 2b enables EPEL + installs
+173 -9
View File
@@ -5,6 +5,28 @@ Persistent backlog, kept in sync with the working session task tracker. Status:
--- ---
## Current build posture (LAB / test) — as of 2026-06-18
The image currently builds in **open-network test mode**. These toggles must be
flipped back for a secure/production build (consolidated as item #8):
- `disable_dot1x=true` / CI `DISABLE_DOT1X="true"` → `rd.dot1x=0` (NO 802.1X /
MACsec; open NVMe-oF). **TEMPORARY.**
- `nvmet_traddr` / CI `NVMET_TRADDR="192.168.50.10"` → hardcoded IP (should be a
per-env DNS name like `nvme.fleet.lab` once DNS-at-boot is trusted).
- Unsigned UKI (`sb_key`/`sb_cert` not passed) → Secure Boot must be OFF on the
test target. Production needs CI/HSM signing.
- `enable_kiosk=false` by default (CI may set true); `enable_stig=true`;
`boot_splash=true`; NTP via DHCP→serving-host chrony.
- **Hostname:** image is hostname-neutral (empty `/etc/hostname`,
`hostname-mode=default`); the serving dnsmasq's `fleet-namer` (`--dhcp-script`)
assigns a stable creative starship name per MAC (e.g. `nebula-vanguard-07`) via
DHCP option 12 + DNS A/PTR. Host picks it up on next DHCP renewal/reboot.
What's working/verified vs awaiting bench is per-item below. Cross-cutting
bench-verify checklist lives in `CLAUDE.md` → "Known bench-verify items".
---
## [x] 1. Port the dracut module off `network-legacy` → `network-manager` ## [x] 1. Port the dracut module off `network-legacy` → `network-manager`
**DONE — test path VERIFIED ON THE BENCH 2026-06-17** (host stays up past the **DONE — test path VERIFIED ON THE BENCH 2026-06-17** (host stays up past the
@@ -56,11 +78,63 @@ in **[stig-deviations.md](stig-deviations.md)** (curated register) and reported
per-build by **[../scripts/stig-deviations.sh](../scripts/stig-deviations.sh)** per-build by **[../scripts/stig-deviations.sh](../scripts/stig-deviations.sh)**
(non-pass rules tagged intentional vs REVIEW); CI publishes (non-pass rules tagged intentional vs REVIEW); CI publishes
`stig-report.html` + `stig-arf.xml` + `stig-deviations.md`. Scanner tooling is `stig-report.html` + `stig-arf.xml` + `stig-deviations.md`. Scanner tooling is
removed from the shipped image. **BENCH-VERIFY:** SSG datastream name; boot/login removed from the shipped image. **BENCH-VERIFY:** boot/login still work;
still work; reproducible verity hash across two builds; triage REVIEW rows. reproducible verity hash across two builds; triage REVIEW rows.
**FINDING 2026-06-18 (FIXED) — false-clean scan.** The first hardened build
(branch `hardening`, `ada4aa4`) produced a report reading "0 REVIEW / clean",
but the ARF had **0 pass, 0 fail** — only 462 `notapplicable` + 1068
`notselected`. Root cause: the provisioner selected the datastream with
`ls ssg-rhel9-ds.xml ssg-rl9-ds.xml | head -1`, which picks **rhel9**
alphabetically; the RHEL9 datastream's CPE dictionary has **no Rocky entry**, so
on Rocky 9.8 every selected rule went `notapplicable` and **nothing was actually
evaluated or remediated** — the image was unhardened despite a clean-looking
report. Fixes: (a) provisioner now maps `/etc/os-release $ID` → datastream
(Rocky→`ssg-rl9-ds.xml`); (b) provisioner **aborts the build** if the scan
evaluates 0 rules; (c) `stig-deviations.sh` flags an **INVALID SCAN** and exits
non-zero on 0-evaluated. **Re-run the hardened build** and confirm pass/fail
counts are non-zero before trusting any deviations report.
**FINDING 2026-06-18 (FIXED) — hardening locked the fleet out.** The first
genuinely-evaluated hardened image (app `215b0eb`) broke **both** login paths:
the Chromium kiosk dropped to a getty login prompt, and admin SSH was rejected
with `Permission denied (publickey)`. Root cause: STIG enables `pam_faillock`
(account phase) + password aging/inactivity, and the **kiosk + fleetadmin
accounts are passwordless / key-only**, so the hardened PAM *account* phase
treats them as expired/lockable (sshd key auth succeeds, then `pam_acct_mgmt`
fails). Fix (provisioner `2c-STIG-postfix`, runs after remediation): exempt the
two appliance accounts — `chage -M -1 -m 0 -I -1 -E -1` + `faillock --reset`;
kiosk uses a dedicated `/etc/pam.d/kiosk` (`PAMName=kiosk`, permissive account /
full session for seat); sshd gets an `account sufficient pam_succeed_if.so user
= fleetadmin` first-rule bypass. Scoped to those two accounts; everyone else
keeps full STIG. Justified as register deviation #10. **Bench-verify on rebuild:
kiosk renders + admin SSH works with hardening on.**
**UPDATE 2026-06-19 — offline erofs inspection of the hardened image (mounted
`rootfs.erofs` read-only) pinned the real causes; the PAM-account theory above
was incomplete:**
- **Kiosk = fapolicyd (CONFIRMED).** fapolicyd is `enabled` and `kiosk-session`
was not in its trust → it blocks `/usr/local/bin/kiosk-session`. Fix: KEEP
fapolicyd (it's the exec-guard for the writable overlay dm-verity can't cover)
and add the kiosk binary to its trust DB at build (2c-STIG-postfix). Not waived.
- **SSH = NOT a STIG/PAM/SELinux issue.** `enforcing=0` didn't help; pam_namespace
is inert (empty namespace.conf); account bypass present; key file + perms +
sshd config all correct. Remaining cause: **baked key mismatch** — the
`SSH_AUTHORIZED_KEY` CI secret bakes `audiobuzz@s3`
(`SHA256:30p4vOQdWk0TdRQIR3VMo95yc6I7cRmCwIBRVjIHaV4`); if the client uses a
different private key → `Permission denied (publickey)`. AWAITING client
fingerprint / `ssh -vvv` to confirm; fix is the secret, not code.
- Side note: SMBIOS cmdline-extra injection works only after a Proxmox VM
**stop/start** (not `reset`); and the debug-shell-on-tty2 attempt did not come
up — diagnosis ended up done via offline erofs mount instead.
Remaining (future hardening, currently waived — see the register): FIPS, Remaining (future hardening, currently waived — see the register): FIPS,
firewall, remote audit offload, per-host/rotatable admin access. firewall, per-host/rotatable admin access. **Remote audit offload — WIRED both
ends** (2026-06-19): receiver `serving/audit-collector/` (rsyslog TCP/514,
per-source-host files) + sender in the image (provisioner 2f, `audit_host` var:
audisp-syslog→LOCAL6 + rsyslog `omfwd` to `audit.fleet.lab`). Lab is plaintext;
TLS/RELP is the remaining hardening. **Bench-verify:** booted host's auditd AVCs
land in `audit-collector/logs/<ip>/audit.log` and journal in `messages.log`.
### Original spec (retained for reference) ### Original spec (retained for reference)
@@ -116,7 +190,12 @@ re-export a **v3** PDF (key change: `rootovl` → `systemd.volatile=overlay` in
step 8 and §5). Retire/reset the errata afterward. Needs the design-doc source step 8 and §5). Retire/reset the errata afterward. Needs the design-doc source
(not in this repo). (not in this repo).
## [~] 4. Kiosk mode — autologin + fullscreen Chromium loading a URL ## [x] 4. Kiosk mode — autologin + fullscreen Chromium loading a URL
**WORKING (display-verified 2026-06-17).** Remaining bits are polish (GPU/mesa
trim, ship via CI `enable_kiosk=true`) and the phone-home/NTP lockdown (done) —
all captured in the sub-notes below. Secure-path render check is folded into #8.
**WORKING / display-verified 2026-06-17** (kiosk renders Chromium fullscreen on **WORKING / display-verified 2026-06-17** (kiosk renders Chromium fullscreen on
the configured URL). Build-flagged OFF by default for now (`enable_kiosk=false`) the configured URL). Build-flagged OFF by default for now (`enable_kiosk=false`)
@@ -147,11 +226,19 @@ Path to getting here (don't re-discover):
smaller image. **Re-enable with `-var enable_kiosk=true` when returning to smaller image. **Re-enable with `-var enable_kiosk=true` when returning to
kiosk work** — CI does not pass the var, so CI currently builds lean (no kiosk work** — CI does not pass the var, so CI currently builds lean (no
kiosk) too; add `-var enable_kiosk=true` to the Gitea workflow to ship it. kiosk) too; add `-var enable_kiosk=true` to the Gitea workflow to ship it.
- `kiosk_url` Packer var (default `http://boot.fleet.lab/kiosk/` — the - `kiosk_url` Packer var (default `http://boot.fleet.lab/kiosk/` — the kiosk
self-contained test page served by the boot HTTP server at **SPA** (`serving/kiosk-app/`, Svelte 5 + Tailwind v4, a cyber sci-fi
`serving/http/kiosk/`, via the `/kiosk/` nginx location; validates a boot spaceship monitoring/control terminal) served by the boot HTTP server, which
without external connectivity. Empty disables the whole install). Wired in CI **reverse-proxies** `/kiosk/` to the `kiosk` container; self-contained so a
as `KIOSK_URL` env. boot validates without external connectivity. Empty disables the whole
install). Wired in CI as `KIOSK_URL` env.
- **Kiosk SPA (`serving/kiosk-app/`)** — replaced the old single static
`serving/http/kiosk/index.html` (2026-06-18). Top navbar + 5 pages
(Bridge/Reactor/Nav/Life Support/Comms) with dummy telemetry; hash routing;
builds in a 2-stage Docker image (Vite → nginx), reverse-proxied at `/kiosk/`
by the main serving nginx (loopback `127.0.0.1:${KIOSK_APP_PORT:-8088}`).
`npm run build` verified locally; container build untested (no docker here).
Dummy data lives in `src/lib/data.js` (swap for a fleet API later).
- Provisioner enables EPEL, installs `weston chromium mesa-dri-drivers - Provisioner enables EPEL, installs `weston chromium mesa-dri-drivers
mesa-libEGL mesa-libgbm dejavu-sans-fonts`, creates a passwordless mesa-libEGL mesa-libgbm dejavu-sans-fonts`, creates a passwordless
unprivileged `kiosk` user (video/input groups). unprivileged `kiosk` user (video/input groups).
@@ -439,3 +526,80 @@ export is read-only + single-namespace = inherently small attack surface.
— it's the fleet's disk). Ties into #6 (rolling updates) availability. — it's the fleet's disk). Ties into #6 (rolling updates) availability.
- Revisit a memory-safe target only if/when an off-the-shelf one matures - Revisit a memory-safe target only if/when an off-the-shelf one matures
(e.g. a future Rust-for-Linux `nvmet`). (e.g. a future Rust-for-Linux `nvmet`).
## [ ] 8. Return to secure / production posture (un-toggle the lab flags)
Consolidates the cleanup of the temporary lab toggles (see "Current build
posture" at the top) and the secure-path bench-verifies deferred while testing
the open-network path. Do these together, then run the full secure boot-chain
smoke test.
- **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`).
Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0).
- **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot
by the serving dnsmasq — drop the hardcoded `192.168.50.10`.
- **Signing:** wire `sb_key`/`sb_cert` (CI/HSM secrets) for a signed UKI; enable
Secure Boot on targets. Until then the loud unsigned warning must stay.
- **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS,
`disable_dot1x=false`) — the items deferred this session:
- NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA
survives switch-root, NVMe stays up past lease (item #1 risk).
- NTP via DHCP option 42 reaches chrony over macsec0 (`chronyc sources`).
- Plymouth `fleet` theme: test-path render VERIFIED 2026-06-18; still confirm
the auth→macsec rows reach `done` live on the secure path.
- STIG remediation didn't break the secure path; reproducible verity hash;
triage the generated `stig-deviations.md` REVIEW rows (item #2).
- **Secure-path NVMe connect trigger:** the test path uses
`nvmf-connect.service` (gated `rd.dot1x=0`); confirm the secure path's
connect (dracut nvmf over macsec0) actually fires, or give it an equivalent
trigger. Apply the same `-k 30 -l -1 -c 10` keep-alive/reconnect flags to the
secure `nvme connect` as the test path has.
- **Future hardening waived for now** (from the STIG register): FIPS, firewall,
per-host/rotatable admin (SSH CA / config-agent).
- **TLS-encrypted audit/log offload (production).** The lab forwards logs to
`serving/audit-collector` in **plaintext**, and the STIG TLS-offload rules
(`rsyslog_encrypt_offload_defaultnetstreamdriver`,
`..._actionsendstreamdrivermode`, `..._actionsendstreamdriverauthmode`) are
**waived** in the tailoring because applying them forces `gtls` + x509 peer
auth + a CA file and breaks the plaintext `2f` forward. For production, do it
properly and DROP the waivers:
1. Stand up a CA (or reuse the boot-infra CA); issue the collector a server
cert (CN/SAN = `audit.fleet.lab`).
2. Configure the collector's rsyslog for TLS: `imtcp` with
`StreamDriver.Name="gtls"`, `StreamDriver.Mode="1"`, server cert/key + CA;
listen on the syslog-TLS port (6514) — add a TLS receiver to
`serving/audit-collector/rsyslog.conf` + compose.
3. Bundle the CA into the image (host-generic, no per-host state — same model
as the RADIUS CA) and point `$DefaultNetstreamDriverCAFile` at it.
4. Set the `2f` `omfwd` action to `StreamDriver="gtls" StreamDriverMode="1"
StreamDriverAuthMode="x509/name"` + the CA file, target port 6514.
5. Re-select the three `rsyslog_encrypt_offload_*` rules (remove from
`files/stig/diskless-tailoring.xml`) — they should then PASS, and update
`docs/stig-deviations.md` #6 to reflect encrypted offload.
(RELP+TLS via `omrelp`/`imrelp` is the alternative if guaranteed delivery is
wanted alongside encryption.)
## [ ] 9. (OPTIONAL / nice-to-have) Convert provisioning to Ansible where it pays off
Low priority — only the *config-management* half of the build is a candidate, and
the ROI is modest because the build VM is single-shot (idempotency buys little)
and reproducibility/auditability favour minimal shell. **Not** a wholesale
rewrite: the load-bearing logic (dracut module hook ordering, `extract-artifacts.sh`,
`build-oci.sh`) is initramfs-runtime / host-side build tooling, not provisioning,
and stays shell.
The high-ROI, low-risk slice if/when we pick this up:
- **STIG via the SSG Ansible playbook** (`ssg-rl9-playbook-stig.yml`, shipped
with scap-security-guide) instead of `oscap --remediate` — the upstream path
and far more *reviewable* (you can diff exactly what each rule changes). Keep
`oscap eval` (no remediate) for the report + the 0-eval guard (item #2).
- Restyle the brittle imperative bits as declarative tasks: chrony.conf edit
(`lineinfile`/`blockinfile` vs `sed`), sshd drop-in / NM dispatcher / kiosk
file installs (`template`/`copy`), datastream-by-`os-release` as `assert`.
Bonus: sidesteps the `%{...}`→`%%{` HCL-escaping footgun.
Tips the balance toward "do it" only if: we start managing the **serving hosts**
(dnsmasq/nginx/SPDK/chrony/audit-collector) with config-management too (shared
roles to amortise — currently they're docker-compose), provisioning grows much
more branchy, or we want molecule-tested hardening roles validated without a
full ~17-min Packer build.
+13 -3
View File
@@ -26,21 +26,31 @@ Posture decisions (2026-06-18, lab phase):
| 3 | **AIDE / file integrity** (`package_aide_installed`, `aide_build_database`, `aide_periodic_cron_checking`, `aide_scan_notification`, `aide_verify_*`) | The root is read-only and cryptographically verified; an on-disk AIDE db would also be non-deterministic and break the reproducible verity hash. | **dm-verity** verifies the whole root filesystem against a signed root hash on every read — continuous, hardware-backed integrity that supersedes AIDE's periodic scan. | | 3 | **AIDE / file integrity** (`package_aide_installed`, `aide_build_database`, `aide_periodic_cron_checking`, `aide_scan_notification`, `aide_verify_*`) | The root is read-only and cryptographically verified; an on-disk AIDE db would also be non-deterministic and break the reproducible verity hash. | **dm-verity** verifies the whole root filesystem against a signed root hash on every read — continuous, hardware-backed integrity that supersedes AIDE's periodic scan. |
| 4 | **Firewall** (`service_firewalld_enabled`, `package_firewalld_installed`, `set_firewalld_default_zone`) | Currently `firewall --disabled`; auto-enabling firewalld/nftables risks the NVMe/TCP (`:4420`) and NetworkManager paths during bring-up. | Isolated boot LAN; egress controlled at the serving-stack / network firewall layer. **Future hardening** — re-enable with explicit allow rules once the secure path is verified. | | 4 | **Firewall** (`service_firewalld_enabled`, `package_firewalld_installed`, `set_firewalld_default_zone`) | Currently `firewall --disabled`; auto-enabling firewalld/nftables risks the NVMe/TCP (`:4420`) and NetworkManager paths during bring-up. | Isolated boot LAN; egress controlled at the serving-stack / network firewall layer. **Future hardening** — re-enable with explicit allow rules once the secure path is verified. |
| 5 | **FIPS** (`enable_fips_mode`, `grub2_enable_fips_mode`, `package_dracut-fips_installed`) | Decision: off for the lab phase (interacts with the still-unsigned UKI / Secure Boot story). | Revisit with signing. No weakened crypto is configured in the meantime. | | 5 | **FIPS** (`enable_fips_mode`, `grub2_enable_fips_mode`, `package_dracut-fips_installed`) | Decision: off for the lab phase (interacts with the still-unsigned UKI / Secure Boot story). | Revisit with signing. No weakened crypto is configured in the meantime. |
| 6 | **Remote audit offload** (`auditd_audispd_configure_remote_server`, `..._encrypt_sent_records`, `..._network_failure_action`, `rsyslog_remote_loghost`) | Decision: local-only for now; no collector deployed. | auditd enabled and recording locally. **Future hardening** — `audisp-remote`/rsyslog to a per-environment collector DNS name (host-generic, no per-host state). | | 6 | **Remote audit offload** (`auditd_audispd_configure_remote_server`, `..._encrypt_sent_records`, `..._network_failure_action`, `rsyslog_remote_loghost`) **+ rsyslog TLS offload** (`rsyslog_encrypt_offload_defaultnetstreamdriver`, `..._actionsendstreamdrivermode`, `..._actionsendstreamdriverauthmode`) | Offload is done via **rsyslog over TCP** (simpler, container-friendly receiver); the `audisp-remote` native-protocol rules stay waived. The **TLS-offload rules are waived** because the isolated lab boot LAN is plaintext (mirrors HTTP boot / no MACsec) and applying them forces `gtls` + x509 peer auth + a CA file, which **breaks** the plaintext `2f` forward (no CA, plaintext `:514`). | **Wired both ends, plaintext.** Sender (image, provisioner 2f, `audit_host`): audisp-syslog → LOCAL6 + rsyslog `omfwd` (disk-assisted queue) → host-generic `audit.fleet.lab`. Receiver: `serving/audit-collector/` (rsyslog TCP/514, per-source-host files). No per-host state; isolated LAN. **Production (tracked in docs/TODO.md #2):** issue the collector a TLS cert, bundle the CA in the image, switch `2f` + the collector to `gtls`/6514, then **re-select** the three `rsyslog_encrypt_offload_*` rules (drop the waivers) so they pass. |
| 7 | **Smart card login** (`require_smart_card_login`, `sssd_enable_smartcard`, `install_smartcard_packages`) | Diskless hosts have no smartcard reader; admin access is SSH key-based. Enforcing smartcard login would lock the host out. | Admin auth is an SSH **key-only** account; root is locked; password auth disabled. | | 7 | **Smart card login** (`require_smart_card_login`, `sssd_enable_smartcard`, `install_smartcard_packages`) | Diskless hosts have no smartcard reader; admin access is SSH key-based. Enforcing smartcard login would lock the host out. | Admin auth is an SSH **key-only** account; root is locked; password auth disabled. |
| 8 | **NOPASSWD sudo** (`sudo_remove_nopasswd`) | The baked admin is a **passwordless, key-only** account, so `sudo` cannot prompt for a password — NOPASSWD is required for it to function at all. | Single fleet-generic admin, key-only, `wheel`; no interactive password exists to protect. Tracked for the eventual per-host/rotatable access model (SSH CA / config-agent). | | 8 | **NOPASSWD sudo** (`sudo_remove_nopasswd`) | The baked admin is a **passwordless, key-only** account, so `sudo` cannot prompt for a password — NOPASSWD is required for it to function at all. | Single fleet-generic admin, key-only, `wheel`; no interactive password exists to protect. Tracked for the eventual per-host/rotatable access model (SSH CA / config-agent). |
| 9 | **GUI / autologin / GDM** (gnome screensaver, GDM banner, automatic-login rules) | No GNOME/GDM is installed; the kiosk is an **intentional** autologin single-app (weston + Chromium) session on kiosk builds. | Kiosk user is unprivileged and passwordless; the session runs a single locked-down browser (no shell, no nav). Applies to kiosk builds only; non-kiosk builds don't install the GUI stack. | | 9 | **GUI / autologin / GDM** (gnome screensaver, GDM banner, automatic-login rules) | No GNOME/GDM is installed; the kiosk is an **intentional** autologin single-app (weston + Chromium) session on kiosk builds. | Kiosk user is unprivileged and passwordless; the session runs a single locked-down browser (no shell, no nav). Applies to kiosk builds only; non-kiosk builds don't install the GUI stack. |
| 10 | **Password aging / faillock on the key-only APPLIANCE ACCOUNTS** (`accounts_*_age_*`, `account_disable_post_pw_expiration`, `pam_faillock` account phase) — applied to the *system* but **exempted for the `fleetadmin` and `kiosk` users** | These two accounts are **passwordless / key-only**. The hardened PAM *account* phase (faillock + password aging/inactivity) treats a passwordless account as expired/lockable, which **locked the fleet out**: the kiosk autologin session never opened (getty kept tty1) and sshd denied the valid key with `Permission denied (publickey)` (pubkey auth succeeds, then `pam_acct_mgmt` fails). Fixed 2026-06-18. | The exemption is **scoped to these two appliance accounts only** (post-STIG `chage -M -1 -m 0 -I -1 -E -1` + `faillock --reset`; kiosk uses a dedicated `/etc/pam.d/kiosk`; sshd gets an `account sufficient pam_succeed_if.so user = fleetadmin` bypass as its first account rule). Every other account keeps the full STIG password policy + faillock. These accounts have **no password to age** and (key-only, `PasswordAuthentication no`) **no password-auth path for faillock to protect**. Folds into the per-host/rotatable access model (SSH CA / config-agent). |
| 11 | **Login banner content** (`banner_etc_issue`, `banner_etc_issue_net`, `banner_etc_motd`) | STIG requires the verbatim DoD/USG "you are accessing a U.S. Government Information System" text. This is a fictional starship fleet, not a USG system, so `/etc/issue{,.net}` + `/etc/motd` carry the fleet (ISV AURELIAN) notice instead (provisioner step 2e, `files/banner/fleet-issue`). The content rules therefore fail by design. | The banner **keeps its security function**: it still states authorized-crew-only, that the terminal is monitored/logged, and that unauthorized access is prohibited — only the wording is themed. `sshd_enable_warning_banner` (Banner is shown) still passes. |
## Things that are NOT deviations (kept / enforced) ## Things that are NOT deviations (kept / enforced)
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). - **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.)
- **auditd** enabled; STIG audit rules applied; logs locally. - **auditd** enabled; STIG audit rules applied; logs locally.
- **sshd hardening**: root login off, password & keyboard-interactive auth off, - **sshd hardening**: root login off, password & keyboard-interactive auth off,
key-only (drop-in `50-fleet-hardening.conf`); STIG ciphers/MACs/KEX applied. key-only (drop-in `50-fleet-hardening.conf`); STIG ciphers/MACs/KEX applied.
- **No per-host state**: SSH host keys stripped at build, regenerated at first - **No per-host state**: SSH host keys stripped at build, regenerated at first
boot into the overlay; `machine-id` empty. boot into the overlay; `machine-id` empty.
- Password-policy / faillock rules remain selected — harmless (no interactive - Password-policy / faillock rules remain **selected and applied system-wide**
passwords) and keep the report honest. (kept honest in the report); they are exempted only for the two key-only
appliance accounts — see deviation #10.
## How to read the generated report ## How to read the generated report
+18
View File
@@ -0,0 +1,18 @@
+===================================================================+
| |
| I S V A U R E L I A N |
| |
| Lancer-class Deep Survey Cruiser |
| Fleet Registry FN-774 |
| |
+===================================================================+
| |
| AUTHORIZED CREW ONLY. This terminal is bonded to the ship's |
| core AI; every keystroke is logged to the bridge. Unauthorized |
| access is a breach of the Fleet Charter and will be met with |
| containment protocols. If you are not cleared for this vessel, |
| disconnect now and report to your duty station. |
| |
| Safe travels, and mind the airlocks. |
| |
+===================================================================+
@@ -0,0 +1,13 @@
# Derive the system hostname from the network at boot.
#
# The image carries NO per-host hostname (invariant: no per-host state in the
# artifact) — /etc/hostname is emptied at build, so the static hostname is unset
# and the host comes up as "localhost". With hostname-mode=default, NetworkManager
# sets the *transient* hostname once it has a DHCP lease, in this order:
# 1. DHCP option 12 (host-name) if the server sends one — the lab dnsmasq's
# fleet-namer assigns a creative starship name per MAC, server-side.
# 2. otherwise a reverse-DNS (PTR) lookup of the leased address.
# This is already NM's default, but we set it explicitly so a stricter
# global/STIG config can't silently switch it to "none" and leave hosts unnamed.
[main]
hostname-mode=default
+23
View File
@@ -0,0 +1,23 @@
# PAM stack for the autologin kiosk session — installed to /etc/pam.d/kiosk and
# referenced by kiosk.service (PAMName=kiosk). Replaces the previous
# PAMName=login, which pulled in the STIG-hardened login/system-auth stacks
# (pam_faillock account phase + password aging/inactivity). The kiosk user is
# unprivileged and PASSWORDLESS, so that hardened *account* phase rejected it and
# the session never opened (getty kept tty1).
#
# systemd runs only the ACCOUNT and SESSION phases for PAMName (never auth), so:
# - account: permissive (the kiosk user has no password to age/lock; this is an
# intentional appliance-account deviation — see docs/stig-deviations.md).
# - session: a full-enough stack that logind/pam_systemd registers the session
# on seat0/vt1 (weston needs a seat for DRM master + libinput). pam_loginuid
# keeps the audit trail; pam_limits applies ulimits.
# A custom service name means STIG/authselect (which only regenerate
# system-auth/password-auth/login) never touch this file.
auth required pam_permit.so
account required pam_permit.so
password required pam_deny.so
session required pam_loginuid.so
session optional pam_keyinit.so force revoke
session required pam_limits.so
session optional pam_systemd.so
session required pam_unix.so
+15 -6
View File
@@ -10,13 +10,17 @@ Conflicts=getty@tty1.service
[Service] [Service]
Type=simple Type=simple
User=kiosk User=kiosk
# A real PAM login session on tty1 so logind assigns seat0 — weston needs a seat # A real PAM session on tty1 so logind assigns seat0 — weston needs a seat for
# for DRM master + libinput. (PAMName runs account/session, not auth, so the # DRM master + libinput. Uses a DEDICATED PAM stack (/etc/pam.d/kiosk, see
# passwordless kiosk user is fine.) XDG_SEAT/XDG_VTNR are set into the PAM # files/kiosk/kiosk.pam) instead of "login": under STIG the login/system-auth
# environment before pam_open_session, so pam_systemd registers the session on # account phase (pam_faillock + password aging) rejects the passwordless kiosk
# user, so the session never opened and getty kept tty1. The kiosk stack keeps a
# full session (pam_systemd seat registration) but a permissive account phase.
# (PAMName runs account/session, not auth.) XDG_SEAT/XDG_VTNR are set into the
# PAM environment before pam_open_session so pam_systemd registers the session on
# seat0/vt1 — WITHOUT them logind's auto-derivation failed and weston died with # seat0/vt1 — WITHOUT them logind's auto-derivation failed and weston died with
# "logind: failed to get session seat" -> "failed to create compositor backend". # "logind: failed to get session seat" -> "failed to create compositor backend".
PAMName=login PAMName=kiosk
Environment=XDG_SEAT=seat0 Environment=XDG_SEAT=seat0
Environment=XDG_VTNR=1 Environment=XDG_VTNR=1
TTYPath=/dev/tty1 TTYPath=/dev/tty1
@@ -38,4 +42,9 @@ Restart=always
RestartSec=2 RestartSec=2
[Install] [Install]
WantedBy=graphical.target # multi-user.target, NOT graphical.target: the kiosk launches weston directly
# (no display manager), and multi-user.target is always reached on a normal boot
# — graphical.target is only active if it's the default AND nothing blocks it,
# which left the kiosk dead at boot (started fine on manual restart). Conflicts=
# getty@tty1 still hands tty1 over to the kiosk.
WantedBy=multi-user.target
+12 -2
View File
@@ -72,13 +72,23 @@
<xccdf:select idref="xccdf_org.ssgproject.content_rule_package_firewalld_installed" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_package_firewalld_installed" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_set_firewalld_default_zone" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_set_firewalld_default_zone" selected="false"/>
<!-- Remote audit offload: LOCAL-only for now (decision). auditd runs and logs <!-- Remote audit offload: done via rsyslog (provisioner 2f) to the lab
to the overlay; audisp-remote/rsyslog remote loghost waived. --> collector. The audisp-remote native-protocol rules stay waived. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_configure_remote_server" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_configure_remote_server" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_encrypt_sent_records" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_encrypt_sent_records" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_network_failure_action" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_auditd_audispd_network_failure_action" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_remote_loghost" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_remote_loghost" selected="false"/>
<!-- rsyslog TLS offload: these force gtls + x509 peer auth + a CA file for ALL
forwarding. The lab boot LAN is isolated and plaintext (HTTP boot, no
MACsec); applying them makes rsyslog require TLS and BREAKS the plaintext
2f forward to serving/audit-collector (no CA, plaintext :514). Waived for
the lab; TLS offload is tracked as production hardening (docs/TODO.md #2).
Re-select once the collector has a cert + the CA is bundled in the image. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_encrypt_offload_defaultnetstreamdriver" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_encrypt_offload_actionsendstreamdrivermode" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_rsyslog_encrypt_offload_actionsendstreamdriverauthmode" selected="false"/>
<!-- Smart card: diskless, key-based admin; no PKI/smartcard reader. Enabling <!-- Smart card: diskless, key-based admin; no PKI/smartcard reader. Enabling
smartcard-required login would lock the host out. --> smartcard-required login would lock the host out. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_require_smart_card_login" selected="false"/> <xccdf:select idref="xccdf_org.ssgproject.content_rule_require_smart_card_login" selected="false"/>
+18 -2
View File
@@ -71,7 +71,20 @@ for k in ("pass", "fixed", "fail", "error", "unknown", "notchecked",
"notapplicable", "notselected", "informational"): "notapplicable", "notselected", "informational"):
if counts.get(k): if counts.get(k):
print(f"- **{k}**: {counts[k]}") print(f"- **{k}**: {counts[k]}")
print(f"\n**{review} rule(s) need REVIEW** "
# A scan where NOTHING was actually evaluated (no pass/fixed/fail — only
# notapplicable/notselected) is NOT clean: the datastream/CPE didn't match the
# host, so no rule was checked and no hardening was verified. Flag it loudly
# instead of letting "0 REVIEW" read as success.
evaluated = counts.get("pass", 0) + counts.get("fixed", 0) + counts.get("fail", 0)
if evaluated == 0:
print("\n> ⚠️ **INVALID SCAN — 0 rules evaluated.** Every rule came back "
"`notapplicable`/`notselected`, which means the SSG datastream/CPE did "
"not match this host (e.g. the RHEL9 datastream run on Rocky). **No "
"hardening was assessed** — this report is NOT a clean result. Use the "
"distro-matching datastream (`ssg-rl9-ds.xml` on Rocky) and rebuild.\n")
else:
print(f"\n**{review} rule(s) need REVIEW** "
f"(non-pass and not a documented intentional deviation).\n") f"(non-pass and not a documented intentional deviation).\n")
if findings: if findings:
@@ -80,8 +93,11 @@ if findings:
print("|---|---|---|---|---|") print("|---|---|---|---|---|")
for sev, res, rid, title, tag in findings: for sev, res, rid, title, tag in findings:
print(f"| {sev} | {res} | `{short(rid)}` | {tag} | {title} |") print(f"| {sev} | {res} | `{short(rid)}` | {tag} | {title} |")
else: elif evaluated > 0:
print("No fail/error/unknown/notchecked rules — clean against the tailored profile.") print("No fail/error/unknown/notchecked rules — clean against the tailored profile.")
# Non-zero exit on an invalid (non-evaluating) scan so callers can gate on it.
sys.exit(2 if evaluated == 0 else 0)
PY PY
[ "$OUT" = /dev/stdout ] || echo "wrote $OUT" >&2 [ "$OUT" = /dev/stdout ] || echo "wrote $OUT" >&2
+9
View File
@@ -28,12 +28,21 @@ DHCP_RANGE_START=192.168.50.100
DHCP_RANGE_END=192.168.50.200 DHCP_RANGE_END=192.168.50.200
DHCP_LEASE=12h DHCP_LEASE=12h
# --- Kiosk SPA -------------------------------------------------------------
# Loopback port the kiosk-app container publishes; the host-network main nginx
# reverse-proxies /kiosk/ to http://127.0.0.1:${KIOSK_APP_PORT}. Change only if
# 8088 clashes on the serving host (also update http/default.conf proxy_pass).
KIOSK_APP_PORT=8088
# --- DNS names ------------------------------------------------------------- # --- DNS names -------------------------------------------------------------
BOOT_DOMAIN=fleet.lab BOOT_DOMAIN=fleet.lab
BOOT_HOST=boot.fleet.lab # HTTP boot server name (UKI) BOOT_HOST=boot.fleet.lab # HTTP boot server name (UKI)
# NVMe/TCP discovery name. Put THIS value in the UKI cmdline as NVMET_TRADDR # NVMe/TCP discovery name. Put THIS value in the UKI cmdline as NVMET_TRADDR
# (extract-artifacts.sh) so rd.nvmf.discover resolves here per environment. # (extract-artifacts.sh) so rd.nvmf.discover resolves here per environment.
NVME_HOST=nvme.fleet.lab NVME_HOST=nvme.fleet.lab
# Remote audit collector name. Host-generic — bake into the image's rsyslog
# forward rule so every host offloads auditd here; resolved to this serving host.
AUDIT_HOST=audit.fleet.lab
# --- NVMe/TCP target ------------------------------------------------------- # --- NVMe/TCP target -------------------------------------------------------
SUBNQN=nqn.2026-06.org.example.fleet:rootfs SUBNQN=nqn.2026-06.org.example.fleet:rootfs
+7 -1
View File
@@ -58,7 +58,8 @@ docker compose ps
# HTTP: UKI is served # HTTP: UKI is served
curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK
# HTTP: kiosk test page is served (what the diskless kiosk loads by default) # HTTP: kiosk SPA is served (reverse-proxied to the kiosk-app container —
# the spaceship terminal the diskless kiosk loads by default)
curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK
# NTP: lab time server answers (diskless hosts get it via DHCP option 42) # NTP: lab time server answers (diskless hosts get it via DHCP option 42)
@@ -71,6 +72,11 @@ sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the
# DNS: discovery name resolves to the serving host # DNS: discovery name resolves to the serving host
dig +short @${SERVER_IP} nvme.fleet.lab # -> ${SERVER_IP} dig +short @${SERVER_IP} nvme.fleet.lab # -> ${SERVER_IP}
# Audit collector: rsyslog receiver is listening on :514 (booted hosts offload
# auditd here; records land in audit-collector/logs/<sender-ip>/)
ss -lntp | grep ':514'
logger -n ${SERVER_IP} -P 514 -T -t audispd "collector smoke test" # -> logs/<ip>/audit.log
# DHCP/boot: watch leases + bootfile handout # DHCP/boot: watch leases + bootfile handout
docker compose logs -f dnsmasq docker compose logs -f dnsmasq
``` ```
+5
View File
@@ -0,0 +1,5 @@
# Received logs and rsyslog spool are runtime state, not source.
logs/*
spool/*
!logs/.gitkeep
!spool/.gitkeep
+17
View File
@@ -0,0 +1,17 @@
# syntax=docker/dockerfile:1
#
# rsyslog as a remote log/audit collector (RECEIVE side only) for the diskless
# fleet serving stack. Booted hosts forward auditd + syslog here; records land
# under /var/log/remote/<sender-ip>/. See rsyslog.conf + README.md.
FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20
RUN apk add --no-cache rsyslog tzdata \
&& mkdir -p /var/log/remote /var/spool/rsyslog
COPY rsyslog.conf /etc/rsyslog.conf
EXPOSE 514/tcp
# Foreground (-n), our config only (-f). rsyslog logs its own startup to stderr,
# captured by `docker compose logs audit`.
ENTRYPOINT ["rsyslogd", "-n", "-f", "/etc/rsyslog.conf"]
+82
View File
@@ -0,0 +1,82 @@
# Audit / log collector (receive side)
An **rsyslog** container that receives logs forwarded by booted diskless hosts
and writes them to durable, per-source-host files. Diskless hosts run on an
immutable erofs root with a **RAM-only volatile overlay**, so their local
`/var/log/audit/audit.log` is lost on every reboot — this gives auditd somewhere
permanent to offload to. It implements the remote-audit-offload that STIG
deviation #6 (`docs/stig-deviations.md`) lists as future hardening.
```
booted host serving host
┌───────────────────────────┐ ┌──────────────────────────────┐
│ auditd → audisp-syslog │ TCP/514 │ audit (this container) │
│ → rsyslog (forward) ├──────────►│ rsyslogd → /var/log/remote/ │
│ to ${AUDIT_HOST} │ syslog │ <sender-ip>/audit.log │
└───────────────────────────┘ │ <sender-ip>/messages.log │
└──────────────────────────────┘
```
## What this provides (the receiver)
- Listens on **TCP/514** (plain syslog). Disk-assisted queue so bursts / brief
stalls don't drop records.
- Writes per **sender IP** (not the claimed hostname): audit records →
`audit.log`, everything else → `messages.log`, under
`./logs/<ip>/` (bind-mounted, inspectable on the host).
- Host-networked, so hosts reach it at `${AUDIT_HOST}` (resolved to the serving
host by the stack's dnsmasq), matching the boot/NVMe DNS pattern.
Run it with the rest of the stack:
```bash
cd serving
docker compose build audit
docker compose up -d audit
docker compose logs -f audit # startup + connection messages
tail -F audit-collector/logs/*/audit.log
```
Quick self-test (send a line as if from a host):
```bash
logger -n 127.0.0.1 -P 514 -T -t audispd "test audit record"
ls audit-collector/logs/ # a 127.0.0.1/ dir appears
```
## What's still needed (the sender — in the image)
This container only **receives**. To make hosts actually send, the hardened
image needs (not yet wired — keep `no per-host state`, use the generic
`${AUDIT_HOST}` name, never a per-host address):
1. **auditd → syslog**: enable the `audisp-syslog` plugin
(`/etc/audit/plugins.d/syslog.conf`: `active = yes`), so audit events also go
to the local syslog socket.
2. **rsyslog forward rule**: a drop-in (e.g. `/etc/rsyslog.d/60-fleet-remote.conf`)
forwarding to the collector with a disk-assisted action queue, so a network
blip doesn't drop records:
```rsyslog
*.* action(type="omfwd" target="audit.fleet.lab" port="514" protocol="tcp"
queue.type="LinkedList" queue.fileName="fwdq"
queue.maxDiskSpace="256m" queue.saveOnShutdown="on"
action.resumeRetryCount="-1")
```
3. Bake `audit.fleet.lab` (the generic name) — DNS resolves it per environment.
Once the sender is wired, flip STIG deviation #6 in `docs/stig-deviations.md`
from "local-only" to "offloading to ${AUDIT_HOST}" and the
`rsyslog_remote_loghost` rule should pass.
## Production hardening (lab simplifications to revisit)
The lab stack is **plaintext TCP**, mirroring the HTTP-boot/no-TLS posture. For
a secured fleet:
- **TLS**: rsyslog `gtls` driver (mutual auth with the boot-infra CA), or
**RELP + TLS** (`imrelp`/`omrelp`) for guaranteed, acknowledged delivery.
- **Restrict senders** by subnet (`$AllowedSender`) or firewall :514 to the
boot LAN.
- Add log rotation/retention (logrotate sidecar or rsyslog `outchannel`) and
ship to long-term storage / SIEM.
+46
View File
@@ -0,0 +1,46 @@
# Remote audit / syslog collector for the diskless fleet (LAB).
#
# Receives logs forwarded by booted diskless hosts over plain TCP/514 and writes
# them per-source-host. Diskless hosts have an immutable, RAM-only overlay root,
# so their local audit log dies on reboot — this gives auditd somewhere durable
# to offload to (STIG remote-audit-offload, deviation #6 in docs/stig-deviations.md).
#
# Lab posture: PLAINTEXT TCP, mirroring the HTTP-boot/no-TLS lab stack. For
# production switch to TLS (gtls driver) or RELP+TLS and restrict senders by
# subnet — see README.md.
global(workDirectory="/var/spool/rsyslog")
# --- intake: plain TCP syslog ---
module(load="imtcp" maxSessions="1000")
input(type="imtcp" port="514")
# Some senders only speak UDP; uncomment to also accept it (lossy, no queue).
#module(load="imudp")
#input(type="imudp" port="514")
# Disk-assisted main queue so a brief disk stall or a burst of records doesn't
# drop audit events (the records keep flowing to spool, then drain to disk).
main_queue(
queue.type="LinkedList"
queue.filename="mainq"
queue.maxdiskspace="1g"
queue.saveOnShutdown="on"
)
# --- per-source-host files (keyed by SENDER IP, not the claimed hostname) ---
template(name="HostAudit" type="string" string="/var/log/remote/%FROMHOST-IP%/audit.log")
template(name="HostMessages" type="string" string="/var/log/remote/%FROMHOST-IP%/messages.log")
# Audit records (audisp-syslog tags them 'audispd'/'audit', or forwards on the
# local6 facility) go to a dedicated per-host audit.log; everything else to
# messages.log. Files/dirs are created 0640/0750 (audit logs aren't world-read).
if ($programname == 'audispd')
or ($programname startswith 'audit')
or ($syslogfacility-text == 'local6') then {
action(type="omfile" dynaFile="HostAudit"
dirCreateMode="0750" fileCreateMode="0640")
stop
}
action(type="omfile" dynaFile="HostMessages"
dirCreateMode="0750" fileCreateMode="0640")
+1 -1
View File
@@ -2,7 +2,7 @@
# host as their time source via DHCP option 42 (dnsmasq), so they never reach the # host as their time source via DHCP option 42 (dnsmasq), so they never reach the
# public NTP pool (no workstation egress). Config is bind-mounted from # public NTP pool (no workstation egress). Config is bind-mounted from
# chrony.conf in docker-compose.yml. Build our own so the version is pinned. # chrony.conf in docker-compose.yml. Build our own so the version is pinned.
FROM alpine:3.20 FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20
RUN apk add --no-cache chrony RUN apk add --no-cache chrony
# -d: run in the foreground (don't daemonise) so the container stays up and logs # -d: run in the foreground (don't daemonise) so the container stays up and logs
# to stdout for `docker compose logs`. # to stdout for `docker compose logs`.
+6 -1
View File
@@ -1,6 +1,11 @@
# Minimal, reliable dnsmasq image (Hub dnsmasq images vary; build our own so # Minimal, reliable dnsmasq image (Hub dnsmasq images vary; build our own so
# the version is pinned and the entrypoint is predictable). Config is passed # the version is pinned and the entrypoint is predictable). Config is passed
# entirely as --flags from docker-compose.yml. # entirely as --flags from docker-compose.yml.
FROM alpine:3.20 FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20
RUN apk add --no-cache dnsmasq RUN apk add --no-cache dnsmasq
# fleet-namer: --dhcp-script that assigns creative starship hostnames per MAC,
# server-side (keeps the workstation image neutral). COPY'd in root-owned, 0755
# so dnsmasq will execute it. Uses busybox sha256sum/cut/wc — all in the image.
COPY fleet-namer /usr/local/bin/fleet-namer
RUN chmod 0755 /usr/local/bin/fleet-namer
ENTRYPOINT ["dnsmasq"] ENTRYPOINT ["dnsmasq"]
+23
View File
@@ -0,0 +1,23 @@
# DHCP reservations for the diskless fleet (dnsmasq --dhcp-hostsfile).
#
# Two ways entries land here, both SERVER-SIDE (the workstation image carries no
# hostname and stays fleet-generic):
#
# 1. AUTO (default): fleet-namer (the --dhcp-script) assigns a stable, creative
# starship-themed name per MAC on first contact (e.g. "nebula-vanguard-07")
# and appends a line below, then reloads dnsmasq. Same MAC -> same name
# forever. The host picks it up on its next DHCP renewal/reboot.
#
# 2. MANUAL (override): add your own line to pin a specific host. fleet-namer
# skips any MAC already listed, so manual entries always win.
#
# Format (fields after the MAC are order-independent):
# <MAC>,<hostname> # name only; IP from --dhcp-range
# <MAC>,<IPv4>,<hostname> # also pin the IP
# Names become <hostname>.${BOOT_DOMAIN} in DNS (A + PTR).
#
# Manual examples (uncomment + edit):
# 52:54:00:11:22:01,192.168.50.101,bridge-command
# 52:54:00:11:22:02,reactor-control
#
# --- fleet-namer auto-assigned entries are appended below this line ---
+45
View File
@@ -0,0 +1,45 @@
#!/bin/sh
# fleet-namer — dnsmasq --dhcp-script that assigns a stable, creative cyber
# sci-fi hostname per host, SERVER-SIDE, so the workstation image stays neutral.
#
# dnsmasq calls this on every lease event with: $1=action(add|old|del)
# $2=MAC $3=IP [$4=hostname]. For a MAC we haven't named yet, we derive a name
# DETERMINISTICALLY from the MAC (sha256 -> curated wordlists), append a
# reservation (MAC,name) to the dhcp-hostsfile, and SIGHUP dnsmasq so it re-reads
# it. dnsmasq then hands the name out as DHCP option 12 + registers DNS A/PTR;
# NetworkManager on the host sets it as the hostname.
#
# Same MAC -> same name forever (idempotent). The host picks up the name on its
# next DHCP transaction (renewal at ~T1, or reboot) — see the lease-time note in
# docker-compose.yml. Manual reservations in the hostsfile always win (we skip a
# MAC that's already listed).
set -eu
action="$1"; mac="$2"
case "$action" in add|old) ;; *) exit 0 ;; esac
[ -n "$mac" ] || exit 0
HOSTSFILE="${FLEET_HOSTSFILE:-/etc/dnsmasq-hosts}"
# Already named (auto or manual)? leave it.
grep -qi "^$mac," "$HOSTSFILE" 2>/dev/null && exit 0
# Curated DNS-safe wordlists (single line, single-spaced — indexed with cut).
PREFIX="nova nebula quasar pulsar comet vega rigel orion lyra cygnus draco hydra phoenix titan atlas helios aurora zenith vortex ion plasma photon quantum warp aether astral stellar lunar solar cobalt obsidian halcyon"
NOUN="cruiser frigate vanguard sentinel ranger voyager pioneer horizon beacon spectre nomad drifter wraith talon raptor falcon meridian apex helm reactor relay array lance aegis ember corsair seeker harbinger paragon warden monolith"
np=$(echo "$PREFIX" | wc -w)
nn=$(echo "$NOUN" | wc -w)
# Deterministic indices from the MAC's sha256 (hex chunks mod list length).
h=$(printf '%s' "$mac" | sha256sum | cut -c1-12)
pi=$(( 0x$(echo "$h" | cut -c1-4) % np + 1 ))
ni=$(( 0x$(echo "$h" | cut -c5-8) % nn + 1 ))
num=$(( 0x$(echo "$h" | cut -c9-12) % 100 ))
name=$(printf '%s-%s-%02d' "$(echo "$PREFIX" | cut -d' ' -f"$pi")" "$(echo "$NOUN" | cut -d' ' -f"$ni")" "$num")
# Register + reload dnsmasq (re-reads --dhcp-hostsfile on SIGHUP). dnsmasq is
# PID 1 in this container; the script runs as root (--user=root in compose).
echo "$mac,$name" >> "$HOSTSFILE"
kill -HUP 1 2>/dev/null || true
echo "fleet-namer: $mac -> $name" >&2
exit 0
+53 -4
View File
@@ -43,6 +43,11 @@ services:
- NET_ADMIN # DHCP raw sockets - NET_ADMIN # DHCP raw sockets
- NET_RAW - NET_RAW
restart: unless-stopped restart: unless-stopped
volumes:
# DHCP reservations (MAC -> name [-> IP]); drives DHCP option 12 (hostname)
# + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add
# manual lines to pin specific hosts. RW so fleet-namer can append.
- ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts
command: command:
- --keep-in-foreground - --keep-in-foreground
# Log to stderr so `docker compose logs` captures output. Without this, # Log to stderr so `docker compose logs` captures output. Without this,
@@ -63,6 +68,9 @@ services:
# resolve the per-environment DNS name to this serving host. # resolve the per-environment DNS name to this serving host.
- --address=/${NVME_HOST}/${SERVER_IP} - --address=/${NVME_HOST}/${SERVER_IP}
- --address=/${BOOT_HOST}/${SERVER_IP} - --address=/${BOOT_HOST}/${SERVER_IP}
# Host-generic remote audit collector name (baked into the image config so
# every host offloads auditd here; resolved per-environment to this host).
- --address=/${AUDIT_HOST}/${SERVER_IP}
# Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's # Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's
# call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser # call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser
# tries. Defense-in-depth behind the in-image Chromium policy/flags; in # tries. Defense-in-depth behind the in-image Chromium policy/flags; in
@@ -81,6 +89,18 @@ services:
# below so they sync time internally and never reach the public NTP pool. # below so they sync time internally and never reach the public NTP pool.
# The image's NM dispatcher (10-chrony-dhcp-ntp) feeds this into chrony. # The image's NM dispatcher (10-chrony-dhcp-ntp) feeds this into chrony.
- --dhcp-option=option:ntp-server,${SERVER_IP} - --dhcp-option=option:ntp-server,${SERVER_IP}
# ---- Per-host names (creative starship hostnames, server-assigned) ----
# The workstation image is hostname-neutral; naming is done HERE. fleet-namer
# (the --dhcp-script) derives a stable themed name per MAC (e.g.
# nebula-vanguard-07), appends it to the hostsfile, and SIGHUPs dnsmasq;
# dnsmasq then sends it as DHCP option 12 (-> NetworkManager sets the
# hostname) and registers DNS A/PTR under ${BOOT_DOMAIN}. The host picks up
# its name on the NEXT DHCP transaction (renewal at ~half lease, or reboot)
# — shorten DHCP_LEASE if you want naming to settle faster on first contact.
# --user=root so the dhcp-script can write the hostsfile + signal dnsmasq.
- --user=root
- --dhcp-hostsfile=/etc/dnsmasq-hosts
- --dhcp-script=/usr/local/bin/fleet-namer
# ---- UEFI HTTP Boot ---- # ---- UEFI HTTP Boot ----
# Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60) # Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60)
# and hand over the UKI URL (opt 67). IP in the URL avoids a DNS round-trip # and hand over the UKI URL (opt 67). IP in the URL avoids a DNS round-trip
@@ -91,9 +111,11 @@ services:
# --- HTTP boot server (serves the UKI) ------------------------------------- # --- HTTP boot server (serves the UKI) -------------------------------------
http: http:
image: nginx:stable image: harbor.bitasylum.org/dockerhub/library/nginx:stable
network_mode: host # listens on :80 -> http://${SERVER_IP}/fleet-<ver>.efi network_mode: host # listens on :80 -> http://${SERVER_IP}/fleet-<ver>.efi
restart: unless-stopped restart: unless-stopped
depends_on:
- kiosk # /kiosk/ is reverse-proxied to the kiosk SPA
volumes: volumes:
- type: image - type: image
source: ${OCI_IMAGE}:${IMAGE_VERSION} source: ${OCI_IMAGE}:${IMAGE_VERSION}
@@ -102,13 +124,23 @@ services:
subpath: artifacts # mount only /artifacts from the image subpath: artifacts # mount only /artifacts from the image
# Fallback (Docker < 28 / offline): bind the local Packer output dir: # Fallback (Docker < 28 / offline): bind the local Packer output dir:
# - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro # - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro
# Serve the kiosk test page at /kiosk/ alongside the artifacts. Config # Config adds the artifact root + the /kiosk/ reverse-proxy location. The
# adds the location; the page is lab content (NOT in the artifact image). # kiosk SPA itself is the separate 'kiosk' service below (not bind-mounted).
- ./http/default.conf:/etc/nginx/conf.d/default.conf:ro - ./http/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./http/kiosk:/usr/share/nginx/kiosk:ro
# For HTTPS boot (production), terminate TLS here with the boot-infra CA's # For HTTPS boot (production), terminate TLS here with the boot-infra CA's
# cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab. # cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab.
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
# Svelte + Tailwind app built into a static bundle served by its own nginx.
# Bridge-networked (NOT host) so it doesn't fight :80; published on loopback
# only, where the host-network 'http' nginx reverse-proxies /kiosk/ to it.
# Build: docker compose build kiosk
kiosk:
build: ./kiosk-app
restart: unless-stopped
ports:
- "127.0.0.1:${KIOSK_APP_PORT:-8088}:80" # loopback only; main nginx proxies here
# --- NTP server (serves time to the boot LAN; handed out via DHCP opt 42) --- # --- NTP server (serves time to the boot LAN; handed out via DHCP opt 42) ---
ntp: ntp:
build: ./chrony build: ./chrony
@@ -123,6 +155,23 @@ services:
# lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already # lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already
# runs its own NTP and you only want this to relay. # runs its own NTP and you only want this to relay.
# --- Remote audit / log collector (RECEIVE side) ---------------------------
# rsyslog receiver: booted diskless hosts (whose RAM-only overlay root loses
# local logs on reboot) forward auditd via audisp-syslog + syslog here over
# TCP/514. Records are written per-source-host under
# ./audit-collector/logs/<sender-ip>/{audit,messages}.log. Host-networked so
# hosts can reach it at ${AUDIT_HOST} (= this host) on the boot LAN.
# NOTE: this is the receiver only — the IMAGE must be configured to send
# (audisp-syslog + an rsyslog forward rule targeting ${AUDIT_HOST}); see
# audit-collector/README.md. Until then the collector simply waits, idle.
audit:
build: ./audit-collector
network_mode: host # receive from booted hosts on the boot LAN (:514)
restart: unless-stopped
volumes:
- ./audit-collector/logs:/var/log/remote # durable, inspectable on host
- ./audit-collector/spool:/var/spool/rsyslog # disk-assisted queue
# --- NVMe/TCP target (serves rootfs.erofs, read-only) ---------------------- # --- NVMe/TCP target (serves rootfs.erofs, read-only) ----------------------
nvmeof: nvmeof:
# Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no # Prebuilt by CI (.gitea/workflows/spdk-image.yaml) and pulled here — no
+18 -11
View File
@@ -1,12 +1,15 @@
# Lab boot HTTP server (nginx). Replaces the stock default.conf so one server on # Lab boot HTTP server (nginx). One server on :80 serves BOTH:
# :80 serves BOTH:
# / -> the OCI release artifacts (UKI fleet-<ver>.efi, etc.), mounted # / -> the OCI release artifacts (UKI fleet-<ver>.efi, etc.), mounted
# read-only from the image volume at /usr/share/nginx/html # read-only from the image volume at /usr/share/nginx/html
# /kiosk/ -> the diskless kiosk test page (lab content bind-mounted at # /kiosk/ -> reverse-proxied to the kiosk SPA container (serving/kiosk-app),
# /usr/share/nginx/kiosk, deliberately NOT part of the artifact # a Svelte spaceship-terminal app. NOT part of the artifact image
# image, which stays FROM-scratch). # (that stays FROM-scratch); a separate, independently built image.
# A separate kiosk container was considered, but every service here uses host #
# networking, so a second nginx would fight for :80; a single location is cleaner. # This nginx uses host networking (firmware must fetch the UKI), so it can't join
# the kiosk container's bridge network by service name. The kiosk container
# publishes on 127.0.0.1:8088 (compose), which the host-network nginx reaches
# over loopback. The trailing slash on proxy_pass strips the /kiosk/ prefix, so
# the SPA (built with base /kiosk/) gets requests at its own root.
server { server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
@@ -17,10 +20,14 @@ server {
autoindex on; autoindex on;
} }
# Kiosk test page. The diskless kiosk (kiosk_url) points at # Kiosk SPA. The diskless kiosk (kiosk_url) points at
# http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq. # http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq
# and proxied to the kiosk-app container.
location /kiosk/ { location /kiosk/ {
alias /usr/share/nginx/kiosk/; proxy_pass http://127.0.0.1:8088/;
index index.html; proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
-72
View File
@@ -1,72 +0,0 @@
<!DOCTYPE html>
<!--
Kiosk test page — served by the lab boot HTTP server (nginx) at /kiosk/, NOT
baked into the OCI artifact image (that stays FROM-scratch: UKI + rootfs only).
The diskless kiosk (weston + Chromium) loads this instead of an internet site,
so a boot is validated end-to-end without external connectivity: DNS resolves
boot.fleet.lab via dnsmasq, HTTP fetches from the serving host, and Chromium
renders it. Fully self-contained (no external fonts/CDN) so it works offline.
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fleet Kiosk — Boot OK</title>
<style>
:root { --fg: #00ffd0; --dim: #4a8c84; --bg: #040a0d; --accent: #ff2e88; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: radial-gradient(circle at 50% 30%, #0a1620 0%, var(--bg) 70%);
color: var(--fg);
font-family: "DejaVu Sans Mono", ui-monospace, monospace;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; gap: 1.2rem; padding: 2rem;
overflow: hidden;
}
h1 { font-size: clamp(2rem, 7vw, 5rem); margin: 0; letter-spacing: .15em;
text-shadow: 0 0 18px var(--fg); }
.ok { color: var(--accent); text-shadow: 0 0 18px var(--accent); }
.sub { color: var(--dim); font-size: clamp(.9rem, 2.2vw, 1.3rem);
letter-spacing: .1em; }
.grid { display: grid; grid-template-columns: auto auto; gap: .35rem 1.2rem;
margin-top: 1rem; font-size: clamp(.8rem, 1.8vw, 1.1rem);
text-align: left; }
.grid .k { color: var(--dim); }
.clock { font-size: clamp(1.4rem, 4vw, 2.4rem); text-shadow: 0 0 12px var(--fg); }
.bar { width: min(60vw, 520px); height: 3px; background: var(--dim); opacity:.4;
border-radius: 2px; }
footer { position: fixed; bottom: 1rem; color: var(--dim); font-size: .8rem;
letter-spacing: .1em; }
</style>
</head>
<body>
<h1><span class="ok">▣</span> FLEET KIOSK ONLINE</h1>
<div class="sub">diskless boot validated &mdash; weston + chromium rendering local HTTP</div>
<div class="bar"></div>
<div class="clock" id="clock">--:--:--</div>
<div class="grid">
<span class="k">served from</span><span id="origin">&hellip;</span>
<span class="k">page url</span><span id="href">&hellip;</span>
<span class="k">user agent</span><span id="ua">&hellip;</span>
<span class="k">screen</span><span id="screen">&hellip;</span>
</div>
<footer>boot HTTP server &middot; replace this page or point kiosk_url elsewhere</footer>
<script>
// Live clock proves JS executes; the origin/href confirm where it was fetched.
function pad(n){ return String(n).padStart(2,'0'); }
function tick(){
var d = new Date();
document.getElementById('clock').textContent =
pad(d.getHours())+':'+pad(d.getMinutes())+':'+pad(d.getSeconds());
}
setInterval(tick, 1000); tick();
document.getElementById('origin').textContent = location.host || '(file)';
document.getElementById('href').textContent = location.href;
document.getElementById('ua').textContent = navigator.userAgent;
document.getElementById('screen').textContent =
window.innerWidth + '×' + window.innerHeight;
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.vite
.git
*.local
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
*.local
+27
View File
@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
#
# Two-stage build: compile the Svelte SPA with Vite, then serve the static
# bundle from a tiny nginx. The main serving-stack nginx reverse-proxies
# /kiosk/ to this container (see ../docker-compose.yml + ../http/default.conf),
# so this image only needs to serve the built assets at its own root.
# ---- build stage ----
FROM harbor.bitasylum.org/dockerhub/library/node:22-alpine AS build
WORKDIR /app
# Install deps first for layer caching. package-lock.json is committed, so use
# `npm ci` for a reproducible, lockfile-exact install.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # -> /app/dist (base path /kiosk/)
# ---- serve stage ----
FROM harbor.bitasylum.org/dockerhub/library/nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1
+54
View File
@@ -0,0 +1,54 @@
# Fleet Kiosk App
A cyber sci-fi **spaceship monitoring & control terminal** — the page the
diskless kiosk (weston + Chromium) loads at boot. Replaces the old single static
`serving/http/kiosk/index.html` test page.
Built with **Svelte 5** + **Tailwind CSS v4** + **Vite**, served as a static
bundle from its own nginx, and reverse-proxied at `/kiosk/` by the main serving
nginx (`../http/default.conf`). Fully self-contained — no CDNs or web fonts — so
it renders on the air-gapped boot LAN (the stack's dnsmasq sinkholes external
call-home).
## Pages
Top navbar with five views, each with dummy telemetry:
- **BRIDGE** — overview tiles, systems status, subsystem load, event log
- **REACTOR** — core output/temp/containment, power distribution, fuel cells
- **NAVIGATION** — heading/velocity, tactical radar plot, flight plan, FTL drive
- **LIFE SUPPORT** — atmosphere mix, per-deck environment, recyclers
- **COMMS** — channels, signal bars, message traffic
Routing is hash-based (`#/power`, `#/nav`, …) so deep links stay client-side and
the proxy needs no SPA-fallback rewrite. A shared 1 Hz clock and small
random-walk "jitter" helpers make the static data feel live.
## Develop
```bash
npm install
npm run dev # http://localhost:5173/kiosk/
```
## Build the container
It builds as part of the serving stack:
```bash
cd .. # serving/
docker compose build kiosk
docker compose up -d
# main nginx serves it at http://${SERVER_IP}/kiosk/ (kiosk_url default)
```
The container publishes on `127.0.0.1:${KIOSK_APP_PORT:-8088}`; the host-network
main nginx proxies `/kiosk/` to it. To point the diskless kiosk here, keep the
Packer `kiosk_url=http://boot.fleet.lab/kiosk/` default (resolved to the serving
host by the stack's dnsmasq).
## Replacing the dummy data
All fictional telemetry lives in [src/lib/data.js](src/lib/data.js). A future
runtime agent could swap these constants for a `fetch()` to a fleet API; the
components only read from that module.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>FLEET // BRIDGE TERMINAL</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
# Internal nginx for the kiosk SPA container. The main serving nginx proxies
# /kiosk/ here with the prefix stripped (proxy_pass .../;), so requests arrive
# at this server rooted at "/". try_files gives the SPA fallback; routing is
# hash-based anyway, so this fallback only matters for a hard refresh on "/".
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't cache the entry document (so a new build is picked up immediately);
# the hashed assets under /assets/ are immutable and cached hard.
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2075
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "fleet-kiosk-app",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Cyber sci-fi spaceship monitoring/control terminal (Svelte + Tailwind) shown by the diskless kiosk.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.19.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
+44
View File
@@ -0,0 +1,44 @@
<script>
import Navbar from './lib/Navbar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { nav } from './lib/router.svelte.js'
import { clock, hhmmss } from './lib/clock.svelte.js'
import Dashboard from './pages/Dashboard.svelte'
import Power from './pages/Power.svelte'
import Navigation from './pages/Navigation.svelte'
import LifeSupport from './pages/LifeSupport.svelte'
import Comms from './pages/Comms.svelte'
const pages = {
dashboard: Dashboard,
power: Power,
nav: Navigation,
life: LifeSupport,
comms: Comms,
}
let Current = $derived(pages[nav.current] ?? Dashboard)
const ticker =
'SYS // all primary buses nominal ◦ FTL capacitor charging — 61% ◦ ' +
'sensor sweep grid 7-K complete ◦ comms relay 2 degraded — rerouting ◦ ' +
'reactor output 84.2% rated ◦ next waypoint: Tarsis Station, ETA 04:12:38 ◦ '
</script>
<div class="relative z-10 flex h-full flex-col">
<Navbar />
<StatusBar />
<main class="flex-1 overflow-y-auto p-4 md:p-6">
<Current />
</main>
<footer class="flex items-center gap-3 overflow-hidden border-t border-haze/20 bg-void/70 px-4 py-1.5">
<span class="shrink-0 text-[0.6rem] uppercase tracking-[0.2em] text-flare glow-sm">● LIVE</span>
<span class="shrink-0 text-[0.6rem] tabular-nums text-haze">{hhmmss(clock.now)}</span>
<div class="relative flex-1 overflow-hidden">
<span class="ticker text-[0.65rem] uppercase tracking-[0.18em] text-haze">{ticker}{ticker}</span>
</div>
</footer>
</div>
+95
View File
@@ -0,0 +1,95 @@
@import "tailwindcss";
/* Cyber sci-fi palette. Defining these as @theme tokens makes them available
as Tailwind utilities (bg-hull, text-term, border-haze, ...). Font is the
stock DejaVu Sans Mono shipped in the kiosk image — no web fonts (offline). */
@theme {
--color-void: #040a0d;
--color-hull: #081419;
--color-hull2: #0c1f27;
--color-term: #00ffd0;
--color-haze: #4a8c84;
--color-flare: #ff2e88;
--color-warn: #ffb000;
--color-ok: #2dff8a;
--color-alert: #ff3b3b;
--font-mono: "DejaVu Sans Mono", ui-monospace, "SFMono-Regular", "Courier New", monospace;
}
:root { color-scheme: dark; }
html, body, #app { height: 100%; margin: 0; padding: 0; }
body {
background-color: var(--color-void);
color: var(--color-term);
font-family: var(--font-mono);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Faint engineering-grid backdrop + center glow + vignette. */
#app::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(0, 255, 208, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 208, 0.035) 1px, transparent 1px),
radial-gradient(circle at 50% 22%, rgba(0, 90, 100, 0.35), transparent 65%);
background-size: 42px 42px, 42px 42px, 100% 100%;
}
/* CRT scanline overlay sitting above content. */
#app::after {
content: "";
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.16) 3px
);
mix-blend-mode: multiply;
}
.glow { text-shadow: 0 0 10px currentColor; }
.glow-sm { text-shadow: 0 0 6px currentColor; }
/* Cut-corner panel edge — the recurring "console module" silhouette. */
.cut {
clip-path: polygon(
0 0,
calc(100% - 14px) 0,
100% 14px,
100% 100%,
14px 100%,
0 calc(100% - 14px)
);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
.blink { animation: blink 1.4s steps(2, jump-none) infinite; }
@keyframes ticker { from { transform: translateX(100%); } to { transform: translateX(-100%); } }
.ticker { display: inline-block; white-space: nowrap; animation: ticker 32s linear infinite; }
/* Radar sweep wedge. */
@keyframes radar { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.radar-sweep {
background: conic-gradient(from 0deg, rgba(0, 255, 208, 0.28), rgba(0, 255, 208, 0) 70deg);
border-radius: 9999px;
animation: radar 4s linear infinite;
}
/* Thin neon scrollbars to match the theme. */
* { scrollbar-width: thin; scrollbar-color: rgba(0, 255, 208, 0.3) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: rgba(0, 255, 208, 0.3); }
*::-webkit-scrollbar-track { background: transparent; }
+15
View File
@@ -0,0 +1,15 @@
<script>
// Horizontal gauge. `tone` is a full bg-* class (statically scannable).
let { label, pct = 0, tone = 'bg-term', value = null, unit = '%' } = $props()
let clamped = $derived(Math.max(0, Math.min(100, pct)))
</script>
<div class="space-y-1">
<div class="flex items-baseline justify-between text-[0.65rem] uppercase tracking-wider">
<span class="text-haze">{label}</span>
<span class="text-term tabular-nums glow-sm">{value ?? clamped}{unit}</span>
</div>
<div class="h-2 w-full overflow-hidden border border-haze/30 bg-void">
<div class="h-full {tone} transition-[width] duration-700 ease-out" style="width:{clamped}%"></div>
</div>
</div>
+31
View File
@@ -0,0 +1,31 @@
<script>
import { navItems, ship } from './data.js'
import { nav } from './router.svelte.js'
</script>
<nav class="flex items-stretch border-b border-haze/30 bg-hull/80">
<div class="flex items-center gap-3 border-r border-haze/30 px-4 py-3">
<span class="text-flare glow text-2xl leading-none">◭</span>
<div class="leading-tight">
<div class="text-term glow-sm text-sm tracking-[0.25em]">{ship.name}</div>
<div class="text-haze text-[0.6rem] tracking-[0.2em]">{ship.registry}</div>
</div>
</div>
<div class="flex flex-1 items-stretch overflow-x-auto">
{#each navItems as item}
<a
href="#/{item.id}"
aria-current={nav.current === item.id ? 'page' : undefined}
class="group relative flex items-center gap-2 px-4 py-3 text-xs uppercase tracking-[0.2em] transition-colors
{nav.current === item.id ? 'text-term glow-sm bg-term/5' : 'text-haze hover:text-term hover:bg-term/5'}"
>
<span class="text-base leading-none">{item.glyph}</span>
<span class="hidden md:inline">{item.label}</span>
{#if nav.current === item.id}
<span class="absolute inset-x-0 bottom-0 h-[2px] bg-term glow"></span>
{/if}
</a>
{/each}
</div>
</nav>
+17
View File
@@ -0,0 +1,17 @@
<script>
// Reusable console "module": cut-corner border, header strip, body slot.
// `accent` is a full Tailwind class string so the class is statically visible
// to Tailwind's scanner (e.g. "text-flare").
let { title = '', accent = 'text-term', children } = $props()
</script>
<section class="cut relative border border-haze/30 bg-hull/70">
<div class="absolute left-0 top-0 h-[2px] w-12 bg-term/70"></div>
<header class="flex items-center gap-2 border-b border-haze/25 bg-hull2/50 px-3 py-2">
<span class="text-term/60 text-xs">▌</span>
<h2 class="text-[0.7rem] uppercase tracking-[0.3em] {accent} glow-sm">{title}</h2>
</header>
<div class="p-3 md:p-4">
{@render children?.()}
</div>
</section>
+25
View File
@@ -0,0 +1,25 @@
<script>
// Sweeping radar with positioned blips. `blips`: [{x,y,tone,label}] in %.
let { blips = [] } = $props()
</script>
<div class="relative mx-auto aspect-square w-full max-w-[300px] overflow-hidden rounded-full border border-haze/40 bg-void/60">
<div class="absolute inset-[14%] rounded-full border border-haze/20"></div>
<div class="absolute inset-[32%] rounded-full border border-haze/20"></div>
<div class="absolute inset-[48%] rounded-full border border-haze/20"></div>
<div class="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-haze/15"></div>
<div class="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-haze/15"></div>
<div class="radar-sweep absolute inset-0"></div>
<!-- own ship -->
<div class="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-flare glow"></div>
{#each blips as b}
<div
class="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full {b.tone ?? 'bg-term'} glow blink"
style="left:{b.x}%; top:{b.y}%"
title={b.label}
></div>
{/each}
</div>
+11
View File
@@ -0,0 +1,11 @@
<script>
let { label, value, unit = '', tone = 'text-term', sub = '' } = $props()
</script>
<div class="flex flex-col gap-1">
<span class="text-[0.6rem] uppercase tracking-[0.2em] text-haze">{label}</span>
<span class="text-2xl md:text-3xl font-bold tabular-nums {tone} glow-sm leading-none">
{value}{#if unit}<span class="ml-1 text-sm font-normal text-haze">{unit}</span>{/if}
</span>
{#if sub}<span class="text-[0.65rem] text-haze">{sub}</span>{/if}
</div>
@@ -0,0 +1,19 @@
<script>
import { ship } from './data.js'
import { clock, stardate, hhmmss } from './clock.svelte.js'
</script>
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 border-b border-haze/20 bg-void/60 px-4 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] text-haze">
<div class="flex flex-wrap items-center gap-x-6 gap-y-1">
<span><span class="text-haze/70">CLASS</span> <span class="text-term">{ship.class}</span></span>
<span><span class="text-haze/70">CMD</span> <span class="text-term">{ship.captain}</span></span>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-1">
<span class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-ok glow blink"></span>
<span class="text-ok glow-sm">CONDITION GREEN</span>
</span>
<span><span class="text-haze/70">STARDATE</span> <span class="text-term tabular-nums">{stardate(clock.now)}</span></span>
<span><span class="text-haze/70">MISSION TIME</span> <span class="text-term tabular-nums glow-sm">{hhmmss(clock.now)}</span></span>
</div>
</div>
+16
View File
@@ -0,0 +1,16 @@
<script>
// Status pill. Literal class map so every variant survives Tailwind purging.
let { tone = 'term', children } = $props()
const map = {
term: 'border-term/60 text-term',
ok: 'border-ok/60 text-ok',
warn: 'border-warn/60 text-warn',
alert: 'border-alert/60 text-alert',
flare: 'border-flare/60 text-flare',
haze: 'border-haze/60 text-haze',
}
</script>
<span class="inline-flex items-center gap-1 border px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] glow-sm {map[tone] ?? map.term}">
{@render children?.()}
</span>
+24
View File
@@ -0,0 +1,24 @@
// Shared 1 Hz clock. Module-level $state + a single interval, so every consumer
// (status bar, footer) shares one timer. Read `clock.now` reactively.
let _now = $state(new Date())
if (typeof window !== 'undefined') {
setInterval(() => { _now = new Date() }, 1000)
}
export const clock = {
get now() { return _now },
}
// Playful "stardate" derived from the wall clock, purely cosmetic.
export function stardate(d) {
const epoch = Date.UTC(2350, 0, 1)
const days = (d.getTime() - epoch) / 86_400_000
return (47000 + days / 3.65).toFixed(1)
}
export function hhmmss(d) {
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
+137
View File
@@ -0,0 +1,137 @@
// Dummy data for the spaceship terminal. All static/fictional — no real
// telemetry source yet. A future runtime agent could replace these with a
// fetch() to a fleet API; the components only read from here.
export const navItems = [
{ id: 'dashboard', label: 'BRIDGE', glyph: '◈' },
{ id: 'power', label: 'REACTOR', glyph: '⬡' },
{ id: 'nav', label: 'NAVIGATION', glyph: '✧' },
{ id: 'life', label: 'LIFE SUPPORT', glyph: '❂' },
{ id: 'comms', label: 'COMMS', glyph: '☰' },
]
export const ship = {
name: 'ISV AURELIAN',
registry: 'FN-774-Δ',
class: 'Lancer-class Deep Survey Cruiser',
captain: 'Cmdr. V. Okonkwo',
}
// --- BRIDGE / dashboard ------------------------------------------------------
export const systemStatus = [
{ name: 'Reactor Core', state: 'NOMINAL', tone: 'ok' },
{ name: 'Shield Matrix', state: 'NOMINAL', tone: 'ok' },
{ name: 'FTL Drive', state: 'CHARGING', tone: 'warn' },
{ name: 'Life Support', state: 'NOMINAL', tone: 'ok' },
{ name: 'Hull Integrity', state: '98.4%', tone: 'ok' },
{ name: 'Weapons', state: 'STANDBY', tone: 'term' },
{ name: 'Sensor Array', state: 'NOMINAL', tone: 'ok' },
{ name: 'Comms Relay', state: 'DEGRADED', tone: 'alert' },
]
export const subsystemLoad = [
{ label: 'Propulsion', pct: 72, tone: 'bg-term' },
{ label: 'Shields', pct: 64, tone: 'bg-term' },
{ label: 'Life Support', pct: 38, tone: 'bg-ok' },
{ label: 'Sensors', pct: 51, tone: 'bg-term' },
{ label: 'FTL Charge', pct: 61, tone: 'bg-warn' },
{ label: 'Weapons', pct: 9, tone: 'bg-haze' },
]
export const logEntries = [
{ t: '07:42:11', tone: 'warn', msg: 'Jump capacitor at 61% — full charge ETA 00:18:24' },
{ t: '07:39:02', tone: 'alert', msg: 'Comms relay 2 packet loss 14% — rerouting via relay 1' },
{ t: '07:31:55', tone: 'term', msg: 'Sensor sweep complete — 3 contacts logged in grid 7-K' },
{ t: '07:28:40', tone: 'ok', msg: 'Hull micro-fracture sealed on deck 4, section C' },
{ t: '07:14:09', tone: 'ok', msg: 'Reactor output stabilised at 84.2% rated' },
{ t: '06:58:21', tone: 'term', msg: 'Course correction +0.4° applied to heading 287' },
]
// --- REACTOR / power ---------------------------------------------------------
export const reactor = {
output: 84.2, // % rated
coreTemp: 4120, // K
containment: 99.1, // % field strength
rpm: 11840, // turbine
}
export const powerDistribution = [
{ label: 'Propulsion Bus', pct: 72, tone: 'bg-term' },
{ label: 'Shield Emitters', pct: 64, tone: 'bg-term' },
{ label: 'FTL Capacitor', pct: 61, tone: 'bg-warn' },
{ label: 'Sensors / EW', pct: 51, tone: 'bg-term' },
{ label: 'Life Support', pct: 38, tone: 'bg-ok' },
{ label: 'Habitation', pct: 27, tone: 'bg-ok' },
{ label: 'Weapons Standby', pct: 9, tone: 'bg-haze' },
]
export const fuelCells = [
{ id: 'CELL-A', charge: 96, tone: 'bg-ok' },
{ id: 'CELL-B', charge: 91, tone: 'bg-ok' },
{ id: 'CELL-C', charge: 78, tone: 'bg-term' },
{ id: 'CELL-D', charge: 44, tone: 'bg-warn' },
]
// --- NAVIGATION --------------------------------------------------------------
export const navState = {
heading: 287.4, // degrees
velocity: 0.21, // c
system: 'Cygnus Reach',
sector: 'Grid 7-K',
destination: 'Tarsis Station',
eta: '04:12:38',
ftlCharge: 61,
coords: { x: '+1184.22', y: '-0473.90', z: '+0061.07' },
}
export const waypoints = [
{ name: 'Tarsis Station', dist: '2.4 AU', status: 'LOCKED', tone: 'ok' },
{ name: 'Helex Belt', dist: '0.8 AU', status: 'PASSING', tone: 'warn' },
{ name: 'Relay Buoy 19', dist: '11.6 AU', status: 'QUEUED', tone: 'term' },
{ name: 'Cygnus Gate', dist: '34.0 AU', status: 'QUEUED', tone: 'term' },
]
// blips positioned in % within the radar circle (50/50 = center = own ship)
export const contacts = [
{ x: 64, y: 38, tone: 'bg-term', label: 'Freighter "Coriol"' },
{ x: 41, y: 27, tone: 'bg-warn', label: 'Unknown — passive' },
{ x: 72, y: 66, tone: 'bg-ok', label: 'Tarsis Station' },
{ x: 30, y: 58, tone: 'bg-flare', label: 'Debris field' },
]
// --- LIFE SUPPORT ------------------------------------------------------------
export const atmosphere = [
{ label: 'Oxygen', value: 20.9, unit: '%', pct: 87, tone: 'bg-ok' },
{ label: 'CO₂', value: 0.04, unit: '%', pct: 12, tone: 'bg-ok' },
{ label: 'Cabin Press.', value: 101.1, unit: 'kPa', pct: 92, tone: 'bg-ok' },
{ label: 'Temperature', value: 21.4, unit: '°C', pct: 71, tone: 'bg-term' },
{ label: 'Humidity', value: 44, unit: '%', pct: 44, tone: 'bg-term' },
{ label: 'Water Reserve', value: 68, unit: '%', pct: 68, tone: 'bg-term' },
]
export const decks = [
{ deck: 'Deck 1 — Bridge', o2: 'OK', temp: '21.0°C', tone: 'ok' },
{ deck: 'Deck 2 — Habitation', o2: 'OK', temp: '22.3°C', tone: 'ok' },
{ deck: 'Deck 3 — Med / Sci', o2: 'OK', temp: '20.6°C', tone: 'ok' },
{ deck: 'Deck 4 — Engineering', o2: 'LOW', temp: '26.8°C', tone: 'warn' },
{ deck: 'Deck 5 — Cargo', o2: 'SEALED', temp: '8.1°C', tone: 'term' },
]
export const crew = { total: 84, onDuty: 31, medical: 2, cryo: 12 }
// --- COMMS -------------------------------------------------------------------
export const channels = [
{ name: 'Fleet Command', band: 'Q-band', status: 'ENCRYPTED', tone: 'ok' },
{ name: 'Tarsis Control', band: 'S-band', status: 'OPEN', tone: 'term' },
{ name: 'Relay Buoy 19', band: 'X-band', status: 'WEAK', tone: 'warn' },
{ name: 'Distress Guard', band: '121.5', status: 'MONITOR', tone: 'term' },
{ name: 'Internal Net', band: 'Hardline', status: 'NOMINAL', tone: 'ok' },
]
export const messages = [
{ t: '07:41', from: 'FLEET CMD', tone: 'ok', text: 'Patrol pattern delta authorised. Maintain EMCON.' },
{ t: '07:33', from: 'TARSIS CTL', tone: 'term', text: 'Docking bay 4 reserved. Transmit manifest on approach.' },
{ t: '07:19', from: 'RELAY-19', tone: 'warn', text: 'Signal degradation detected. Recommend X-band switch.' },
{ t: '06:52', from: 'CORIOL', tone: 'term', text: 'Thanks for the escort, Aurelian. Safe travels.' },
{ t: '06:40', from: 'FLEET CMD', tone: 'flare', text: 'Priority weather: ion storm forming in grid 9-B.' },
]
+17
View File
@@ -0,0 +1,17 @@
// Reactive "telemetry" helper: returns an object whose .value drifts around a
// base value on an interval, so static dummy data feels like a live readout.
// Call inside a component's script (it registers an $effect for cleanup).
export function useJitter(base, { amp = 1, decimals = 0, every = 2000 } = {}) {
let value = $state(base)
$effect(() => {
const id = setInterval(() => {
const delta = (Math.random() - 0.5) * 2 * amp
value = Number((base + delta).toFixed(decimals))
}, every)
return () => clearInterval(id)
})
return { get value() { return value } }
}
@@ -0,0 +1,20 @@
// Tiny hash-based router using a module-level rune. Hash routing keeps deep
// links client-side (they never reach nginx), so the reverse proxy needs no
// SPA-fallback rewrite. `nav.current` is reactive: read it in markup/$derived.
function parse() {
if (typeof window === 'undefined') return 'dashboard'
const raw = window.location.hash.replace(/^#\/?/, '')
return raw.split(/[/?]/)[0] || 'dashboard'
}
let _route = $state(parse())
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', () => { _route = parse() })
if (!window.location.hash) window.location.hash = '#/dashboard'
}
export const nav = {
get current() { return _route },
}
+7
View File
@@ -0,0 +1,7 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, { target: document.getElementById('app') })
export default app
+62
View File
@@ -0,0 +1,62 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { channels, messages } from '../lib/data.js'
const fromTone = {
ok: 'text-ok', warn: 'text-warn', alert: 'text-alert',
flare: 'text-flare', term: 'text-term',
}
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Uplink"><Stat label="fleet command" value="ENCRYPTED" tone="text-ok" sub="Q-band · 0% loss" /></Panel>
<Panel title="Active Channels"><Stat label="monitored" value={channels.length} sub="1 degraded" /></Panel>
<Panel title="Signal Latency"><Stat label="to relay" value={418} unit="ms" tone="text-warn" sub="lightspeed lag" /></Panel>
<Panel title="EMCON"><Stat label="emissions" value="DELTA" tone="text-flare" sub="restricted transmit" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Panel title="Channels" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Channel</th>
<th class="pb-2 font-normal">Band</th>
<th class="pb-2 text-right font-normal">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each channels as c}
<tr>
<td class="py-2 text-term">{c.name}</td>
<td class="py-2 tabular-nums text-haze">{c.band}</td>
<td class="py-2 text-right"><Tag tone={c.tone}>{c.status}</Tag></td>
</tr>
{/each}
</tbody>
</table>
<div class="mt-4 space-y-3">
<Bar label="Antenna alignment" pct={97} tone="bg-ok" />
<Bar label="Relay-19 link" pct={43} tone="bg-warn" />
</div>
</Panel>
<Panel title="Message Traffic" accent="text-flare">
<ul class="space-y-2">
{#each messages as m}
<li class="border border-haze/20 bg-void/40 px-3 py-2">
<div class="flex items-center justify-between text-[0.6rem] uppercase tracking-wider">
<span class="{fromTone[m.tone] ?? 'text-term'} glow-sm">{m.from}</span>
<span class="tabular-nums text-haze">{m.t}</span>
</div>
<p class="mt-1 text-xs leading-snug text-haze/90">{m.text}</p>
</li>
{/each}
</ul>
</Panel>
</div>
</div>
@@ -0,0 +1,61 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { systemStatus, subsystemLoad, logEntries } from '../lib/data.js'
const output = useJitter(84.2, { amp: 0.6, decimals: 1 })
const velocity = useJitter(0.21, { amp: 0.004, decimals: 3, every: 1500 })
const hull = useJitter(98.4, { amp: 0.05, decimals: 2, every: 3000 })
const shields = useJitter(91.7, { amp: 0.8, decimals: 1 })
// Literal classes so Tailwind keeps them (no dynamic interpolation).
const dot = {
term: 'text-term', ok: 'text-ok', warn: 'text-warn',
alert: 'text-alert', flare: 'text-flare',
}
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Velocity"><Stat label="relative" value={velocity.value} unit="c" sub="sublight cruise" /></Panel>
<Panel title="Hull Integrity"><Stat label="structural" value={hull.value} unit="%" tone="text-ok" sub="no active breaches" /></Panel>
<Panel title="Shield Matrix"><Stat label="forward arc" value={shields.value} unit="%" sub="harmonics stable" /></Panel>
<Panel title="Reactor Output"><Stat label="rated" value={output.value} unit="%" tone="text-warn" sub="core nominal" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Systems Status" accent="text-term">
<ul class="divide-y divide-haze/15">
{#each systemStatus as s}
<li class="flex items-center justify-between py-2 text-sm">
<span class="text-haze">{s.name}</span>
<Tag tone={s.tone}>{s.state}</Tag>
</li>
{/each}
</ul>
</Panel>
<Panel title="Subsystem Load" accent="text-term">
<div class="space-y-3">
{#each subsystemLoad as b}
<Bar label={b.label} pct={b.pct} tone={b.tone} />
{/each}
</div>
</Panel>
<Panel title="Event Log" accent="text-flare">
<ul class="space-y-2 text-xs">
{#each logEntries as e}
<li class="flex gap-2">
<span class="shrink-0 tabular-nums text-haze">{e.t}</span>
<span class="shrink-0 {dot[e.tone] ?? 'text-term'}">▍</span>
<span class="text-haze/90 leading-snug">{e.msg}</span>
</li>
{/each}
</ul>
</Panel>
</div>
</div>
@@ -0,0 +1,60 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { atmosphere, decks, crew } from '../lib/data.js'
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Crew Aboard"><Stat label="souls" value={crew.total} sub={crew.onDuty + ' on active duty'} /></Panel>
<Panel title="In Cryo"><Stat label="long-haul" value={crew.cryo} tone="text-term" sub="pods nominal" /></Panel>
<Panel title="Medical"><Stat label="infirmary" value={crew.medical} tone="text-warn" sub="under observation" /></Panel>
<Panel title="Atmosphere"><Stat label="overall" value="STABLE" tone="text-ok" sub="recyclers nominal" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Atmospheric Mix" accent="text-term">
<div class="space-y-3 lg:col-span-2">
{#each atmosphere as a}
<Bar label={a.label} pct={a.pct} tone={a.tone} value={a.value} unit={' ' + a.unit} />
{/each}
</div>
</Panel>
<Panel title="Deck Environment" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Deck</th>
<th class="pb-2 font-normal">O₂</th>
<th class="pb-2 text-right font-normal">Temp</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each decks as d}
<tr>
<td class="py-2 text-term">{d.deck}</td>
<td class="py-2"><Tag tone={d.tone}>{d.o2}</Tag></td>
<td class="py-2 text-right tabular-nums text-haze">{d.temp}</td>
</tr>
{/each}
</tbody>
</table>
</Panel>
<Panel title="Recyclers & Reserves" accent="text-flare">
<div class="space-y-3">
<Bar label="CO₂ scrubbers" pct={88} tone="bg-ok" />
<Bar label="O₂ generation" pct={82} tone="bg-ok" />
<Bar label="Water recovery" pct={68} tone="bg-term" />
<Bar label="Waste reclaim" pct={59} tone="bg-term" />
<div class="mt-3 flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze uppercase tracking-wider">Emergency O₂</span>
<Tag tone="ok">72 hrs</Tag>
</div>
</div>
</Panel>
</div>
</div>
@@ -0,0 +1,80 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import Radar from '../lib/Radar.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { navState, waypoints, contacts } from '../lib/data.js'
const heading = useJitter(navState.heading, { amp: 0.3, decimals: 1, every: 1500 })
const velocity = useJitter(navState.velocity, { amp: 0.004, decimals: 3, every: 1500 })
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Heading"><Stat label="bearing" value={heading.value} unit="°" sub="auto-helm engaged" /></Panel>
<Panel title="Velocity"><Stat label="relative" value={velocity.value} unit="c" sub="sublight" /></Panel>
<Panel title="Current System"><Stat label="position" value={navState.system} tone="text-term" sub={navState.sector} /></Panel>
<Panel title="Destination"><Stat label="next waypoint" value={navState.destination} tone="text-flare" sub={'ETA ' + navState.eta} /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Tactical Plot" accent="text-term">
<Radar blips={contacts} />
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-[0.65rem] text-haze">
{#each contacts as c}
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full {c.tone} glow"></span>
<span class="truncate">{c.label}</span>
</div>
{/each}
</div>
</Panel>
<Panel title="Flight Plan" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Waypoint</th>
<th class="pb-2 font-normal">Range</th>
<th class="pb-2 text-right font-normal">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each waypoints as w}
<tr>
<td class="py-2 text-term">{w.name}</td>
<td class="py-2 tabular-nums text-haze">{w.dist}</td>
<td class="py-2 text-right"><Tag tone={w.tone}>{w.status}</Tag></td>
</tr>
{/each}
</tbody>
</table>
</Panel>
<Panel title="FTL Drive" accent="text-flare">
<div class="space-y-4">
<Bar label="Jump capacitor" pct={navState.ftlCharge} tone="bg-warn" />
<div class="grid grid-cols-3 gap-2 text-center text-xs">
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">X</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.x}</div>
</div>
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">Y</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.y}</div>
</div>
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">Z</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.z}</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze uppercase tracking-wider">Jump readiness</span>
<Tag tone="warn">CHARGING</Tag>
</div>
</div>
</Panel>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { reactor, powerDistribution, fuelCells } from '../lib/data.js'
const output = useJitter(reactor.output, { amp: 0.7, decimals: 1 })
const temp = useJitter(reactor.coreTemp, { amp: 18, decimals: 0, every: 1500 })
const rpm = useJitter(reactor.rpm, { amp: 40, decimals: 0, every: 1200 })
const cont = useJitter(reactor.containment, { amp: 0.15, decimals: 1, every: 2500 })
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Core Output"><Stat label="rated power" value={output.value} unit="%" tone="text-warn" sub="84% of 2.4 TW" /></Panel>
<Panel title="Core Temp"><Stat label="plasma" value={temp.value} unit="K" tone="text-flare" sub="within tolerance" /></Panel>
<Panel title="Containment"><Stat label="field strength" value={cont.value} unit="%" tone="text-ok" sub="magnetic bottle stable" /></Panel>
<Panel title="Turbine"><Stat label="primary spindle" value={rpm.value} unit="rpm" sub="no vibration faults" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Power Distribution" accent="text-term">
<div class="space-y-3 lg:col-span-2">
{#each powerDistribution as p}
<Bar label={p.label} pct={p.pct} tone={p.tone} />
{/each}
</div>
</Panel>
<Panel title="Fuel Cells" accent="text-term">
<div class="space-y-3">
{#each fuelCells as c}
<Bar label={c.id} pct={c.charge} tone={c.tone} />
{/each}
<div class="mt-3 flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze">Deuterium reserve</span>
<Tag tone="ok">77% / 14 days</Tag>
</div>
</div>
</Panel>
<Panel title="Reactor Controls" accent="text-flare">
<div class="space-y-2">
{#each [
{ l: 'Auto-throttle', v: 'ENGAGED', t: 'ok' },
{ l: 'SCRAM arming', v: 'SAFE', t: 'term' },
{ l: 'Coolant loop A', v: 'FLOW 100%', t: 'ok' },
{ l: 'Coolant loop B', v: 'STANDBY', t: 'term' },
{ l: 'Plasma injectors', v: '6 / 6 ONLINE', t: 'ok' },
{ l: 'Overload damper', v: 'ARMED', t: 'warn' },
] as r}
<div class="flex items-center justify-between border border-haze/20 bg-void/40 px-3 py-2 text-xs">
<span class="text-haze uppercase tracking-wider">{r.l}</span>
<Tag tone={r.t}>{r.v}</Tag>
</div>
{/each}
</div>
</Panel>
</div>
</div>
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
// The kiosk loads the app at http://boot.fleet.lab/kiosk/ (the main serving
// nginx reverse-proxies /kiosk/ -> this container). base must match that path
// so emitted asset URLs are absolute under /kiosk/. Hash-based routing is used
// (see src/lib/router.svelte.js) so deep links never hit the server — no SPA
// fallback rewrite is needed through the proxy.
export default defineConfig({
base: '/kiosk/',
plugins: [svelte(), tailwindcss()],
build: {
target: 'es2022',
// Fully self-contained bundle (no CDN/fonts) so it renders on the
// air-gapped boot LAN — the dnsmasq sinkhole blocks external call-home.
assetsInlineLimit: 4096,
},
})
+1 -1
View File
@@ -6,7 +6,7 @@
# (~10-20 min — it pulls submodules incl. DPDK and compiles them), but it's # (~10-20 min — it pulls submodules incl. DPDK and compiles them), but it's
# self-contained, reproducible, and cached thereafter. Bump SPDK_VERSION to a # self-contained, reproducible, and cached thereafter. Bump SPDK_VERSION to a
# tag from https://github.com/spdk/spdk/releases. # tag from https://github.com/spdk/spdk/releases.
FROM ubuntu:24.04 FROM harbor.bitasylum.org/dockerhub/library/ubuntu:24.04
ARG SPDK_VERSION=v24.09 ARG SPDK_VERSION=v24.09
ARG SPDK_REPO=https://github.com/spdk/spdk ARG SPDK_REPO=https://github.com/spdk/spdk