Add app
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
reports
|
||||||
@@ -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
|
||||||
|
|||||||
+19
-3
@@ -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",
|
"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)'",
|
||||||
]
|
]
|
||||||
|
|||||||
+87
-9
@@ -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`
|
## [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 +74,28 @@ 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.
|
||||||
|
|
||||||
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 — 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)
|
### 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
|
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 +187,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 +487,33 @@ 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,
|
||||||
|
remote audit offload, per-host/rotatable admin (SSH CA / config-agent).
|
||||||
|
|||||||
@@ -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. |
|
| 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`) | 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ 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]}")
|
||||||
|
|
||||||
|
# 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** "
|
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")
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Received logs and rsyslog spool are runtime state, not source.
|
||||||
|
logs/*
|
||||||
|
spool/*
|
||||||
|
!logs/.gitkeep
|
||||||
|
!spool/.gitkeep
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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")
|
||||||
@@ -63,6 +63,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
|
||||||
@@ -94,6 +97,8 @@ services:
|
|||||||
image: nginx:stable
|
image: 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 +107,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 +138,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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 — 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">…</span>
|
|
||||||
<span class="k">page url</span><span id="href">…</span>
|
|
||||||
<span class="k">user agent</span><span id="ua">…</span>
|
|
||||||
<span class="k">screen</span><span id="screen">…</span>
|
|
||||||
</div>
|
|
||||||
<footer>boot HTTP server · 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>
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
.git
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.local
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2075
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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())}`
|
||||||
|
}
|
||||||
@@ -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.' },
|
||||||
|
]
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user