This commit is contained in:
Audiobuzz
2026-06-18 12:03:45 +00:00
parent ada4aa4b18
commit 3b776dd970
47 changed files with 3416 additions and 110 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
# works across environments — the serving stack's dnsmasq resolves it.
NVMET_TRADDR: "192.168.50.10"
# URL the fullscreen Chromium kiosk loads at boot. Defaults to the test page
# served by the boot HTTP server (serving/http/kiosk) at /kiosk/; boot.fleet.lab
# resolves to the serving host via the stack's dnsmasq. Empty disables the kiosk.
# URL the fullscreen Chromium kiosk loads at boot. Defaults to the kiosk SPA
# (serving/kiosk-app) reverse-proxied by the boot HTTP server at /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/"
# 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
+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/build-oci.sh ukify + sbsign + roothash gate + OCI image
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
docs/ Design PDF and supporting notes
```
@@ -152,9 +154,17 @@ instead of working around it.
build; the supplicant config carries both options.
- erofs module availability on the target RHEL kernel (fallback:
journal-less ext4 in `extract-artifacts.sh`).
- STIG remediation: (a) verify the SSG datastream name on the target distro
(`ssg-rhel9-ds.xml` vs `ssg-rl9-ds.xml` — the provisioner tries both) and that
the `...content_profile_stig` profile id exists; (b) remediation must not break
- STIG remediation: (a) datastream **must match the distro**. The provisioner
maps `/etc/os-release` `$ID` → datastream (Rocky→`ssg-rl9-ds.xml`,
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`
is the escape hatch; (c) **reproducibility**: build twice and confirm identical
`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
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
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-veritysetup (verified: "unknown option hash-offset=" → "Failed to load
verity superblock"). Resolved by the documented fallback — data and hash are
+19 -3
View File
@@ -440,15 +440,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",
"echo '==> Installing OpenSCAP + SCAP Security Guide'",
"dnf install -y openscap-scanner scap-security-guide",
# SSG ships rhel9 content (usable on Rocky); some rebuilds name it rl9.
"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)",
"[ -n \"$DS\" ] || { echo 'ERROR: SSG rhel9 datastream not found' >&2; exit 1; }",
# Pick the datastream that MATCHES THIS DISTRO. The RHEL9 datastream's CPE
# dictionary has no Rocky entry, so running it on Rocky makes EVERY rule
# '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",
"echo \"==> Remediating against tailored STIG profile using $DS (this changes a lot)\"",
# --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",
# 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",
# 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",
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
]
+87 -9
View File
@@ -5,6 +5,24 @@ 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.
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`
**DONE — test path VERIFIED ON THE BENCH 2026-06-17** (host stays up past the
@@ -56,11 +74,28 @@ in **[stig-deviations.md](stig-deviations.md)** (curated register) and reported
per-build by **[../scripts/stig-deviations.sh](../scripts/stig-deviations.sh)**
(non-pass rules tagged intentional vs REVIEW); CI publishes
`stig-report.html` + `stig-arf.xml` + `stig-deviations.md`. Scanner tooling is
removed from the shipped image. **BENCH-VERIFY:** SSG datastream name; boot/login
still work; reproducible verity hash across two builds; triage REVIEW rows.
removed from the shipped image. **BENCH-VERIFY:** boot/login still work;
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.
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 — receiver
DONE** (`serving/audit-collector/`, rsyslog over TCP/514, per-source-host files;
DNS name `${AUDIT_HOST}`); the **image-side sender** (audisp-syslog + rsyslog
forward to `audit.fleet.lab`) is still to wire — see that dir's README.
### Original spec (retained for reference)
@@ -116,7 +151,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
(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
the configured URL). Build-flagged OFF by default for now (`enable_kiosk=false`)
@@ -147,11 +187,19 @@ Path to getting here (don't re-discover):
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) too; add `-var enable_kiosk=true` to the Gitea workflow to ship it.
- `kiosk_url` Packer var (default `http://boot.fleet.lab/kiosk/` — the
self-contained test page served by the boot HTTP server at
`serving/http/kiosk/`, via the `/kiosk/` nginx location; validates a boot
without external connectivity. Empty disables the whole install). Wired in CI
as `KIOSK_URL` env.
- `kiosk_url` Packer var (default `http://boot.fleet.lab/kiosk/` — the kiosk
**SPA** (`serving/kiosk-app/`, Svelte 5 + Tailwind v4, a cyber sci-fi
spaceship monitoring/control terminal) served by the boot HTTP server, which
**reverse-proxies** `/kiosk/` to the `kiosk` container; self-contained so a
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
mesa-libEGL mesa-libgbm dejavu-sans-fonts`, creates a passwordless
unprivileged `kiosk` user (video/input groups).
@@ -439,3 +487,33 @@ export is read-only + single-namespace = inherently small attack surface.
— 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
(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,
remote audit offload, per-host/rotatable admin (SSH CA / config-agent).
+1 -1
View File
@@ -26,7 +26,7 @@ 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. |
| 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. |
| 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`) | Decision: local-only for now; image not yet forwarding. | auditd enabled and recording locally. **Receiver now deployed** — `serving/audit-collector/` (rsyslog over TCP/514, per-source-host files, DNS name `${AUDIT_HOST}`). **Remaining hardening** — wire the image-side sender (audisp-syslog + rsyslog forward to the host-generic `audit.fleet.lab`, no per-host state); then this row's `rsyslog_remote_loghost` should 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. |
| 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. |
+18 -2
View File
@@ -71,7 +71,20 @@ for k in ("pass", "fixed", "fail", "error", "unknown", "notchecked",
"notapplicable", "notselected", "informational"):
if counts.get(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")
if findings:
@@ -80,8 +93,11 @@ if findings:
print("|---|---|---|---|---|")
for sev, res, rid, title, tag in findings:
print(f"| {sev} | {res} | `{short(rid)}` | {tag} | {title} |")
else:
elif evaluated > 0:
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
[ "$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_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 -------------------------------------------------------------
BOOT_DOMAIN=fleet.lab
BOOT_HOST=boot.fleet.lab # HTTP boot server name (UKI)
# 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.
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 -------------------------------------------------------
SUBNQN=nqn.2026-06.org.example.fleet:rootfs
+7 -1
View File
@@ -58,7 +58,8 @@ docker compose ps
# HTTP: UKI is served
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
# 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
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
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 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")
+35 -3
View File
@@ -63,6 +63,9 @@ services:
# resolve the per-environment DNS name to this serving host.
- --address=/${NVME_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
# 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
@@ -94,6 +97,8 @@ services:
image: nginx:stable
network_mode: host # listens on :80 -> http://${SERVER_IP}/fleet-<ver>.efi
restart: unless-stopped
depends_on:
- kiosk # /kiosk/ is reverse-proxied to the kiosk SPA
volumes:
- type: image
source: ${OCI_IMAGE}:${IMAGE_VERSION}
@@ -102,13 +107,23 @@ services:
subpath: artifacts # mount only /artifacts from the image
# Fallback (Docker < 28 / offline): bind the local Packer output dir:
# - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro
# Serve the kiosk test page at /kiosk/ alongside the artifacts. Config
# adds the location; the page is lab content (NOT in the artifact image).
# Config adds the artifact root + the /kiosk/ reverse-proxy location. The
# kiosk SPA itself is the separate 'kiosk' service below (not bind-mounted).
- ./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
# 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:
build: ./chrony
@@ -123,6 +138,23 @@ services:
# 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.
# --- 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) ----------------------
nvmeof:
# 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
# :80 serves BOTH:
# Lab boot HTTP server (nginx). One server on :80 serves BOTH:
# / -> the OCI release artifacts (UKI fleet-<ver>.efi, etc.), mounted
# read-only from the image volume at /usr/share/nginx/html
# /kiosk/ -> the diskless kiosk test page (lab content bind-mounted at
# /usr/share/nginx/kiosk, deliberately NOT part of the artifact
# image, which stays FROM-scratch).
# 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.
# /kiosk/ -> reverse-proxied to the kiosk SPA container (serving/kiosk-app),
# a Svelte spaceship-terminal app. NOT part of the artifact image
# (that stays FROM-scratch); a separate, independently built image.
#
# 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 {
listen 80 default_server;
server_name _;
@@ -17,10 +20,14 @@ server {
autoindex on;
}
# Kiosk test page. The diskless kiosk (kiosk_url) points at
# http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq.
# Kiosk SPA. The diskless kiosk (kiosk_url) points at
# http://boot.fleet.lab/kiosk/ — resolved to this host by the stack's dnsmasq
# and proxied to the kiosk-app container.
location /kiosk/ {
alias /usr/share/nginx/kiosk/;
index index.html;
proxy_pass http://127.0.0.1:8088/;
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 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 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,
},
})