From 7d27d1b5d15bdf7d276b13b44117ae3b663e399e Mon Sep 17 00:00:00 2001 From: Jeremy Anderson Date: Wed, 16 Sep 2026 21:37:02 -0400 Subject: [PATCH] SysDeck 4.4 QA fixes --- Makefile | 103 ++-- QA.md | 213 ++++++++ QUICKSTART.md | 4 +- README.md | 10 +- bridge/__init__.py | 10 +- bridge/auth.py | 39 +- bridge/benchmark.py | 2 +- bridge/builder.py | 76 ++- bridge/db.py | 96 ++-- bridge/fester.py | 12 +- bridge/firewall.py | 43 +- bridge/firmware.py | 2 +- bridge/fleet.py | 7 +- bridge/glances.py | 80 ++- bridge/grafana.py | 9 +- bridge/hwalert.py | 38 +- bridge/integrity.py | 5 + bridge/kata.py | 25 +- bridge/mining.py | 13 +- bridge/modules3p.py | 27 +- bridge/netsec.py | 17 +- bridge/packages.py | 76 ++- bridge/photos.py | 6 +- bridge/policy.py | 18 +- bridge/prometheus.py | 58 +- bridge/remotefs.py | 82 +-- bridge/themes.py | 21 +- bridge/vault.py | 2 +- compat/compat-manifest.json | 4 +- docs/INSTALL.md | 2 +- firewall/policies/cilium-default.yaml | 52 +- firewall/templates/ai-llm.sh | 15 +- firewall/templates/cilium.sh | 5 +- firewall/templates/no-services.sh | 60 ++- firewall/templates/public-webserver.sh | 15 +- firewall/templates/remote-admin.sh | 15 +- firewall/templates/sysdeck-fw.sh | 39 +- firewall/templates/vps-webserver.sh | 18 +- packaging/PKGBUILD | 34 +- packaging/debian/changelog | 90 +++- packaging/debian/control | 8 +- packaging/setup.py | 2 +- packaging/sysdeck.install | 17 + packaging/sysdeck.metainfo.xml | 19 +- packaging/sysdeck.spec | 87 +-- plugins/sysdeck-auth/index.html | 0 plugins/sysdeck-auth/manifest.json | 0 plugins/sysdeck-benchmark/index.html | 0 plugins/sysdeck-benchmark/manifest.json | 0 plugins/sysdeck-builder/builder.js | 28 +- plugins/sysdeck-builder/index.html | 0 plugins/sysdeck-builder/manifest.json | 0 plugins/sysdeck-containers/containers.js | 23 +- plugins/sysdeck-containers/index.html | 0 plugins/sysdeck-containers/manifest.json | 0 plugins/sysdeck-db/db.js | 2 +- plugins/sysdeck-db/index.html | 0 plugins/sysdeck-db/manifest.json | 0 plugins/sysdeck-fester/fester.js | 10 +- plugins/sysdeck-fester/index.html | 0 plugins/sysdeck-fester/manifest.json | 0 plugins/sysdeck-firewall/firewall.js | 6 +- plugins/sysdeck-firewall/index.html | 0 plugins/sysdeck-firewall/manifest.json | 0 plugins/sysdeck-firmware/index.html | 0 plugins/sysdeck-firmware/manifest.json | 0 plugins/sysdeck-fleet/index.html | 0 plugins/sysdeck-fleet/manifest.json | 0 plugins/sysdeck-glances/index.html | 0 plugins/sysdeck-glances/manifest.json | 0 plugins/sysdeck-integrity/index.html | 0 plugins/sysdeck-integrity/manifest.json | 0 plugins/sysdeck-jellyfin/index.html | 0 plugins/sysdeck-jellyfin/jellyfin.js | 6 +- plugins/sysdeck-jellyfin/manifest.json | 0 plugins/sysdeck-kata/index.html | 0 plugins/sysdeck-kata/kata.js | 0 plugins/sysdeck-kata/manifest.json | 0 plugins/sysdeck-klanker/klanker.js | 10 +- plugins/sysdeck-mesh/index.html | 0 plugins/sysdeck-mesh/manifest.json | 0 plugins/sysdeck-mining/index.html | 0 plugins/sysdeck-mining/manifest.json | 0 plugins/sysdeck-modules/index.html | 2 +- plugins/sysdeck-modules/modules.js | 9 +- plugins/sysdeck-monitoring/index.html | 4 +- plugins/sysdeck-monitoring/manifest.json | 0 plugins/sysdeck-netsec/index.html | 0 plugins/sysdeck-netsec/manifest.json | 0 plugins/sysdeck-netsec/netsec.js | 2 +- plugins/sysdeck-packages/index.html | 0 plugins/sysdeck-packages/manifest.json | 0 plugins/sysdeck-photos/index.html | 0 plugins/sysdeck-photos/manifest.json | 0 plugins/sysdeck-photos/photos.js | 2 +- plugins/sysdeck-policy/index.html | 0 plugins/sysdeck-policy/manifest.json | 0 plugins/sysdeck-remotefs/index.html | 0 plugins/sysdeck-remotefs/manifest.json | 0 plugins/sysdeck-remotefs/remotefs.js | 2 +- plugins/sysdeck-sensors/index.html | 0 plugins/sysdeck-sensors/manifest.json | 0 plugins/sysdeck-themes/index.html | 0 plugins/sysdeck-themes/manifest.json | 0 plugins/sysdeck-vault/index.html | 0 plugins/sysdeck-vault/manifest.json | 0 scripts/generate-plugins.py | 502 +++--------------- shared/branding.css | 2 +- shared/bridge.js | 13 +- shared/manifest.json | 2 +- shared/sysdeck-web.css | 8 +- shared/sysdeck.css | 48 +- tests/test_bridge_parsers.py | 4 +- web/Caddyfile | 12 +- web/README.md | 20 +- web/eslint.config.mjs | 5 + web/next.config.ts | 8 +- web/package.json | 2 +- web/scripts/manage-users.mjs | 8 +- web/src/app/api/bridge/route.ts | 2 +- web/src/app/api/release/route.ts | 8 +- web/src/app/globals.css | 19 +- web/src/app/layout.tsx | 2 - .../components/sysdeck/panels/authPanel.tsx | 2 +- .../sysdeck/panels/cockpitModulesPanel.tsx | 32 +- .../sysdeck/panels/containersPanel.tsx | 23 +- .../components/sysdeck/panels/festerPanel.tsx | 26 +- .../sysdeck/panels/hwalertPanel.tsx | 2 +- .../components/sysdeck/panels/meshPanel.tsx | 4 +- .../sysdeck/panels/overviewPanel.tsx | 11 +- .../sysdeck/panels/runbookPanel.tsx | 5 +- .../components/sysdeck/panels/themesPanel.tsx | 17 +- web/src/components/sysdeck/shell.tsx | 19 +- web/src/components/sysdeck/ui.tsx | 51 +- web/src/lib/sysdeck/bridge/auth.ts | 1 - web/src/lib/sysdeck/bridge/hwalert.ts | 2 +- web/src/lib/sysdeck/bridge/jellyfin.ts | 4 +- web/src/lib/sysdeck/bridge/kata.ts | 6 +- web/src/lib/sysdeck/bridge/modules.ts | 2 +- web/src/lib/sysdeck/bridge/monitoring.ts | 2 +- web/src/lib/sysdeck/bridge/overview.ts | 31 +- web/src/lib/sysdeck/bridge/packages.ts | 8 - web/src/lib/sysdeck/bridge/remotefs.ts | 1 - web/src/lib/sysdeck/registry.ts | 2 +- worklog.md | 23 + 145 files changed, 1465 insertions(+), 1156 deletions(-) create mode 100644 packaging/sysdeck.install mode change 100755 => 100644 plugins/sysdeck-auth/index.html mode change 100755 => 100644 plugins/sysdeck-auth/manifest.json mode change 100755 => 100644 plugins/sysdeck-benchmark/index.html mode change 100755 => 100644 plugins/sysdeck-benchmark/manifest.json mode change 100755 => 100644 plugins/sysdeck-builder/index.html mode change 100755 => 100644 plugins/sysdeck-builder/manifest.json mode change 100755 => 100644 plugins/sysdeck-containers/index.html mode change 100755 => 100644 plugins/sysdeck-containers/manifest.json mode change 100755 => 100644 plugins/sysdeck-db/index.html mode change 100755 => 100644 plugins/sysdeck-db/manifest.json mode change 100755 => 100644 plugins/sysdeck-fester/index.html mode change 100755 => 100644 plugins/sysdeck-fester/manifest.json mode change 100755 => 100644 plugins/sysdeck-firewall/index.html mode change 100755 => 100644 plugins/sysdeck-firewall/manifest.json mode change 100755 => 100644 plugins/sysdeck-firmware/index.html mode change 100755 => 100644 plugins/sysdeck-firmware/manifest.json mode change 100755 => 100644 plugins/sysdeck-fleet/index.html mode change 100755 => 100644 plugins/sysdeck-fleet/manifest.json mode change 100755 => 100644 plugins/sysdeck-glances/index.html mode change 100755 => 100644 plugins/sysdeck-glances/manifest.json mode change 100755 => 100644 plugins/sysdeck-integrity/index.html mode change 100755 => 100644 plugins/sysdeck-integrity/manifest.json mode change 100755 => 100644 plugins/sysdeck-jellyfin/index.html mode change 100755 => 100644 plugins/sysdeck-jellyfin/manifest.json mode change 100755 => 100644 plugins/sysdeck-kata/index.html mode change 100755 => 100644 plugins/sysdeck-kata/kata.js mode change 100755 => 100644 plugins/sysdeck-kata/manifest.json mode change 100755 => 100644 plugins/sysdeck-mesh/index.html mode change 100755 => 100644 plugins/sysdeck-mesh/manifest.json mode change 100755 => 100644 plugins/sysdeck-mining/index.html mode change 100755 => 100644 plugins/sysdeck-mining/manifest.json mode change 100755 => 100644 plugins/sysdeck-monitoring/index.html mode change 100755 => 100644 plugins/sysdeck-monitoring/manifest.json mode change 100755 => 100644 plugins/sysdeck-netsec/index.html mode change 100755 => 100644 plugins/sysdeck-netsec/manifest.json mode change 100755 => 100644 plugins/sysdeck-packages/index.html mode change 100755 => 100644 plugins/sysdeck-packages/manifest.json mode change 100755 => 100644 plugins/sysdeck-photos/index.html mode change 100755 => 100644 plugins/sysdeck-photos/manifest.json mode change 100755 => 100644 plugins/sysdeck-policy/index.html mode change 100755 => 100644 plugins/sysdeck-policy/manifest.json mode change 100755 => 100644 plugins/sysdeck-remotefs/index.html mode change 100755 => 100644 plugins/sysdeck-remotefs/manifest.json mode change 100755 => 100644 plugins/sysdeck-sensors/index.html mode change 100755 => 100644 plugins/sysdeck-sensors/manifest.json mode change 100755 => 100644 plugins/sysdeck-themes/index.html mode change 100755 => 100644 plugins/sysdeck-themes/manifest.json mode change 100755 => 100644 plugins/sysdeck-vault/index.html mode change 100755 => 100644 plugins/sysdeck-vault/manifest.json mode change 100755 => 100644 shared/bridge.js mode change 100755 => 100644 shared/manifest.json mode change 100755 => 100644 shared/sysdeck.css diff --git a/Makefile b/Makefile index e53c641..b12b747 100755 --- a/Makefile +++ b/Makefile @@ -14,11 +14,11 @@ # /usr/lib/sysdeck/bridge/ (called via cockpit.spawn). # # Targets: -# make install - install all 26 plugins + bridge + scripts + firewall templates -# make uninstall - remove all 26 plugins + bridge + scripts +# make install - install all 27 plugins + bridge + scripts + firewall templates +# make uninstall - remove all 27 plugins + bridge + scripts # make check - validate all manifests against cockpit-podman pattern, # syntax-check JS/Python/shell sources, run unit tests -# make plugins - regenerate plugins/ and shared/ from scripts/generate-plugins.py +# make plugins - verify plugins/ + shared/ match the generator catalog # make clean - remove build artifacts # make dist - build the source tarball (runs check first) # make distcheck - extract the tarball into a clean dir and run check inside @@ -30,7 +30,7 @@ # Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS. PACKAGE := sysdeck -VERSION := 0.4.4 +VERSION := 0.4.5 LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE) PYTHON_DIR := $(LIB_DIR)/bridge SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE) @@ -62,14 +62,17 @@ GENERATOR := scripts/generate-plugins.py .PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master install-branding uninstall-branding -# ─── plugins: regenerate from generator ────────────────────────────── +# ─── plugins: verify the tree against the generator catalog ───────── +# The shipped plugins/ and shared/ assets are hand-maintained source; +# the generator VERIFIES catalog <-> disk consistency and never writes. +# (--force exists only to bootstrap a tree with missing plugin dirs.) plugins: - @echo ">>> Regenerating plugins/ and shared/ from $(GENERATOR)" + @echo ">>> Verifying plugins/ and shared/ against the catalog in $(GENERATOR)" python3 $(GENERATOR) -# ─── install: 26 visible plugins + shared/ + bridge + scripts + metainfo ──── +# ─── install: 27 visible plugins + shared/ + bridge + scripts + metainfo ──── install: - @echo ">>> Installing $(PACKAGE) $(VERSION): 26 standalone Cockpit plugins" + @echo ">>> Installing $(PACKAGE) $(VERSION): 27 standalone Cockpit plugins" # Each plugin: /usr/share/cockpit/sysdeck-/{manifest.json,index.html,.js} @for plugin in plugins/sysdeck-*; do \ [ -d "$$plugin" ] || continue; \ @@ -166,16 +169,20 @@ install: install -m 0644 $(POLKIT_FILE) $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy # v0.0.46: 3rd-party-modules polkit action (org.sysdeck.modules3p.modify) install -m 0644 packaging/polkit/org.sysdeck.modules3p.policy $(DESTDIR)/usr/share/polkit-1/actions/ - # Reload polkit + refresh AppStream cache. - -@if command -v systemctl >/dev/null 2>&1; then \ - systemctl reload polkit 2>/dev/null || true; \ - fi - -@if command -v appstreamcli >/dev/null 2>&1; then \ - appstreamcli refresh-cache 2>/dev/null || true; \ + # Host integration (polkit reload, AppStream refresh) runs only + # on a real install — a DESTDIR staging install (deb/rpm/pacman + # package build) must never mutate the build host. + -@if [ -z "$(DESTDIR)" ]; then \ + if command -v systemctl >/dev/null 2>&1; then \ + systemctl reload polkit 2>/dev/null || true; \ + fi; \ + if command -v appstreamcli >/dev/null 2>&1; then \ + appstreamcli refresh-cache 2>/dev/null || true; \ + fi; \ fi @echo ">>> Done. Restart cockpit.socket to pick up the new plugins:" @echo " sudo systemctl restart cockpit.socket" - @echo ">>> 26 sidebar entries should appear under 'SysDeck ' in Cockpit." + @echo ">>> 27 sidebar entries should appear under 'SysDeck ' in Cockpit." # ─── uninstall: remove EVERY trace of EVERY prior version ────────── # This target is deliberately over-aggressive. It removes: @@ -212,17 +219,17 @@ uninstall: rm -rf "$$dir"; \ done # Python bridge helpers (all versions) - rm -rf $(LIB_DIR) + rm -rf "$(LIB_DIR)" # Diagnostic + smoke-test scripts + firewall templates (v0.0.19+) # v0.0.31: firewall/templates/*.sh live under here too. - rm -rf $(DESTDIR)/usr/share/$(PACKAGE) + rm -rf "$(DESTDIR)/usr/share/$(PACKAGE)" # Documentation (v0.0.20+) - rm -rf $(DESTDIR)/usr/share/doc/$(PACKAGE) + rm -rf "$(DESTDIR)/usr/share/doc/$(PACKAGE)" # AppStream metainfo (v0.0.17+) - rm -f $(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml + rm -f "$(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml" # PolKit policy (v0.0.17+) - rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy - rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy + rm -f "$(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy" + rm -f "$(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy" # Python site-packages symlink (all versions) @SITE_PACKAGES=$$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null); \ if [ -n "$$SITE_PACKAGES" ] && [ -L "$(DESTDIR)$$SITE_PACKAGES/$(PACKAGE)" ]; then \ @@ -255,7 +262,7 @@ check-metainfo-consistency: # - menu keys other than `index` (the magic key) # - `path` field inside menu entries check-manifest-consistency: - @echo ">>> Checking all 25 plugin manifests against cockpit-podman reference" + @echo ">>> Checking all 27 plugin manifests against cockpit-podman reference" @python3 tests/check_manifest_consistency.py check-makefile-recipes: @@ -323,6 +330,23 @@ check-no-broken-python-module: fi echo " OK: no JS file uses the broken python3 -m sysdeck.bridge pattern" +# check-release-tree: supply-chain gate from docs/SECURITY-HARDENING.md — +# a release tarball builds from a clean tree, never from a working copy +# with uncommitted edits (the 2019 Webmin build-host compromise class). +# Steps down by environment: git tree → git status must be clean; +# plain tarball tree → skip (nothing to verify against). +check-release-tree: + @if [ -d .git ]; then \ + if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then \ + echo "FAIL: working tree has uncommitted changes — commit or stash before building a release."; \ + git status --porcelain | sed 's/^/ /'; \ + exit 1; \ + fi; \ + echo " OK: git working tree is clean"; \ + else \ + echo " OK: not a git tree (tarball build) — nothing to verify"; \ + fi + check-version-sync: @echo ">>> Checking version consistency across release surfaces" @v=$(VERSION); \ @@ -341,7 +365,7 @@ check-version-sync: done; \ echo " OK: all release surfaces report v$$v" -check: check-metainfo-consistency check-manifest-consistency check-makefile-recipes check-no-broken-cockpit-import check-no-broken-python-module check-bridge-subcommands check-version-sync +check: check-metainfo-consistency check-manifest-consistency check-makefile-recipes check-no-broken-cockpit-import check-no-broken-python-module check-bridge-subcommands check-version-sync check-release-tree @echo ">>> Syntax-checking Python sources" @python3 -m py_compile bridge/*.py bridge/modules/*.py @echo ">>> Syntax-checking JS sources (node --check)" @@ -367,7 +391,10 @@ clean: dist: check @echo ">>> Building $(PACKAGE)-$(VERSION).tar.bz2" find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \ + LC_ALL=C tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \ + --sort=name \ + --mtime="@$(SOURCE_DATE_EPOCH)" \ + --owner=0 --group=0 --numeric-owner \ --exclude='__pycache__' \ --exclude='*.pyc' \ --exclude='*.tar.bz2' \ @@ -378,21 +405,25 @@ dist: check sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh @echo ">>> $(PACKAGE)-$(VERSION).tar.bz2 ready" +# SOURCE_DATE_EPOCH: pin the tarball mtime for reproducible builds. +# Release builds set it explicitly (e.g. `make dist SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)`); +# unpacked trees without git fall back to a fixed epoch. +SOURCE_DATE_EPOCH ?= 0 + # distcheck: verify the tarball extracts into -/ and # passes `make check` from inside the extracted tree. distcheck: dist @echo ">>> Distcheck: extracting $(PACKAGE)-$(VERSION).tar.bz2" - rm -rf /tmp/sysdeck-distcheck-$$ - mkdir -p /tmp/sysdeck-distcheck-$$ - tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C /tmp/sysdeck-distcheck-$$ - @if [ ! -d /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) ]; then \ + DISTCHECK_DIR=$$(mktemp -d /tmp/sysdeck-distcheck.XXXXXX) + tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C $$DISTCHECK_DIR + @if [ ! -d $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) ]; then \ echo "FAIL: tarball did not extract into $(PACKAGE)-$(VERSION)/"; \ - rm -rf /tmp/sysdeck-distcheck-$$; \ + rm -rf $$DISTCHECK_DIR; \ exit 1; \ fi @echo ">>> Distcheck: running make check inside extracted tree" - @(cd /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) && make check) - @rm -rf /tmp/sysdeck-distcheck-$$ + @(cd $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) && make check) + @rm -rf $$DISTCHECK_DIR @echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct." # ─── v0.3.0 master edition: web + fester + klanker-gate ───────────────────────────── @@ -413,6 +444,10 @@ web-install: cd $(FESTER_DIR) && bun install web-dev: web-install + @echo ">>> Starting fester in the background (log: /tmp/fester.log)" + cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 & + @echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)" + cd web && bun run dev # ─── branding: theme the Cockpit SHELL chrome to the web-edition look ──── # /usr/share/cockpit/branding.css is Cockpit's documented override point @@ -442,11 +477,6 @@ uninstall-branding: echo " reinstall the cockpit-bridge package to restore defaults"; \ fi - @echo ">>> Starting fester in the background (log: /tmp/fester.log)" - cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 & - @echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)" - cd web && bun run dev - # master: rebuild the master tarball from this tree (cockpit + web + fester + klanker-gate) master: @echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester + klanker-gate)" @@ -454,6 +484,7 @@ master: --exclude='__pycache__' --exclude='*.pyc' --exclude='*.tar.bz2' \ --exclude='*node_modules*' --exclude='*.next' --exclude='*.tsbuildinfo' \ --exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \ + --exclude='dev.log' --exclude='server.log' --exclude='*.log' --exclude='.env' \ --transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \ bridge plugins shared tests packaging compat standalone-plugins \ prometheus scripts firewall docs web klanker-gate \ diff --git a/QA.md b/QA.md index b9520de..a644e94 100755 --- a/QA.md +++ b/QA.md @@ -2836,3 +2836,216 @@ from a real session — no mocks, per the zero-demo contract. direct SdUser row insert with the same scrypt format. Fix options: rename to `manage-users.ts` or strip the annotations — left as a code change for the next patch release. + +--- + +## v0.4.5 QA — MoE production-hardening pass + +**Reviewer panel:** web designers · backend coders · JavaScript experts +(React/Vue/Next/Node) · Elm discipline (SPA state modeling) · CSS expert · +UI/UX expert · algorithms specialist · Linux systems engineer · DevOps +manager +**Date:** 2026-09-17 +**Scope:** the whole first-party tree — Makefile, bridge (28 helpers), +web console (31 modules + panels), cockpit plugin suite (27 plugins + +shared), firewall templates (7 + Cilium policy), packaging (deb/rpm/pacman/ +AppStream/polkit), Caddyfile, docs. +**Standards applied:** SEI CERT (TS/JS/Python subset) · PEP 8 (spirit) · +POSIX · nftables wiki recipes · AppStream 1.0 · Debian/RPM/pacman +packaging policy. +**Verdict:** ✅ Production-ready. `make check` 267/267, `tsc --noEmit` +clean with build enforcement on, `eslint` clean with +exhaustive-deps + no-unused-vars enabled as errors, six nftables +rulesets structurally validated, all three distro packaging paths +repaired. + +### 0. How this pass ran + +Four parallel expert reviews (backend/security, web frontend, cockpit +plugins, packaging/devops) produced a findings ledger; every HIGH and +blocker finding was fixed in this release, and each fix lands with a +comment stating the standing decision in the present tense. The +klanker-gate/ vendored tree (Apache-2.0, TykoDev) stays untouched — +rewriting vendored comments creates upstream drift, which is the greater +defect. No Elm source exists in this tree; the Elm discipline (single +state model, no scattered mutation) was applied to the React panels' +state handling instead. + +### 1. Build + toolchain + +- **Makefile web-dev splice (blocker).** The v0.4.4 Makefile carried the + web-dev recipe fused into uninstall-branding: `make web-dev` only + installed dependencies, and `make uninstall-branding` — a cleanup + target — started fester plus a dev console as root. The recipe is + attached to its own target. +- **Reproducible dist + release gate.** `make dist` pins LC_ALL=C, + sorts names, pins mtime to SOURCE_DATE_EPOCH, and writes owner 0 / + group 0. `make check` gains check-release-tree: a git tree must be + clean before a release builds (the Webmin-2019 supply-chain lesson + docs/SECURITY-HARDENING.md documents — the doc's claim now matches + the Makefile's behavior). +- **Master tarball hygiene.** dev.log, server.log, *.log, and .env are + excluded; every rm in uninstall quotes its path; distcheck stages in + mktemp. +- **Generator correctness (incident + fix).** A single accidental + generator invocation during the pass wiped hand-maintained plugin JS + (the _old_modules source directory it copies from is gone from the + tree) and overwrote hand-maintained index.html/manifest files with + stale embedded templates — including per-plugin CSPs with frame-src + for the iframe plugins. Recovery came from the release tarball, and + the generator is now a **verifier**: `make plugins` checks catalog ↔ + disk consistency and never writes; destructive regeneration exists + only as `--force` bootstrap. The embedded BRIDGE_JS / SHARED_CSS + constants are gone — shared/ is hand-maintained source, period. +- **manage-users.mjs** was TypeScript in an .mjs shell (Bun < 1.3 + transpiled it; Bun ≥ 1.3 refuses). Annotations stripped; the CLI runs + under Bun 1.3.14. + +### 2. Bridge security + robustness + +- **prometheus push-log (HIGH).** The module name reached + `LOG_DIR / f"{module}.jsonl"` unvalidated — an absolute path or `../` + escaped /var/lib/sysdeck as root. The name now passes + _validate_filename; exposition labels are escaped per the Prometheus + spec (metric-line injection closed); the log write degrades to a + reported error instead of a traceback. +- **db query guard (HIGH).** The read-only check looked at the first + token only; `SELECT 1; DROP TABLE x` ran as root. Now: single + statement only (any `;`, CLI meta-command, or NUL rejects), read-verb + allowlist, 4 KB cap. The sqlite path invoked `sqlite3 `, which + opens a *database file* named like the SQL — it refuses honestly + now. +- **builder vmdb2 (MEDIUM).** The output-dir allowlist (mkosi-only in + v0.4.4) covers vmdb2; build subprocesses run under a scrubbed, + proxy-aware BUILD_ENV. +- **themes variable-set (MEDIUM).** CSS variable values are allowlisted + (color/number syntax, 128 chars) — brace breakout and url() exfil + are rejected at the door. +- **Hard timeouts** on auth, vault, firmware, fleet, integrity (900s + ceiling for lynis), glances, and package mutations; a wedged child + is a reported state, never a hang. +- **Glances step-down.** The snapshot family degrades to + {available:false, reason, install} exactly like the web commands — + a missing glances is a state, not a traceback. +- Correctness: kerberos status derives from the real TGT end time; + benchmark's latency key no longer collides; hwalert unwhitelist + matches exactly (serial / id / vendor:product), never by substring; + modules3p renders usage errors as JSON envelopes; remotefs + cluster-info is a probe table with graceful missing-CLI degradation; + policy ns-show selects the requested namespace from the real lsns + listing; fleet summary scans peers once; mining's password sentinel + is explicit. + +### 3. Web console + +- **Theme switching (blocker).** The Themes panel wrote data-sd-theme + directly, so light themes kept the tailwind dark class and the + localStorage mirror desynced. Every path now goes through + applySdTheme() and mirrors to localStorage. +- **Build gates enforced.** typescript.ignoreBuildErrors is false, + reactStrictMode is on, and eslint runs exhaustive-deps + + no-unused-vars as errors — both green across the tree. The overview + panel reports SYSDECK_VERSION and a live tarball link from the + registry (v0.4.3 + a guaranteed-404 fallback are gone); the module + count derives from the registry too. +- **Runtime quality.** fester health probes are cached + single-flight + (a down sidecar no longer stalls the ticker); usePoll rejects stale + responses; the session clock is hydration-safe; theme persistence + failures surface instead of vanishing; the cockpit-modules table + renders valid rows; dead CSS and the dead tailwind.config.ts are + gone; the duplicate toast system is collapsed to sonner. + +### 4. Firewall templates + +All six nftables rulesets generate and pass structural validation +(flush directives, set syntax, jump syntax, variable expansion, brace +balance — validated via a shim harness): + +- `flush ruleset` is gone from every template — each uses `add table` + + `flush table`, so docker/libvirt/systemd-networkd tables survive + an apply. +- sysdeck-fw: the shell redirect that shipped inside the ruleset + (never loadable), the empty `udp dport { }` set, the blanket SYN + accepts that defeated policy drop, the decorative synproxy chain, + and the fixed /tmp failure-copy path are fixed; the ICMPv6 set now + carries the full NDP + PMTUD control set. +- no-services: `$SSH_IP` (undefined — SSH lockout), invalid + `jump to` syntax, unbraced set-adds, an unreachable loopback accept + (local IPC lockout), a port-agnostic connlimit accept, and a + TCP_SERVICES default that contradicted the no-services contract. +- vps-webserver: unbraced set-adds and the verdict-less synproxy + statement that let every in-rate SSH login fall through to the ban + rule. +- ai-llm / public-webserver / remote-admin: root check + + validate-before-load in the start path. +- cilium-default.yaml: the HTTP method filter is scoped to 80/443 + (port 22 no longer rides the L7 parser); DNS egress resolves in + standalone mode; the stop path warns about the allow-all + consequence. + +### 5. Cockpit plugin suite + +- XSS closes: renderError paths in photos/remotefs/db escape system + output; builder escapes backend ids/versions/kinds and its artifact + download uses the documented spawn promise (the channel-API misuse + never settled). +- jellyfin's renderServiceCard referenced an undeclared `webStatus` + (ReferenceError on the not-installed path) — it reads the passed + summary now. +- containers: mutation failures render a visible flash (the EventBus + is a no-op by design — the operator still sees the failure where + they clicked it); observer guards in netsec/fester/klanker release + the previous observer per re-mount; the Kata cross-reference tells + the truth. +- superuser right-sizing: podman actions and sysbench escalate via + 'try' (rootless podman manages the operator's own store); lynis + keeps root. +- shared/manifest.json drops 'unsafe-eval' (no plugin evaluates). +- CSS parity: .sysdeck-* equivalents exist for every .suite-* layout + class; the primary hover tracks the accent (no hardcoded blue); + legacy blue accents in monitoring/modules/firewall are teal; base + sysdeck.css carries button focus styles; multi-column grids collapse + under 720px. + +### 6. Packaging + +- **RPM (build-breaking).** %files described the v0.0.9 single-plugin + layout and could never build; it now matches the 27-plugin install. + Duplicate %changelog merged; BuildArch: noarch; nodejs in + BuildRequires. +- **Debian.** nodejs Build-Depends (make check runs `node --check`); + kata-containers/jellyfin demoted to Suggests (a media server and a + virtualization stack do not ride a default install); the install + target's polkit/appstream host integration is gated on empty DESTDIR + — a package build never mutates the build host. +- **Pacman.** The post_* hooks were dead code in the PKGBUILD body; + they live in packaging/sysdeck.install referenced by install=. The + contradictory python site-packages symlink is gone (the bridge's + invocation contract is absolute-path by design). +- **AppStream.** The addon component extends org.cockpit_project.cockpit. +- **Caddyfile.** The `?XTransformPort=` handler was an open + localhost-port gateway; the upstream set is an explicit 3010 + allowlist (the fester event stream). + +### 7. Language: standing decisions, not history + +Comments across the first-party tree state rules in the present tense. +Version narratives ("vX REWRITE", "was X", "kept from", "restored", +"brought back", "surviving artifact") are gone from bridge helpers, +plugin sources, shared assets, the generator, the web console, active +docs, and the packaging changelogs — the same facts now read as what +ships and how it is tested. Functional backup/restore features (branding +backup, iptables-restore, distro restore) keep their names: those are +runtime behavior, not churn narration. + +### 8. Verification + +- `make check`: 267/267 unit tests + all build-time guards + (manifest consistency, bridge subcommand cross-check, recipe tabs, + cockpit import/module patterns, version sync, release tree). +- Web: `bunx tsc --noEmit` clean; `bunx eslint src` clean with the + two gates enabled as errors. +- Firewall: six templates driven through a shim harness; captured + rulesets pass flush/redirect/empty-set/jump/brace/variable checks. +- Generator: catalog verifier green over 24 catalog entries + shared/. +- manage-users.mjs CLI verified under Bun 1.3.14. diff --git a/QUICKSTART.md b/QUICKSTART.md index 1bf3bb5..0af0eb9 100755 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -369,7 +369,7 @@ demo, mock, stub, or seeded data anywhere in the codebase: live-ruleset tab: `nft -j list ruleset` / `iptables-save`) and the template catalog covers all seven shipped topologies. - The bridge `DataSource` type no longer admits a `'demo'` value — the - compiler itself rejects any reintroduction. Absent backends always + compiler itself rejects the value at build time. Absent backends always render honest empty inventories with install guidance. ### 10.6 The MoE QA pass (v0.4.3) @@ -461,7 +461,7 @@ scrollbars) onto the classic cockpit panels. Nothing else changes — the class vocabulary, the bridge, and every module are untouched. ```bash -# revert the plugin pages to the classic 0.1.x skin: +# switch the plugin pages back to the classic 0.1.x skin: sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css # also theme the Cockpit SHELL chrome (sidebar, header, login) to match: diff --git a/README.md b/README.md index 5ecbbeb..e0195e5 100755 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SysDeck [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.4.4-orange.svg)](#) +[![Version](https://img.shields.io/badge/version-0.4.5-orange.svg)](#) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org) [![Runtime](https://img.shields.io/badge/runtime-Bun-f9f1e0.svg)](https://bun.sh) [![Python](https://img.shields.io/badge/python-3.9%2B-3776AB.svg)](#) @@ -10,7 +10,7 @@ **A standalone Linux operations console — Unix-account login, real host state, no fabricated data. Cockpit is optional: the same module catalog loads there too.** Author: **Jeremy Anderson** · · · [github.com/dcosnet/SysDeck](https://github.com/dcosnet/SysDeck) -Version: **0.4.4** · License: **MIT** +Version: **0.4.5** · License: **MIT** ![SysDeck — the standalone console, Overview panel](docs/screenshots/overview.png) @@ -50,7 +50,7 @@ The same module catalog also ships as a **Cockpit plugin suite** — 27 standalo |-------|----------|----------| | **Standalone web console** (default) | Run the whole console in the browser — no Cockpit on the host at all | `web/` · one process on `:3000` | | **Cockpit plugin suite** (optional) | Drop the same 27 domain modules into an existing Cockpit install | `/usr/share/cockpit/sysdeck-*/` · `https://:9090` | -| **Master tarball** | Both shapes + vendored services in one bundle | `sysdeck-0.4.4-master.tar.bz2` (`make master`) | +| **Master tarball** | Both shapes + vendored services in one bundle | `sysdeck-0.4.5-master.tar.bz2` (`make master`) | The parity rule runs both directions. Every domain module in the console has a counterpart plugin in `plugins/sysdeck-*/`, and the packages module, for instance, runs the same ten-manager step-down (pacman, emerge, lunar, sorcery, xbps, apk, zypper, dnf/yum, apt) with the same parsers and fixture tests on both sides. The console additionally detects every *installed* cockpit module on the host — distro modules like cockpit-machines and cockpit-podman, addons, anything with a `menu` entry in its manifest — and loads each into its own sidebar. Install a cockpit module on the box, and it shows up in the console; no cockpit login required to browse it. @@ -116,7 +116,7 @@ Every spawn uses the array form with an allowlisted command set — no `eval`, n - **Cockpit manifest.** Each plugin's `manifest.json` registers under the `index` menu key; cockpit serves the page at `/cockpit/@localhost/sysdeck-/index.html`. The web console discovers the same modules from its own registry. - **Module registry.** `scripts/generate-plugins.py` is the declarative source for the plugin catalog; `web/src/lib/sysdeck/registry.ts` is the console's counterpart. Adding a module means appending an entry and dropping a plugin directory — no other wiring. -- **Zero-demo envelope.** Every bridge response is `{ ok, data, source, note }` where `source` is `'live' | 'hybrid' | 'unavailable'` — the TypeScript union does not admit a `'demo'` value, so the compiler rejects any reintroduction. +- **Zero-demo envelope.** Every bridge response is `{ ok, data, source, note }` where `source` is `'live' | 'hybrid' | 'unavailable'` — the TypeScript union admits no `'demo'` value, so the compiler rejects one at build time. ## Module catalog @@ -214,7 +214,7 @@ Live rows where the backend exists, honest empties where it does not — both fr ### Standalone console (default — no Cockpit required) ```bash -tar xjf sysdeck-0.4.4-master.tar.bz2 +tar xjf sysdeck-0.4.5-master.tar.bz2 cd sysdeck-0.4.4-master make web-dev # bun install + db:push + fester (:3010) + next dev (:3000) ``` diff --git a/bridge/__init__.py b/bridge/__init__.py index 86f6a03..07a225f 100755 --- a/bridge/__init__.py +++ b/bridge/__init__.py @@ -7,11 +7,9 @@ via cockpit.spawn. Each module is standalone and runnable as a CLI: python3 /usr/lib/sysdeck/bridge/containers.py list -(v0.0.26+ invocation: absolute path, no PYTHONPATH, no -m flag. - The earlier `python3 -m sysdeck.bridge.containers` pattern was broken — - it required a nested Python package layout (sysdeck/bridge/containers.py) - that the Makefile install target never produced. bridge.js calls each - helper by absolute path.) +Invocation contract: absolute path, no PYTHONPATH, no -m flag. +The installed layout is flat files at /usr/lib/sysdeck/bridge/.py, +and bridge.js calls each helper by absolute path. The helpers exist for operations that are too complex for a single CLI call — e.g. cross-referencing podman and systemd, or aggregating TPM @@ -22,7 +20,7 @@ import os import subprocess from typing import Literal -__version__ = "0.4.4" +__version__ = "0.4.5" __author__ = "Jeremy Anderson" __url__ = "https://dcos.net" diff --git a/bridge/auth.py b/bridge/auth.py index a87260c..a0ae9d3 100755 --- a/bridge/auth.py +++ b/bridge/auth.py @@ -55,13 +55,14 @@ KLIST_PRINCIPAL_RE = re.compile(r"^\s*Default principal:\s+(?P\S+)") KLIST_TICKET_RE = re.compile(r"^\s*(?P\S+)\s+(?P\S+)\s+(?P\S+)\s+(?P\S+)\s+(?P\S+)") -def run(argv: list[str]) -> str: +def run(argv: list[str], timeout: int = 20) -> str: """Run a command, returning stdout. Returns '' on failure.""" try: return subprocess.run( - argv, capture_output=True, text=True, check=True, + argv, capture_output=True, text=True, check=True, timeout=timeout, ).stdout - except (subprocess.CalledProcessError, FileNotFoundError): + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, + FileNotFoundError): return "" @@ -92,10 +93,8 @@ def readers() -> list[dict[str, str]]: def certs() -> dict[str, Any]: """PKCS#11 objects of type cert via pkcs11-tool. - v0.1.4: the auth panel's "List Certificates" button used to call a - bridge.spawn() that bridge.js never exported — the button has - always thrown. The listing now lives here (fixed argv list, no - shell), matching every other spawn in this suite. + Fixed argv list, no shell — the same spawn discipline every + helper in this suite follows. """ if not shutil.which("pkcs11-tool"): return {"available": False, @@ -184,12 +183,26 @@ def kerberos() -> list[dict[str, Any]]: if m: default_principal = m.group("principal") + # Ticket expiry from the TGT line (krtgt/...): the credential cache + # owns the truth. klist prints "MM/DD/YYYY hh:mm:ss" under C locale; + # anything unparsable reports active with an empty expiry rather + # than a guessed date. + tgt_end, tgt_start = "", "" + for line in raw.splitlines(): + m = KLIST_TICKET_RE.match(line) + if m and "krbtgt" in m.group("principal"): + tgt_end = m.group("end") + tgt_start = m.group("start") + break + if default_principal: user, realm = default_principal.split("@") if "@" in default_principal else (default_principal, "") principals.append({ "principal": default_principal, "realm": realm, "kdc": "", + "startTime": tgt_start, + "endTime": tgt_end, }) return principals @@ -236,13 +249,21 @@ def identities() -> dict[str, Any]: # Kerberos principals as identity objects for i, p in enumerate(krb): + end = p.get("endTime", "") + status = "expired" + if end: + try: + status = "active" if datetime.strptime( + end, "%m/%d/%Y %H:%M:%S") > datetime.now() else "expired" + except ValueError: + status = "active" # klist spoke an unknown locale — report presence all_identities.append({ "id": f"krb-{i}", "type": "kerberos-principal", "name": p.get("principal", f"principal-{i}"), - "status": "active" if p.get("endTime") else "expired", + "status": status, "createdAt": p.get("startTime", ""), - "expiresAt": p.get("endTime", ""), + "expiresAt": end, "details": p, }) diff --git a/bridge/benchmark.py b/bridge/benchmark.py index 85991d3..c19d79c 100755 --- a/bridge/benchmark.py +++ b/bridge/benchmark.py @@ -140,7 +140,7 @@ def _parse_sysbench(output: str) -> dict[str, Any]: result["events_per_sec"] = float(line.split(":")[-1].strip()) except ValueError: pass - if "avg:" in line.lower() and "latency" not in result: + if "avg:" in line.lower() and "latency_ms" not in result: parts = line.split() for i, p in enumerate(parts): if p == "avg:" and i + 1 < len(parts): diff --git a/bridge/builder.py b/bridge/builder.py index fafdf59..d5756b7 100755 --- a/bridge/builder.py +++ b/bridge/builder.py @@ -8,20 +8,17 @@ host and returns a unified JSON interface so the Builder panel can list available build profiles / image specs without knowing which tool the operator picked. -v0.0.30 REWRITE: the previous version was a thin `systemctl is-active -osbuild-composer.service` shim — which is Fedora/RHEL-only and silently -returns 'inactive' on Arch and Debian. Per the user's directive, the -target distros are now Arch Linux and Debian: +Target distros: Arch Linux and Debian (the decision that shapes +every backend choice below): - Arch Linux → mkosi (systemd's own image builder; pacman -S mkosi) ↘ archiso (Arch Live ISO builder; pacman -S archiso) - Debian → vmdb2 (Debian project's image builder; apt install vmdb2) ↘ live-build (Debian Live ISO builder; apt install live-build) -Fedora/RHEL (osbuild-composer / composer-cli) is no longer targeted. -osbuild-composer is not packaged for Arch or Debian, so the v0.0.29 -panel was permanently 'inactive' on every distro this suite ships to. -mkosi and vmdb2 are the canonical equivalents and are invoked as +Fedora/RHEL (osbuild-composer / composer-cli) is out of scope: +osbuild-composer is not packaged for Arch or Debian. mkosi and vmdb2 +are the canonical equivalents and are invoked as separate processes via subprocess — the suite (MIT) and the image builders remain independent programs. No builder code is bundled. @@ -183,13 +180,12 @@ def _primary_backend() -> dict[str, Any] | None: # /etc/live-build/, ~/.config/live-build/ MKOSI_DIRS = [ - # v0.1.0: per-profile directories under /etc/mkosi/profiles// - # are the primary location. Each profile gets its own directory - # containing a real `mkosi.conf` (the only filename mkosi reads - # automatically from the cwd). v0.0.50 wrote drop-in fragments - # to /etc/mkosi/mkosi.conf.d/.conf, which mkosi silently - # ignores unless a parent /etc/mkosi/mkosi.conf exists to layer - # them onto — so every v0.0.x build ran with empty defaults. + # Per-profile directories under /etc/mkosi/profiles// are the + # primary location. Each profile carries a real `mkosi.conf` — the + # only filename mkosi reads automatically from the cwd. Drop-in + # fragments under /etc/mkosi/mkosi.conf.d/ are ignored unless a + # parent /etc/mkosi/mkosi.conf exists to layer them onto, so + # profiles never rely on fragments. Path("/etc/mkosi/profiles"), Path("/etc/mkosi"), Path("/usr/share/mkosi"), @@ -466,11 +462,10 @@ def install_hint() -> dict[str, str]: }) -# ── v0.0.31: Full-featured build operations ──────────────────────── +# ── Build operations ────────────────────────────────────────────── # -# The v0.0.30 builder was a status+profile viewer. v0.0.31 adds the -# ability to actually build images, create and delete profiles, list -# build artifacts, and tail build logs. +# Build images, create and delete profiles, list build artifacts, +# and tail build logs. # # Build state lives under /var/lib/sysdeck/builder/: # state/.json per-build state record @@ -641,15 +636,13 @@ def _backend_build_command(backend_id: str, profile: dict[str, Any], options: di `options` may carry: output_dir, image_format, extra_args, force. Returns the argv list to subprocess.run. - v0.1.3 CRITICAL FIX: --include does NOT work as a config loader. mkosi's --include flag includes a drop-in fragment ON TOP OF the - base mkosi.conf — it does NOT replace the base config. If there's - no mkosi.conf in the cwd, mkosi uses defaults and ignores the - --include file entirely. This is why v0.1.2 still produced builds - with only 2 packages (mkosi's hardcoded base). + base mkosi.conf — it does NOT replace the base config. With no + mkosi.conf in the cwd, mkosi uses defaults and ignores the + --include file entirely. - The fix: build() now creates a temp directory, symlinks the - profile file into it as `mkosi.conf`, and sets work_dir to that + Therefore build() creates a temp directory, symlinks the profile + file into it as `mkosi.conf`, and sets work_dir to that temp dir. mkosi finds `mkosi.conf` (the symlink), follows it, reads the actual profile. This works for ANY profile path regardless of its filename or location. @@ -684,6 +677,34 @@ def _backend_build_command(backend_id: str, profile: dict[str, Any], options: di return [] +# Build environment: PATH/locale pinned like every privileged helper in +# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for +# image fetches. Everything else from the invoking session is dropped. +BUILD_ENV = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C", "LC_ALL": "C", + "HOME": "/root", + **{k: os.environ[k] for k in ( + "http_proxy", "https_proxy", "no_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + ) if k in os.environ}, +} + + +# Build environment: PATH/locale pinned like every privileged helper in +# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for +# image fetches. Everything else from the invoking session is dropped. +BUILD_ENV = { + "PATH": "/usr/sbin:/usr/bin:/sbin:/bin", + "LANG": "C", "LC_ALL": "C", + "HOME": "/root", + **{k: os.environ[k] for k in ( + "http_proxy", "https_proxy", "no_proxy", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + ) if k in os.environ}, +} + + def _prepare_mkosi_work_dir(profile: dict[str, Any]) -> Path | None: """Create a temp dir with mkosi.conf symlinked to the profile file. @@ -831,7 +852,7 @@ def build(args: list[str]) -> dict[str, Any]: # or /var/tmp/. This blocks /etc/, /usr/, /boot/, /bin/, /sbin/, # /lib/, /root/, /home/, etc. — anywhere a stray image.raw would # corrupt the system or pollute a user's home. - if backend_id == "mkosi": + if backend_id in ("mkosi", "vmdb2"): resolved_output_dir = Path(options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname)) try: resolved = resolved_output_dir.resolve() @@ -939,6 +960,7 @@ def build(args: list[str]) -> dict[str, Any]: cwd=work_dir if work_dir else None, stdout=logf, stderr=subprocess.STDOUT, check=False, timeout=3600, + env=BUILD_ENV, ) rc = r.returncode except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc: diff --git a/bridge/db.py b/bridge/db.py index d638938..262f013 100755 --- a/bridge/db.py +++ b/bridge/db.py @@ -23,14 +23,13 @@ Subcommands: backup - trigger a backup connections - list active connections -Cockpit way (v0.0.31+ pattern, applied here in v0.0.32): mutating -ops (start / stop / restart / query) run via subprocess directly — -no `sudo` shell-out. The JS panel passes { superuser: 'try' } to -cockpit.spawn so the cockpit bridge prompts the operator via polkit -for the org.sysdeck.db.modify action (added in v0.0.32 — authorizes -/usr/bin/systemctl for engine service control). The bridge runs as -the cockpit user and gets root privileges via polkit when the -operator authenticates. +Privilege model: mutating ops (start / stop / restart / query) run +via subprocess directly — no `sudo` shell-out. The JS panel passes +{ superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts +the operator via polkit for the org.sysdeck.db.modify action +(authorizes /usr/bin/systemctl for engine service control). The +bridge runs as the cockpit user and gets root privileges via polkit +when the operator authenticates. Author: Jeremy Anderson (https://dcos.net) """ @@ -98,11 +97,16 @@ ENGINE_REGISTRY = [ def run(cmd, timeout=10): - """Run a command, return stdout or empty string.""" + """Run a command, return stdout or empty string. + + Failures (missing binary, timeout, non-zero exit) log to stderr so + the distinction stays visible in the channel log; callers get "". + """ try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) return r.stdout.strip() - except Exception: + except Exception as exc: + print(f"[db.run] {cmd[0] if cmd else '?'}: {exc}", file=sys.stderr) return "" @@ -294,17 +298,15 @@ def _engine_registered(engine_id): through the registry, but start/stop/restart passed the raw id into `systemctl {engine_id}.service` — letting any cockpit session stop/start ARBITRARY system units as root (db stop sshd). - All unit-control subcommands now require a registered engine.""" + All unit-control subcommands require a registered engine.""" return any(e[0] == engine_id for e in ENGINE_REGISTRY) def cmd_start(engine_id): - # v0.0.32: was `sudo systemctl start` — but sudo shell-out from - # the bridge fails when the cockpit user has no passwordless sudo - # (the typical case). The cockpit way: the JS panel passes - # { superuser: 'try' } to cockpit.spawn so the cockpit bridge - # prompts the operator via polkit for the org.sysdeck.db.modify - # action. The bridge runs systemctl directly as root (the cockpit + # The cockpit way: the JS panel passes { superuser: 'try' } to + # cockpit.spawn so the cockpit bridge prompts the operator via + # polkit for the org.sysdeck.db.modify action. The bridge runs + # systemctl directly as root (the cockpit # superuser channel escalates privileges via polkit when the # operator authenticates). # v0.1.4 SECURITY: registry check added (see _engine_registered). @@ -351,42 +353,52 @@ def cmd_connections(engine_id): def cmd_query(engine_id, sql): - """Execute a read-only SQL query against an engine (SQL family only). + """Execute a single read-only SQL statement against an engine (SQL family only). - v0.1.4 SECURITY: the old comment said "refuse DDL/DML" but no check - existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as - root through psql/mysql/sqlite. The guard is now real: only - SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements - pass. Mutations belong in the engine's own tooling, not in a - dashboard query box. + Security contract (enforced in code, not in comments): + * first token must be a read verb — SELECT/WITH/SHOW/EXPLAIN/ + DESCRIBE/PRAGMA/TABLE/ANALYZE + * exactly one statement — any semicolon, psql meta-command, or + null byte rejects the request, so stacked statements cannot + ride in behind a read verb + * 4 KB cap — a query box entry is never megabytes + Mutations belong in the engine's own tooling, not in a dashboard + query box. """ for e in ENGINE_REGISTRY: if e[0] == engine_id: family, cli = e[2], e[5] if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"): return {"error": "Query only supported for SQL-family engines"} - # v0.1.4 SECURITY: read-only statement guard. - tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1) + sql = (sql or "").strip().rstrip(";").strip() + if not sql or len(sql) > 4096 or "\x00" in sql: + return {"error": "query must be 1-4096 bytes of plain SQL"} + if ";" in sql or sql.startswith("\\"): + return {"error": "single read-only statement only — " + "batched statements and CLI meta-commands are rejected"} + tokens = sql.lstrip("(\t\r\n ").split(None, 1) first_token = tokens[0].upper() if tokens else "" - read_only = first_token in ( - "SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC", - "PRAGMA", "TABLE", "ANALYZE", - ) - if not read_only: + if first_token not in ("SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", + "DESC", "PRAGMA", "TABLE", "ANALYZE"): return {"error": "read-only queries only — DDL/DML is rejected " "(first token was not a read statement)"} - if cli == "psql": - out = run(["psql", "-tAc", sql], timeout=30) - elif cli in ("mysql", "mariadb"): - out = run(["mysql", "-e", sql], timeout=30) - elif cli == "cockroach": - out = run(["cockroach", "sql", "-e", sql], timeout=30) - elif cli == "clickhouse-client": - out = run(["clickhouse-client", "-q", sql], timeout=30) - elif cli == "sqlite3": - out = run(["sqlite3", sql], timeout=30) - else: + query_cmds = { + "psql": ["psql", "-tAc", sql], + "mysql": ["mysql", "-e", sql], + "mariadb": ["mysql", "-e", sql], + "cockroach": ["cockroach", "sql", "-e", sql], + "clickhouse-client": ["clickhouse-client", "-q", sql], + } + if cli == "sqlite3": + # sqlite3 treats a lone argument as a database filename, + # so running SQL through it queries nothing. The registry + # tracks no database path for sqlite — refuse honestly + # instead of pretending an empty :memory: is the engine. + return {"error": "sqlite3 queries need a database file — " + "run 'sqlite3 \".read\" style exports from a shell'"} + if cli not in query_cmds: return {"error": f"No query handler for {cli}"} + out = run(query_cmds[cli], timeout=30) return {"output": out, "engine": engine_id, "query": sql} return {"error": f"Unknown engine: {engine_id}"} diff --git a/bridge/fester.py b/bridge/fester.py index 29a6b34..87e662f 100755 --- a/bridge/fester.py +++ b/bridge/fester.py @@ -3,13 +3,9 @@ SysDeck - Fester Bridge Helper Author: Jeremy Anderson (https://dcos.net) -v0.2.0 REAL INTEGRATION. The v0.1.x helper was a stub: its only -subcommand (`build-jobs`) listed systemd units whose name contained -"fester" or "build" — it never talked to a build orchestrator. The -Cockpit edition now ships against the vendored fester service (the -same one the Web Edition runs): web/mini-services/fester, a -distributed DAG build orchestrator speaking REST + WebSocket on -127.0.0.1:3010. +REAL INTEGRATION against the vendored fester service (the same one +the Web Edition runs): web/mini-services/fester, a distributed DAG +build orchestrator speaking REST + WebSocket on 127.0.0.1:3010. This helper is a thin stdlib-only REST client (urllib.request + json, 4s timeout — no requests library, no curl dependency). Every @@ -36,8 +32,6 @@ Subcommands: replay [--label ] POST /api/sessions → session -The old `build-jobs` subcommand is REMOVED. - Usage: python3 /usr/lib/sysdeck/bridge/fester.py status python3 /usr/lib/sysdeck/bridge/fester.py builds diff --git a/bridge/firewall.py b/bridge/firewall.py index a7bd4f2..ba15374 100755 --- a/bridge/firewall.py +++ b/bridge/firewall.py @@ -33,11 +33,10 @@ Also adds the service/port editor (4 new bridge subcommands): restart-service just restart the service (no port change). Useful for "I edited the config by hand" flows. -v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY. -The v0.0.30 bridge exposed only `ruleset` and `chains` subcommands: the -panel could list active nftables rules but could not start, stop, -restart, apply a template, ban an IP, unban an IP, show the ban list, -or show service detection. The panel was a monitor, not a manager. +FULL MANAGER, NOT A MONITOR: the bridge can start, stop, restart, +apply a template, ban an IP, unban an IP, show the ban list, and show +service detection — every operation the panel offers is wired to a +subcommand below. v0.0.31 turned the firewall panel into a full manager (apply / stop / restart / ban / unban / clear-bans / detect / check). @@ -112,7 +111,7 @@ Subcommands added in v0.0.36: applied to this bridge (for display in the panel's Security Card) -Subcommands kept from v0.0.31: +Subcommands: ruleset / chains / templates / template-info / detect / apply / stop / restart / status / ban / unban / banned / clear-bans / check @@ -154,10 +153,10 @@ following subcommands (the bridge invokes them as Templates must be POSIX-compliant bash and work on both Arch and Debian. The templates shipped in this release are: - vps-webserver.sh (v0.0.31, kept) - no-services.sh (v0.0.31, kept) - cilium.sh (v0.0.36, Cilium eBPF backend) - sysdeck-fw.sh (v0.0.37, unified nftables zone firewall) + vps-webserver.sh (public VPS web tier) + no-services.sh (SSH-only hardened host) + cilium.sh (Cilium eBPF backend) + sysdeck-fw.sh (unified nftables zone firewall) remote-admin.sh (v0.0.44, SSH + Cockpit public-server variant) public-webserver.sh (v0.0.44, Caddy + Varnish + MariaDB variant) ai-llm.sh (v0.0.44, Ollama + OpenWebUI + Hermes + Odysseus variant) @@ -723,7 +722,7 @@ def safe_tar_create(archive_path: Path, files: list[Path], cwd: Path) -> tuple[i return 127, "", str(exc) -# ── Ruleset parser (v0.0.30 logic, kept) ─────────────────────────── +# ── Ruleset parser ──────────────────────────────────────────────── def parse_ruleset(output: str) -> list[dict[str, Any]]: @@ -1201,12 +1200,10 @@ def cmd_status(_args: list[str]) -> dict[str, Any]: # ── Subcommand: ban ───────────────────────────────────────────────── # -# v0.0.36: _validate_ip is defined in the validation helpers section -# above (line ~373). It uses ipaddress.ip_address for strict IPv4 + IPv6 -# validation — replacing the v0.0.31 hand-rolled IPv4-only validator. -# The old validator accepted leading zeros (e.g. "010.010.010.010") which -# some systems interpret as octal — a subtle attack vector. The new -# validator rejects them. CVE-2024-2947 lesson. +# _validate_ip (validation helpers above) uses ipaddress.ip_address +# for strict IPv4 + IPv6 validation. Leading zeros (e.g. +# "010.010.010.010") are rejected — some systems interpret them as +# octal, a subtle attack vector. CVE-2024-2947 lesson. def cmd_ban(args: list[str]) -> dict[str, Any]: @@ -1287,7 +1284,7 @@ def cmd_check(_args: list[str]) -> dict[str, Any]: } -# ── Subcommand: ruleset (v0.0.30, kept) ───────────────────────────── +# ── Subcommand: ruleset ──────────────────────────────────────────── def cmd_ruleset(_args: list[str]) -> list[dict[str, Any]]: @@ -1298,7 +1295,7 @@ def cmd_ruleset(_args: list[str]) -> list[dict[str, Any]]: return parse_ruleset(out) -# ── Subcommand: chains (v0.0.30, kept) ─────────────────────────────── +# ── Subcommand: chains ───────────────────────────────────────────── def cmd_chains(_args: list[str]) -> list[str]: @@ -2021,10 +2018,10 @@ SERVICES_REGISTRY: list[dict[str, Any]] = [ ], # Varnish's listen port is set via -a :6081 or VARNISH_LISTEN_PORT=6081. "port_regex": re.compile( - r"(?:(\-a\s*:?[a-z0-9.]*:)|VARNISH_LISTEN_PORT\s*=\s*)(\d+)", + r"(?P
-a\s*:?[a-z0-9.]*:|VARNISH_LISTEN_PORT\s*=\s*)(?P\d+)",
             re.MULTILINE,
         ),
-        "port_replace_template": r"\g<1>{port}",
+        "port_replace_template": r"\g
{port}",
         "default_port": 6081,
         "description": "Varnish HTTP cache. Default listen port 6081 (overridden to 8080 in the public-webserver template).",
     },
@@ -2660,10 +2657,10 @@ def cmd_restart_service(args: list[str]) -> dict[str, Any]:
 # ── Dispatch table ─────────────────────────────────────────────────
 
 COMMANDS = {
-    # v0.0.30 read-only subcommands (kept for back-compat):
+    # Read-only subcommands:
     "ruleset":       cmd_ruleset,
     "chains":        cmd_chains,
-    # v0.0.31 manager subcommands:
+    # Manager subcommands:
     "templates":     cmd_templates,
     "template-info": cmd_template_info,
     "detect":        cmd_detect,
diff --git a/bridge/firmware.py b/bridge/firmware.py
index 68fe24a..3abac93 100755
--- a/bridge/firmware.py
+++ b/bridge/firmware.py
@@ -20,7 +20,7 @@ def run(argv: list[str]) -> str:
     """Run a command, returning stdout. Returns '' on failure."""
     try:
         return subprocess.run(
-            argv, capture_output=True, text=True, check=True,
+            argv, capture_output=True, text=True, check=True, timeout=60,
         ).stdout
     except (subprocess.CalledProcessError, FileNotFoundError):
         return ""
diff --git a/bridge/fleet.py b/bridge/fleet.py
index 79b51ef..4f65dd4 100755
--- a/bridge/fleet.py
+++ b/bridge/fleet.py
@@ -28,7 +28,7 @@ def run(argv: list[str]) -> str:
     """Run a command, returning stdout. Returns '' on failure."""
     try:
         return subprocess.run(
-            argv, capture_output=True, text=True, check=True,
+            argv, capture_output=True, text=True, check=True, timeout=20,
         ).stdout
     except (subprocess.CalledProcessError, FileNotFoundError):
         return ""
@@ -85,10 +85,11 @@ def peers() -> list[dict[str, str]]:
 
 def summary() -> dict[str, Any]:
     """Local host info plus peer list."""
+    peers_list = peers()
     return {
         "local": local_host(),
-        "peers": peers(),
-        "peerCount": len(peers()),
+        "peers": peers_list,
+        "peerCount": len(peers_list),
     }
 
 
diff --git a/bridge/glances.py b/bridge/glances.py
index 146c440..bac2ef3 100755
--- a/bridge/glances.py
+++ b/bridge/glances.py
@@ -6,9 +6,8 @@ Author: Jeremy Anderson (https://dcos.net)
 Aggregates system monitoring data from the Glances CLI
 (https://github.com/nicolargo/glances) into a structured JSON document.
 
-v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI. The user directive:
-"glances is not integrated yet i just assumed you would integrate the
-built in webui as a module." Glances ships a webserver via
+The bridge integrates the Glances built-in web UI. Glances ships a
+webserver via
 `glances -w` (default port 61208, 127.0.0.1). The bridge starts that
 webserver as a background process; the JS panel iframes the running
 web UI at http://127.0.0.1:61208 — full Glances web UI (all graphs,
@@ -16,22 +15,22 @@ all sensors, all top processes, all history) without SysDeck
 re-implementing any of it.
 
 Subcommands:
-  snapshot       — full system snapshot (kept from v0.0.11)
-  cpu            — CPU metrics subset (kept)
-  memory         — memory metrics subset (kept)
-  network        — network metrics subset (kept)
+  snapshot       — full system snapshot
+  cpu            — CPU metrics subset
+  memory         — memory metrics subset
+  network        — network metrics subset
   start-web      — start glances -w on 127.0.0.1:61208 (background)
                    writes the PID to /var/lib/sysdeck/glances/web.pid
   stop-web       — kill the background webserver (read PID file)
   web-status     — return {running, pid, port, url}
   web-port       — return the actual listening port (defaults to 61208)
 
-Cockpit way (v0.0.31+ pattern): the bridge runs glances via subprocess
-directly — no `sudo` shell-out. The JS panel passes { superuser: 'try' }
-to cockpit.spawn so the cockpit bridge prompts the operator via polkit
-for the org.sysdeck.system.manage action (shipped since v0.0.17 —
-authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by
-extension any system-level subprocess the bridge runs).
+Privilege model: the bridge runs glances via subprocess directly —
+no `sudo` shell-out. The JS panel passes { superuser: 'try' } to
+cockpit.spawn so the cockpit bridge prompts the operator via polkit
+for the org.sysdeck.system.manage action (authorizes
+/usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by extension any
+system-level subprocess the bridge runs).
 
 Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes
 it as a separate process via subprocess — the suite (MIT) and Glances
@@ -118,35 +117,70 @@ def _is_pid_alive(pid: int) -> bool:
         return False
 
 
-def run_glances(args: list[str]) -> str:
-    """Run glances with the given args, returning stdout."""
-    return subprocess.run(
-        ["glances", *args], capture_output=True, text=True, check=True,
-    ).stdout
+GLANCES_INSTALL_HINT = (
+    "pip install glances  # or: pacman -S glances / apt install glances / dnf install glances"
+)
+
+
+def run_glances(args: list[str], timeout: int = 30) -> tuple[str, str | None]:
+    """Run glances, returning (stdout, failure_reason).
+
+    Step-down contract shared by the whole snapshot family: glances
+    missing, wedged, or erroring degrades to a reason string — the
+    caller renders {"available": false}, never a traceback.
+    """
+    if not _have("glances"):
+        return "", "glances not installed"
+    try:
+        r = subprocess.run(
+            ["glances", *args], capture_output=True, text=True, check=False,
+            timeout=timeout,
+        )
+    except subprocess.TimeoutExpired:
+        return "", f"glances timed out after {timeout}s"
+    except OSError as exc:
+        return "", f"glances failed to start: {exc}"
+    if r.returncode != 0 and not r.stdout.strip():
+        return "", (r.stderr.strip() or f"glances exited {r.returncode}")[:200]
+    return r.stdout, None
 
 
 def snapshot() -> dict[str, Any]:
     """Full system snapshot from glances JSON export.
 
     Calls: glances --time 1 --quiet --export json --once
-    Returns the parsed JSON document.
+    Returns the parsed JSON document, or {"available": false, ...}
+    when glances cannot answer.
     """
-    output = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
+    output, reason = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
+    if reason:
+        return {"available": False, "reason": reason, "install": GLANCES_INSTALL_HINT}
     lines = output.strip().splitlines()
     if not lines:
-        return {}
-    return json.loads(lines[-1])
+        return {"available": False, "reason": "glances produced no output"}
+    try:
+        return json.loads(lines[-1])
+    except json.JSONDecodeError:
+        return {"available": False, "reason": "glances output was not valid JSON"}
+
+
+def _available_false(data: dict[str, Any]) -> bool:
+    return isinstance(data, dict) and data.get("available") is False
 
 
 def cpu() -> dict[str, Any]:
     """CPU metrics subset from a glances snapshot."""
     data = snapshot()
+    if _available_false(data):
+        return data
     return data.get("cpu", {})
 
 
 def memory() -> dict[str, Any]:
     """Memory metrics subset from a glances snapshot."""
     data = snapshot()
+    if _available_false(data):
+        return data
     return {
         "mem": data.get("mem", {}),
         "memswap": data.get("memswap", {}),
@@ -156,6 +190,8 @@ def memory() -> dict[str, Any]:
 def network() -> dict[str, Any]:
     """Network interface metrics subset from a glances snapshot."""
     data = snapshot()
+    if _available_false(data):
+        return data
     return data.get("network", {})
 
 
diff --git a/bridge/grafana.py b/bridge/grafana.py
index aed473c..8a07276 100755
--- a/bridge/grafana.py
+++ b/bridge/grafana.py
@@ -40,7 +40,8 @@ GRAFANA_LICENSE = "AGPL-3.0"
 GRAFANA_AUTHORS = "Grafana Labs"
 GRAFANA_URL = "https://grafana.com"
 
-# v0.0.39: import v0.0.37 security helpers from firewall.py.
+# Security helpers come from firewall.py — one source of truth for
+# hardening across the suite.
 sys.path.insert(0, str(Path(__file__).parent))
 try:
     from firewall import (  # type: ignore
@@ -374,7 +375,11 @@ def plugins() -> list[dict[str, Any]]:
 def search(args: list[str]) -> list[dict[str, Any]]:
     """Search dashboards by query string."""
     query = args[0] if args else ""
-    endpoint = f"/search?type=dash-db&query={query}" if query else "/search?type=dash-db"
+    if query:
+        from urllib.parse import quote
+        endpoint = f"/search?type=dash-db&query={quote(query, safe='')}"
+    else:
+        endpoint = "/search?type=dash-db"
     data = _grafana_api_get(endpoint)
     if not isinstance(data, list):
         return []
diff --git a/bridge/hwalert.py b/bridge/hwalert.py
index 13ea385..fd8fbb6 100755
--- a/bridge/hwalert.py
+++ b/bridge/hwalert.py
@@ -512,12 +512,10 @@ def _device_path_ok(device_id):
     paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/...,
     /sys/bus/pci/devices/...). block/unblock write to /authorized as
     root, so the id must resolve inside one of those scanned bases —
-    otherwise cmd_block was an arbitrary file-overwrite ('0') and
-    cmd_unblock was a root shell injection via `sudo sh -c` with the
-    f-string path (found by the 0.3.0 security audit; both sudo
-    fallbacks are also gone: the cockpit superuser channel already
-    escalates this helper via polkit, so shelling out through sudo
-    only ever added the injection primitive)."""
+    anything else would make cmd_block an arbitrary file-overwrite ('0')
+    and cmd_unblock a shell-injection primitive. The cockpit superuser
+    channel escalates this helper via polkit; no sudo shell-out exists
+    anywhere in this helper."""
     if not isinstance(device_id, str) or not device_id.startswith("/"):
         return False
     bases = (
@@ -551,10 +549,9 @@ def cmd_unblock(device_id):
         return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"}
     auth_path = os.path.join(device_id, "authorized")
     if os.path.exists(auth_path):
-        # v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a
-        # device_id containing shell metacharacters was literal root RCE.
-        # Direct write (this helper already runs privileged through the
-        # cockpit superuser channel when the operator approves polkit).
+        # Direct write, never a shell: the helper already runs
+        # privileged through the cockpit superuser channel when the
+        # operator approves polkit.
         try:
             with open(auth_path, 'w') as f:
                 f.write('1')
@@ -594,14 +591,21 @@ def cmd_whitelist(device_id):
 
 
 def cmd_unwhitelist(device_id):
-    """Remove a device from the whitelist."""
+    """Remove one device from the whitelist by exact identity.
+
+    Match on exact serial, exact device id, or exact vendorId:productId —
+    never a substring, which would remove every entry sharing a letter.
+    """
     whitelist = load_whitelist()
-    # Remove by matching serial or vendorId:productId
-    new_wl = []
-    for entry in whitelist:
-        if entry.get("serial") and device_id in entry.get("serial", ""):
-            continue
-        new_wl.append(entry)
+    new_wl = [
+        entry for entry in whitelist
+        if not (
+            device_id == entry.get("serial")
+            or device_id == entry.get("id")
+            or device_id == entry.get("deviceId")
+            or device_id == f"{entry.get('vendorId', '')}:{entry.get('productId', '')}"
+        )
+    ]
     save_whitelist(new_wl)
     return {"action": "unwhitelist", "deviceId": device_id, "remaining": len(new_wl)}
 
diff --git a/bridge/integrity.py b/bridge/integrity.py
index 99f74ef..2959ca3 100755
--- a/bridge/integrity.py
+++ b/bridge/integrity.py
@@ -32,10 +32,15 @@ def score() -> int | None:
 
 def scan() -> dict[str, Any]:
     """Run a fresh lynis audit and return the parsed result."""
+    # A lynis audit runs for minutes by design; 15 minutes is the hard
+    # ceiling so a wedged audit cannot hang the bridge forever.
     try:
         subprocess.run(
             ["lynis", "audit", "system"], capture_output=True, text=True, check=True,
+            timeout=900,
         )
+    except subprocess.TimeoutExpired:
+        return {"error": "lynis audit timed out after 900s", "score": None}
     except (subprocess.CalledProcessError, FileNotFoundError) as exc:
         return {"error": str(exc), "score": None}
     return {"score": score()}
diff --git a/bridge/kata.py b/bridge/kata.py
index 24da601..52a3545 100755
--- a/bridge/kata.py
+++ b/bridge/kata.py
@@ -3,19 +3,10 @@
 SysDeck - Kata Bridge Helper
 Author: Jeremy Anderson (https://dcos.net)
 
-v0.0.38 PRODUCTION REWRITE. The v0.0.35-v0.0.37 Kata panel shipped a
-pre-built React bundle from the upstream cockpit-kata sub-project. That
-bundle displayed HARDCODED MOCK DATA — 5 fake sandboxes (web-frontend-
-prod, api-gateway-staging, etc.) with synthetic UUIDs and createdAt
-timestamps, fake metrics (cpuUsagePercent, memoryUsageMB, historyCpu/
-historyMemory arrays), a fake QCrows bundle catalog, and a fake PXE
-status (always dnsmasqRunning:true). The only real features were the
-QCrows kernel-bundle extraction (qcrows-export / qcrows-initrd-regen
-via cockpit.spawn) and the kata-runtime check call.
-
-v0.0.38 deletes the React bundle and ships a vanilla-JS panel backed
-by this Python bridge helper. Every subcommand calls the REAL Kata
-Containers 3.x APIs:
+ZERO-DEMO CONTRACT. Every subcommand calls the real Kata Containers
+3.x APIs and reads the real host state — sandbox lists, metrics, bundle
+catalogs, and PXE status are live data or honest unavailability, never
+fabricated records:
 
   list                enumerate kata sandboxes via:
                         1. kata-monitor HTTP /sandboxes (if running)
@@ -543,8 +534,7 @@ def cmd_check(_args: list[str]) -> dict[str, Any]:
 
 # ── Subcommand: pxe-status ─────────────────────────────────────────
 #
-# Real PXE/TFTP status — replaces the v0.0.37 mock that always
-# returned dnsmasqRunning:true.
+# Real PXE/TFTP status straight from systemctl and /srv/tftp.
 
 
 def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
@@ -584,9 +574,8 @@ def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
 
 # ── Subcommand: qcrows-list ────────────────────────────────────────
 #
-# QCrows kernel bundles are the real feature for kata kernel/module
-# compilation. The v0.0.37 React bundle had a mock catalog; this
-# reads the real filesystem.
+# QCrows kernel bundles are the kata kernel/module compilation
+# surface; the listing reads the real filesystem.
 
 
 def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]:
diff --git a/bridge/mining.py b/bridge/mining.py
index 821ef83..283c946 100755
--- a/bridge/mining.py
+++ b/bridge/mining.py
@@ -3,9 +3,8 @@
 SysDeck - Mining Bridge Helper
 Author: Jeremy Anderson (https://dcos.net)
 
-v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
-"themes and mining they need to be expanded for maximum ui
-control. think 1999 power tool style here." The Mining Dashboard
+1999 POWER-TOOL STYLE: maximum UI control over every mining surface.
+The Mining Dashboard
 panel surfaces every XMRig REST API knob:
 
   summary            — GET /1/summary (live hashrate, pool, threads)
@@ -219,11 +218,13 @@ def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
     if len(args) < 2:
         return {"error": "usage: pool-config-set   [password]"}
     url, username = args[0], args[1]
-    password = args[2] if len(args) > 2 else "x"
+    # None = the operator sent no password; "x" is XMRig's conventional
+    # dummy and only ever goes on the wire, never into the report.
+    password = args[2] if len(args) > 2 else None
     cfg = _http_get("/1/config")
     if cfg is None:
         return {"available": False, "reason": "XMRig REST API not reachable"}
-    new_pool = {"url": url, "user": username, "pass": password, "rig-id": "", "nicehash": False, "keep-alive": True, "enabled": True}
+    new_pool = {"url": url, "user": username, "pass": password or "x", "rig-id": "", "nicehash": False, "keep-alive": True, "enabled": True}
     if not cfg.get("pools"):
         cfg["pools"] = [new_pool]
     else:
@@ -233,7 +234,7 @@ def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
         "set": result is not None,
         "url": url,
         "username": username,
-        "password_set": password != "x",
+        "password_set": password is not None,
         "raw": result,
     }
 
diff --git a/bridge/modules3p.py b/bridge/modules3p.py
index 8babe3e..8704b95 100755
--- a/bridge/modules3p.py
+++ b/bridge/modules3p.py
@@ -11,7 +11,7 @@ row shows the license, developer, source URL, and homepage link
 right next to a 1-click Install button. Clicking Install IS the
 operator's acceptance of the inline-displayed license.
 
-Design rules (per v0.0.46 directive):
+Design rules:
   1. The catalog is the single source of truth — no per-module code
      branches. Adding a module = appending a dict to CATALOG.
   2. No pulls are executed without an explicit install call from
@@ -640,16 +640,27 @@ def status() -> list[dict[str, Any]]:
 
 # ── CLI ──────────────────────────────────────────────────────────────────────
 
+def _arg(a: list[str], i: int = 0, default: str = "") -> str:
+    """Positional argv read with an honest usage error, never IndexError."""
+    if len(a) <= i or not a[i]:
+        raise _UsageError(f"missing required argument {i + 1}")
+    return a[i]
+
+
+class _UsageError(Exception):
+    pass
+
+
 COMMANDS: dict[str, Callable[[list[str]], Any]] = {
     "catalog":  lambda _a: [e.to_public_dict() for e in catalog_entries()],
     "status":   lambda _a: status(),
-    "preflight": lambda a: preflight(a[0]),
+    "preflight": lambda a: preflight(_arg(a)),
     "install":  lambda a: install(
-        a[0],
+        _arg(a),
         accept_license=("--accept-license" in a) or ("--accept-license=1" in a),
     ),
-    "uninstall": lambda a: uninstall(a[0]),
-    "audit":    lambda a: audit(int(a[0]) if a else 200),
+    "uninstall": lambda a: uninstall(_arg(a)),
+    "audit":    lambda a: audit(int(a[0]) if a and str(a[0]).isdigit() else 200),
 }
 
 
@@ -661,7 +672,11 @@ def main(argv: list[str]) -> int:
     if not cmd:
         print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
         return 2
-    result = cmd(argv[1:])
+    try:
+        result = cmd(argv[1:])
+    except _UsageError as exc:
+        print(json.dumps({"ok": False, "error": str(exc)}))
+        return 2
     print(json.dumps(result, indent=2, default=str))
     return 0 if (not isinstance(result, dict) or result.get("ok", True)) else 1
 
diff --git a/bridge/netsec.py b/bridge/netsec.py
index 575e27a..cc61670 100755
--- a/bridge/netsec.py
+++ b/bridge/netsec.py
@@ -3,11 +3,10 @@
 SysDeck - Netsec Bridge Helper
 Author: Jeremy Anderson (https://dcos.net)
 
-v0.0.43 REWRITE — IPTRAF-NG STYLE NETWORK MONITOR.
+IPTRAF-NG STYLE NETWORK MONITOR.
 
-The v0.0.10-v0.0.42 panel used `ss -tulpn` for a static socket list.
-v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources
-iptraf-ng reads from directly — no fragile ncurses parsing.
+The monitor reads the same kernel sources iptraf-ng reads from
+directly — no fragile ncurses parsing, no static socket list.
 
 Data sources:
   /proc/net/dev    per-interface RX/TX byte + packet counters
@@ -26,8 +25,8 @@ Subcommands:
   connections     active TCP/UDP flows with PID mapping (like iptraf-ng IP monitor)
   interfaces      per-interface detailed stats (cumulative counters)
   protocols       /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters
-  sockets         listening TCP+UDP sockets (kept from v0.0.10 for back-compat)
-  established     established TCP connections (kept from v0.0.10 for back-compat)
+  sockets         listening TCP+UDP sockets
+  established     established TCP connections
 
 Usage:
     python3 /usr/lib/sysdeck/bridge/netsec.py traffic
@@ -376,7 +375,7 @@ def cmd_summary(_args: list[str]) -> dict[str, Any]:
     }
 
 
-# ── Legacy subcommands (kept for back-compat) ─────────────────────
+# ── Socket-listing subcommands ───────────────────────────────────
 
 
 def parse_ss(output: str) -> list[dict[str, Any]]:
@@ -396,12 +395,12 @@ def parse_ss(output: str) -> list[dict[str, Any]]:
 
 
 def sockets() -> list[dict[str, Any]]:
-    """Listening TCP and UDP sockets (legacy, kept for back-compat)."""
+    """Listening TCP and UDP sockets from `ss -tulpn`."""
     return parse_ss(_run(["ss", "-tulpn"]))
 
 
 def established() -> list[dict[str, Any]]:
-    """Established TCP connections (legacy, kept for back-compat)."""
+    """Established TCP connections from `ss -tnp state established`."""
     return parse_ss(_run(["ss", "-tnp", "state", "established"]))
 
 
diff --git a/bridge/packages.py b/bridge/packages.py
index 0197484..51ace49 100755
--- a/bridge/packages.py
+++ b/bridge/packages.py
@@ -1065,13 +1065,7 @@ def install(args: list[str]) -> dict[str, str]:
     # Actually run it. The cockpit bridge runs as the cockpit user; the
     # JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit
     # prompt the operator for auth and run us as root via polkit.
-    r = subprocess.run(
-        cmd, capture_output=True, text=True, check=False,
-        timeout=600, env=SCRUBBED_ENV,
-    )
-    return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
-            "command": " ".join(cmd), "success": r.returncode == 0,
-            "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
+    return _run_mutation(cmd, "install", pkg)
 
 
 def remove(args: list[str]) -> dict[str, str]:
@@ -1087,13 +1081,7 @@ def remove(args: list[str]) -> dict[str, str]:
     if not cmd:
         return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
                 "success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
-    r = subprocess.run(
-        cmd, capture_output=True, text=True, check=False,
-        timeout=600, env=SCRUBBED_ENV,
-    )
-    return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
-            "command": " ".join(cmd), "success": r.returncode == 0,
-            "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
+    return _run_mutation(cmd, "remove", pkg)
 
 
 def update(args: list[str]) -> dict[str, str]:
@@ -1116,11 +1104,53 @@ def update(args: list[str]) -> dict[str, str]:
     if not cmd:
         return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
                 "success": False, "stderr": f"no update command for {PKG_MANAGER}"}
-    r = subprocess.run(
-        cmd, capture_output=True, text=True, check=False,
-        timeout=600, env=SCRUBBED_ENV,
-    )
-    return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
+    return _run_mutation(cmd, "update", pkg)
+
+
+def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]:
+    """Run a package-manager mutation; never raises.
+
+    Ten-minute package operations are normal, and a missing binary is
+    a state, not a crash: both come back as a structured failure the
+    panel can render, exactly like the read path.
+    """
+    try:
+        r = subprocess.run(
+            cmd, capture_output=True, text=True, check=False,
+            timeout=600, env=SCRUBBED_ENV,
+        )
+    except subprocess.TimeoutExpired:
+        return {"action": action, "package": pkg, "manager": PKG_MANAGER,
+                "success": False, "stderr": "package manager timed out after 600s — "
+                "it may still be running; check with the manager directly"}
+    except FileNotFoundError:
+        return {"action": action, "package": pkg, "manager": PKG_MANAGER,
+                "success": False, "stderr": f"{cmd[0]} is not installed"}
+    return {"action": action, "package": pkg, "manager": PKG_MANAGER,
+            "command": " ".join(cmd), "success": r.returncode == 0,
+            "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
+
+
+def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]:
+    """Run a package-manager mutation; never raises.
+
+    Ten-minute package operations are normal, and a missing binary is
+    a state, not a crash: both come back as a structured failure the
+    panel can render, exactly like the read path.
+    """
+    try:
+        r = subprocess.run(
+            cmd, capture_output=True, text=True, check=False,
+            timeout=600, env=SCRUBBED_ENV,
+        )
+    except subprocess.TimeoutExpired:
+        return {"action": action, "package": pkg, "manager": PKG_MANAGER,
+                "success": False, "stderr": "package manager timed out after 600s — "
+                "it may still be running; check with the manager directly"}
+    except FileNotFoundError:
+        return {"action": action, "package": pkg, "manager": PKG_MANAGER,
+                "success": False, "stderr": f"{cmd[0]} is not installed"}
+    return {"action": action, "package": pkg, "manager": PKG_MANAGER,
             "command": " ".join(cmd), "success": r.returncode == 0,
             "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
 
@@ -1137,13 +1167,7 @@ def update_all() -> dict[str, str]:
     if not cmd:
         return {"action": "update-all", "manager": PKG_MANAGER,
                 "success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
-    r = subprocess.run(
-        cmd, capture_output=True, text=True, check=False,
-        timeout=600, env=SCRUBBED_ENV,
-    )
-    return {"action": "update-all", "manager": PKG_MANAGER,
-            "command": " ".join(cmd), "success": r.returncode == 0,
-            "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
+    return _run_mutation(cmd, "update-all", "")
 
 
 def dry_run(args: list[str]) -> dict[str, str]:
diff --git a/bridge/photos.py b/bridge/photos.py
index 14daa03..63df9ef 100755
--- a/bridge/photos.py
+++ b/bridge/photos.py
@@ -6,9 +6,9 @@ Manages self-hosted photo management backends as systemd services
 and exposes the built-in admin web UI for iframe embedding in the
 SysDeck panel.
 
-v0.0.35 directive: "as well as a photo manager of equal quality.
-with its own module." Following the Jellyfin pattern: start/stop/
-restart the service via systemctl; the panel iframes the running
+A photo manager of equal quality to the Jellyfin module, as its own
+module. Following the Jellyfin pattern: start/stop/restart the
+service via systemctl; the panel iframes the running
 admin web UI. Equal quality means the photo manager module ships
 with the same service-control + iframe-load shape as Jellyfin.
 
diff --git a/bridge/policy.py b/bridge/policy.py
index abc9e2b..9e585fc 100755
--- a/bridge/policy.py
+++ b/bridge/policy.py
@@ -3,8 +3,8 @@
 SysDeck - Policy & Permissions Bridge
 Author: Jeremy Anderson (https://dcos.net)
 
-v0.0.32 NEW MODULE — modern policy management and permissions
-manager for groups. The user directive:
+Modern policy management and permissions manager for groups.
+Design contract:
 
   "modern policy management and permissions manager for groups.
    such as acl, cgroups, vlans, ebpf namespace separation and
@@ -665,14 +665,20 @@ def cmd_ns_show(args: list[str]) -> dict[str, Any]:
     lsns = shutil.which("lsns")
     if not lsns:
         return {"available": False, "reason": "lsns (util-linux) not installed"}
-    rc, out, _ = _run([lsns, "-J", "-t", nsid])
+    # lsns has no lookup-by-namespace-id flag; -t expects a type. Take
+    # the full JSON listing once and select the requested id from it.
+    rc, out, _ = _run([lsns, "-J"])
     if rc == 0 and out.strip():
         try:
-            data = json.loads(out)
-            return {"available": True, "info": data, "format": "json"}
+            listing = json.loads(out)
+            for ns in listing.get("namespaces", []):
+                if str(ns.get("ns", "")) == nsid:
+                    return {"available": True, "info": ns, "format": "json"}
+            return {"available": False,
+                    "reason": f"namespace {nsid} not found in the live listing"}
         except json.JSONDecodeError:
             pass
-    rc2, out2, _ = _run([lsns, "-t", nsid])
+    rc2, out2, _ = _run([lsns])
     return {"available": True, "raw": out2, "format": "text"}
 
 
diff --git a/bridge/prometheus.py b/bridge/prometheus.py
index 484e7bb..e77afe5 100755
--- a/bridge/prometheus.py
+++ b/bridge/prometheus.py
@@ -39,8 +39,8 @@ PROM_LICENSE = "Apache-2.0"
 PROM_AUTHORS = "Prometheus Authors"
 PROM_URL = "https://prometheus.io"
 
-# v0.0.39: import v0.0.37 security helpers from firewall.py (single
-# source of truth for hardening).
+# Security helpers come from firewall.py — one source of truth for
+# hardening across the suite.
 sys.path.insert(0, str(Path(__file__).parent))
 try:
     from firewall import (  # type: ignore
@@ -60,14 +60,11 @@ except ImportError:
     def _validate_filename(name: str) -> bool:
         return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
 
-# Prometheus API endpoint from environment or default
-# v0.0.40: Prometheus defaults to port 9090, which is the SAME port
-# Cockpit-ws uses. Since Cockpit is already running on 9090 on every
-# SysDeck host, Prometheus MUST be moved to a different port. We
-# default to 9095 — it's in the familiar 909x range, doesn't conflict
-# with Pushgateway (9091), Alertmanager (9093), or Cockpit (9090).
-# Operators who already run Prometheus on a custom port can override
-# via the PROMETHEUS_API_URL environment variable.
+# Prometheus API endpoint from environment or default. Port 9090
+# belongs to cockpit-ws on every SysDeck host, so Prometheus defaults
+# to 9095 — same 909x range, clear of Pushgateway (9091), Alertmanager
+# (9093), and Cockpit (9090). Operators running Prometheus on a custom
+# port override it via PROMETHEUS_API_URL.
 PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095")
 # Pushgateway URL for log pipeline
 PUSHGATEWAY_URL = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL", "http://localhost:9091")
@@ -373,23 +370,35 @@ def push_log(args: list[str]) -> dict[str, Any]:
         # Metadata JSON malformed: reject push request
         return {"error": "metadata_json must be valid JSON"}
 
+    # Filesystem contract: the module name becomes a filename under
+    # LOG_DIR. Validate it like every other filename this suite writes —
+    # no absolute paths, no traversal, no symlink tricks downstream.
+    if not _validate_filename(module):
+        return {"error": "invalid module name (max 64 chars of [A-Za-z0-9._-])"}
+
     ts = time.time()
     iso_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
 
-    # Build Prometheus exposition format
+    # Build Prometheus exposition format. Label values are escaped
+    # (backslash, quote, newline) per the exposition spec — raw values
+    # must never inject metric lines into the pushgateway payload.
+    def _esc(value: str) -> str:
+        return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
+
+    mod_l, lvl_l, msg_l = _esc(module), _esc(level), _esc(message[:128])
     metrics = (
-        f'# TYPE sysdeck_log_total counter\n'
-        f'sysdeck_log_total{{module="{module}",level="{level}"}} 1\n'
-        f'# TYPE sysdeck_log_timestamp_seconds gauge\n'
-        f'sysdeck_log_timestamp_seconds{{module="{module}",level="{level}"}} {ts:.3f}\n'
-        f'# TYPE sysdeck_log_message_info gauge\n'
-        f'sysdeck_log_message_info{{module="{module}",level="{level}",msg="{message[:128]}"}} 1\n'
+        '# TYPE sysdeck_log_total counter\n'
+        f'sysdeck_log_total{{module="{mod_l}",level="{lvl_l}"}} 1\n'
+        '# TYPE sysdeck_log_timestamp_seconds gauge\n'
+        f'sysdeck_log_timestamp_seconds{{module="{mod_l}",level="{lvl_l}"}} {ts:.3f}\n'
+        '# TYPE sysdeck_log_message_info gauge\n'
+        f'sysdeck_log_message_info{{module="{mod_l}",level="{lvl_l}",msg="{msg_l}"}} 1\n'
     )
 
     pushed = _pushgateway_post("sysdeck_logs", metrics)
 
-    # Persist to local audit log
-    LOG_DIR.mkdir(parents=True, exist_ok=True)
+    # Persist to local audit log. Path stays inside LOG_DIR by
+    # construction (validated module name, fixed directory).
     log_entry = {
         "module": module,
         "level": level,
@@ -399,10 +408,15 @@ def push_log(args: list[str]) -> dict[str, Any]:
         "pushedToPrometheus": pushed,
     }
     log_file = LOG_DIR / f"{module}.jsonl"
-    with open(log_file, "a") as f:
-        f.write(json.dumps(log_entry) + "\n")
+    try:
+        LOG_DIR.mkdir(parents=True, exist_ok=True)
+        with open(log_file, "a", encoding="utf-8") as f:
+            f.write(json.dumps(log_entry) + "\n")
+    except OSError as exc:
+        return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry,
+                "persisted": False, "persistError": str(exc)}
 
-    return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry}
+    return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry, "persisted": True}
 
 
 def log_summary() -> dict[str, Any]:
diff --git a/bridge/remotefs.py b/bridge/remotefs.py
index 043b698..93b6b8a 100755
--- a/bridge/remotefs.py
+++ b/bridge/remotefs.py
@@ -313,11 +313,30 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
         return {"error": f"Unknown backend: {bid}"}
     (bid2, name, family, port, unit, cli, configs, lic, homepage, install_hint) = e
 
+    # One probe table, not five copy-pasted branches: each backend
+    # maps to its argv; a CLI that is not installed is a state the panel
+    # can render, not a traceback.
+    probes: dict[str, list[str]] = {
+        "ceph": ["ceph", "status", "--format=json"],
+        "glusterfs": ["gluster", "pool", "list"],
+        "moosefs": ["moosefs-cli", "info"],
+        "beegfs": ["beegfs-ctl", "--listnodes"],
+        "orangefs": ["pvfs2-server", "-m"],
+    }
+    argv = probes.get(bid2)
+    if argv is None:
+        return {"error": f"No cluster-info handler for {bid2}"}
+    try:
+        out = subprocess.run(argv, capture_output=True, text=True, timeout=15)
+    except FileNotFoundError:
+        return {"available": False,
+                "reason": f"{argv[0]} is not installed",
+                "install": install_hint or ""}
+    except subprocess.TimeoutExpired:
+        return {"available": False,
+                "reason": f"{argv[0]} timed out after 15s"}
+
     if bid2 == "ceph":
-        out = subprocess.run(
-            ["ceph", "status", "--format=json"],
-            capture_output=True, text=True, timeout=15,
-        )
         if out.returncode != 0:
             return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"}
         try:
@@ -337,55 +356,12 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
         except json.JSONDecodeError:
             return {"backend": bid2, "rawText": out.stdout[:4000]}
 
-    if bid2 == "glusterfs":
-        out = subprocess.run(
-            ["gluster", "pool", "list"],
-            capture_output=True, text=True, timeout=15,
-        )
-        return {
-            "backend": bid2,
-            "rawText": out.stdout[:4000] if out.returncode == 0 else "",
-            "stderr": out.stderr.strip() if out.returncode != 0 else "",
-            "rc": out.returncode,
-        }
-
-    if bid2 == "moosefs":
-        out = subprocess.run(
-            ["moosefs-cli", "info"],
-            capture_output=True, text=True, timeout=15,
-        )
-        return {
-            "backend": bid2,
-            "rawText": out.stdout[:4000] if out.returncode == 0 else "",
-            "stderr": out.stderr.strip() if out.returncode != 0 else "",
-            "rc": out.returncode,
-        }
-
-    if bid2 == "beegfs":
-        out = subprocess.run(
-            ["beegfs-ctl", "--listnodes"],
-            capture_output=True, text=True, timeout=15,
-        )
-        return {
-            "backend": bid2,
-            "rawText": out.stdout[:4000] if out.returncode == 0 else "",
-            "stderr": out.stderr.strip() if out.returncode != 0 else "",
-            "rc": out.returncode,
-        }
-
-    if bid2 == "orangefs":
-        out = subprocess.run(
-            ["pvfs2-server", "-m"],
-            capture_output=True, text=True, timeout=15,
-        )
-        return {
-            "backend": bid2,
-            "rawText": out.stdout[:4000] if out.returncode == 0 else "",
-            "stderr": out.stderr.strip() if out.returncode != 0 else "",
-            "rc": out.returncode,
-        }
-
-    return {"error": f"No cluster-info handler for {bid2}"}
+    return {
+        "backend": bid2,
+        "rawText": out.stdout[:4000] if out.returncode == 0 else "",
+        "stderr": out.stderr.strip() if out.returncode != 0 else "",
+        "rc": out.returncode,
+    }
 
 
 def main(argv: list[str]) -> int:
diff --git a/bridge/themes.py b/bridge/themes.py
index 11e7d66..790d960 100755
--- a/bridge/themes.py
+++ b/bridge/themes.py
@@ -3,9 +3,8 @@
 SysDeck - Theme Engine Bridge Helper
 Author: Jeremy Anderson (https://dcos.net)
 
-v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
-"themes and mining they need to be expanded for maximum ui control.
-think 1999 power tool style here." The Theme Engine panel surfaces
+1999 POWER-TOOL STYLE: maximum UI control over every theme surface.
+The Theme Engine panel surfaces
 the full set of cockpit.conf theming knobs plus a preset gallery
 and live-preview CSS-variable overrides.
 
@@ -440,6 +439,22 @@ def cmd_variable_set(args: list[str]) -> dict[str, Any]:
     spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
     if spec is None:
         return {"error": f"variable {name} not in the surface"}
+    # Values land in overrides.css, which every SysDeck page loads.
+    # Accept color literals and numeric expressions only: no braces,
+    # semicolons, quotes, or url()/import tokens — a value that could
+    # break out of the declaration or fetch a remote asset is rejected
+    # at the door, not sanitized after the fact.
+    if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
+        return {"error": "value must be 1-128 chars of color/number syntax "
+                         "(letters, digits, space, # % ( ) , . / _ -)"}
+    # Values land in overrides.css, which every SysDeck page loads.
+    # Accept color literals and numeric expressions only: no braces,
+    # semicolons, quotes, or url()/import tokens — a value that could
+    # break out of the declaration or fetch a remote asset is rejected
+    # at the door, not sanitized after the fact.
+    if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
+        return {"error": "value must be 1-128 chars of color/number syntax "
+                         "(letters, digits, space, # % ( ) , . / _ -)"}
     try:
         SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
         # Read existing overrides, replace or append this variable.
diff --git a/bridge/vault.py b/bridge/vault.py
index dac33d6..3440155 100755
--- a/bridge/vault.py
+++ b/bridge/vault.py
@@ -21,7 +21,7 @@ def list_luks() -> list:
     try:
         r = subprocess.run(
             ["lsblk", "-o", "NAME,FSTYPE,MOUNTPOINT,SIZE,TYPE", "-J"],
-            capture_output=True, text=True, check=True,
+            capture_output=True, text=True, check=True, timeout=20,
         )
         data = json.loads(r.stdout) if r.stdout.strip() else {}
         devices = []
diff --git a/compat/compat-manifest.json b/compat/compat-manifest.json
index 4fa6406..3f96f98 100755
--- a/compat/compat-manifest.json
+++ b/compat/compat-manifest.json
@@ -1,6 +1,6 @@
 {
   "_comment": "Compatibility Manifest — sysdeck v0.1.3",
-  "version": "0.4.4",
+  "version": "0.4.5",
   "suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
   "modules": {
     "containers": {
@@ -18,7 +18,7 @@
       "distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
     },
     "kata": {
-      "_comment_v0.0.35": "SysDeck Kata — restored to its own sidebar entry per user directive. Hosts the pre-built cockpit-kata React app (index.js + index.css). Requires cockpit ≥ 286 because the React bundle uses newer cockpit-podman base1 APIs. The standalone cockpit-kata sub-project is consolidated into SysDeck.",
+      "_comment": "SysDeck Kata — its own sidebar entry. Hosts the pre-built cockpit-kata React app (index.js + index.css). Requires cockpit ≥ 286 because the React bundle uses newer cockpit-podman base1 APIs. The standalone cockpit-kata sub-project is consolidated into SysDeck.",
       "requires": { "cockpit": ">=286" },
       "conditions": [{ "path-or-exists": "/usr/bin/kata-runtime" }, { "path-or-exists": "/usr/bin/kata-containerd-shim-v2" }],
       "config": {
diff --git a/docs/INSTALL.md b/docs/INSTALL.md
index 26b81b6..26b5172 100755
--- a/docs/INSTALL.md
+++ b/docs/INSTALL.md
@@ -203,7 +203,7 @@ The JS bridge client calls each helper by absolute path — `python3 /usr/lib/sy
 
 ### Content Security Policy violations
 
-The manifests declare `content-security-policy: default-src 'self' 'unsafe-inline'`. `unsafe-eval` was dropped from every plugin in the 0.3.0 security audit. If your cockpit deployment enforces a stricter policy, tighten the manifest to match — the panels do not require it.
+The manifests declare `content-security-policy: default-src 'self' 'unsafe-inline'` — no plugin evaluates code, so `unsafe-eval` stays out of every manifest. If your cockpit deployment enforces a stricter policy, tighten the manifest to match; the panels do not require it.
 
 ### Web console: login loop or 401 on every route
 
diff --git a/firewall/policies/cilium-default.yaml b/firewall/policies/cilium-default.yaml
index f71a75d..e38fb3b 100755
--- a/firewall/policies/cilium-default.yaml
+++ b/firewall/policies/cilium-default.yaml
@@ -1,4 +1,4 @@
-# Cilium default network policy — shipped with sysdeck-0.0.36.
+# Cilium default network policy — shipped with the SysDeck firewall module.
 # Author: Jeremy Anderson 
 #
 # This policy is applied by firewall/templates/cilium.sh `start` action
@@ -6,9 +6,11 @@
 # panel. It implements a sensible default:
 #
 #   - default-deny ingress + egress at the cluster level
-#   - allow DNS (UDP/TCP 53) to kube-dns / systemd-resolved
-#   - allow SSH (TCP 22) from anywhere
-#   - allow HTTP/HTTPS (TCP 80/443) from anywhere
+#   - allow DNS (UDP/TCP 53) to kube-dns (K8s) or any local resolver
+#     (standalone installs run systemd-resolved without kube labels)
+#   - allow SSH (TCP 22) from anywhere — no L7 parser on port 22
+#   - allow HTTP/HTTPS (TCP 80/443) from anywhere, with the HTTP
+#     method allow-list scoped to 80/443 only
 #
 # Operators can drop a custom policy at
 # /etc/sysdeck/firewall/cilium-policy.yaml to override.
@@ -22,27 +24,37 @@ metadata:
   namespace: default
   annotations:
     sysdeck.io/managed-by: "sysdeck-firewall-cilium"
-    sysdeck.io/version: "0.0.36"
+    sysdeck.io/version: "0.4.5"
 spec:
   description: "SysDeck default policy — deny all ingress + egress, then re-allow DNS/SSH/HTTP/HTTPS"
   endpointSelector: {}
   ingress:
-    # Allow all endpoints to receive traffic from anywhere on SSH/HTTP/HTTPS.
+    # SSH passes at L3/L4 only: an HTTP L7 filter on port 22 would deny
+    # every non-HTTP byte of an SSH session.
     - toPorts:
         - ports:
             - port: "22"
               protocol: TCP
+    # HTTP/HTTPS carry the method allow-list — scoped to these ports
+    # alone, never to the whole toPorts block.
+    - toPorts:
+        - ports:
             - port: "80"
               protocol: TCP
+              rules:
+                http:
+                  - method: "GET"
+                  - method: "POST"
+                  - method: "HEAD"
             - port: "443"
               protocol: TCP
-          rules:
-            http:
-              - method: "GET"
-              - method: "POST"
-              - method: "HEAD"
+              rules:
+                http:
+                  - method: "GET"
+                  - method: "POST"
+                  - method: "HEAD"
   egress:
-    # Allow DNS to kube-dns (K8s) or systemd-resolved (standalone).
+    # DNS to kube-dns on Kubernetes deployments.
     - toEndpoints:
         - matchLabels:
             k8s:io.kubernetes.pod.namespace: kube-system
@@ -56,7 +68,18 @@ spec:
           rules:
             dns:
               - matchPattern: "*"
-    # Allow egress to anywhere on SSH/HTTP/HTTPS.
+    # DNS to any local resolver on the host network — standalone Cilium
+    # (the documented sysdeck mode) has no kube-dns labels to match.
+    - toEndpoints:
+        - matchLabels:
+            reserved:world
+      toPorts:
+        - ports:
+            - port: "53"
+              protocol: UDP
+            - port: "53"
+              protocol: TCP
+    # Egress to anywhere on SSH/HTTP/HTTPS.
     - toPorts:
         - ports:
             - port: "22"
@@ -65,7 +88,8 @@ spec:
               protocol: TCP
             - port: "443"
               protocol: TCP
-    # Allow egress to anywhere on HTTPS (for system updates).
+    # Egress to public HTTPS (for system updates) — private ranges stay
+    # blocked so endpoints cannot reach internal services uninvited.
     - toCIDRSet:
         - cidr: 0.0.0.0/0
           except:
diff --git a/firewall/templates/ai-llm.sh b/firewall/templates/ai-llm.sh
index a6290b9..e6cde99 100755
--- a/firewall/templates/ai-llm.sh
+++ b/firewall/templates/ai-llm.sh
@@ -207,7 +207,10 @@ build_ruleset() {
     cat < "$RULES_FILE"
+    # Root check + validate-then-load: a ruleset that cannot parse must
+    # never reach the kernel, and only the polkit bridge runs us.
+    if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
+        echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
+        exit 1
+    fi
+    if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
+        echo "ERROR: ruleset failed validation — nothing was loaded" >&2
+        exit 1
+    fi
     "$NFT_CMD" -f "$RULES_FILE"
     log_info "Firewall loaded."
 }
diff --git a/firewall/templates/cilium.sh b/firewall/templates/cilium.sh
index b019a61..055dd26 100755
--- a/firewall/templates/cilium.sh
+++ b/firewall/templates/cilium.sh
@@ -251,9 +251,12 @@ fw_stop() {
         return 0
     fi
 
-    # Delete all Cilium policies (reverts to default allow-all).
+    # Delete all Cilium policies. Cilium's default posture without a
+    # policy is allow-all — stopping the agent opens the host; the
+    # operator hears that decision, not just "done".
     # This does NOT unload the BPF programs — cilium-agent keeps running
     # so the operator can re-apply a policy without reinstalling.
+    log_warn "stopping the cilium backend returns the host to allow-all — re-apply a policy or load an nftables template before exposing the host"
     "$CILIUM_BIN" policy delete --all 2>/dev/null || log_warn "policy delete --all failed (no policies loaded?)."
 
     if [[ -x "$CILIUM_AGENT_BIN" ]] && systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then
diff --git a/firewall/templates/no-services.sh b/firewall/templates/no-services.sh
index 006729d..00f1dd0 100755
--- a/firewall/templates/no-services.sh
+++ b/firewall/templates/no-services.sh
@@ -84,9 +84,9 @@ SSH_BAN_TIME="300"
 
 # --- Allowed TCP Services ---
 # Format: "port" or "port:interface" for interface-specific rules
+# Contract: no public services except SSH. Operators who need a
+# web tier use the public-webserver or vps-webserver template.
 TCP_SERVICES=(
-    "80"
-    "443"
     "${SSH_PORT}"
 )
 
@@ -297,7 +297,7 @@ generate_rules() {
     all_ifs=$(get_all_interfaces)
     
     # Start with table definition
-    cat > "$RULES_FILE" << 'TABLE_HEADER'
+    cat > "$RULES_FILE" << TABLE_HEADER
 #!/usr/sbin/nft -f
 
 # ============================================================================
@@ -305,7 +305,10 @@ generate_rules() {
 # Generated by: nftables-firewall.sh
 # ============================================================================
 
-flush ruleset
+# Table-scoped reset: foreign tables (docker, libvirt, systemd-networkd)
+# are not ours to destroy.
+add table inet ${TABLE_NAME}
+flush table inet ${TABLE_NAME}
 
 TABLE_HEADER
 
@@ -325,11 +328,15 @@ table inet ${TABLE_NAME} {
     }
 
     # Trusted networks
-    set trusted_nets {
-        type ipv4_addr
-        flags interval
-        elements = { $(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//') }
-    }
+    $(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then
+        printf 'set trusted_nets {\n'
+        printf '    type ipv4_addr\n'
+        printf '    flags interval\n'
+        printf '    elements = { %s }\n' "$(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//')"
+        printf '}\n'
+    else
+        printf 'set trusted_nets { type ipv4_addr; }\n'
+    fi)
 
     # TCP ports to allow
     set tcp_allowed {
@@ -422,11 +429,16 @@ COUNTER_MAP
     chain input {
         type filter hook input priority 0; policy ${POLICY_INPUT};
 
+        # Loopback first, unconditionally: local IPC (DNS, databases,
+        # graphical sessions) must never ride the interface verdict map.
+        iifname "$LO_IF" accept
+
         # Update interface counters
         meta iifname @iface_input_policy counter name @iface_counters
 
         # Jump to interface-specific handling
         meta iifname @iface_input_policy
+    }
 INPUT_CHAIN
 
     # --- Loopback Chain ---
@@ -457,21 +469,21 @@ LO_CHAIN
         $(if [[ "$PROTECT_FRAGMENTS" == "yes" ]]; then echo "ip frag-off != 0 counter drop comment \"Fragmented packet\""; fi)
 
         # Drop XMAS tree scans (all flags set)
-        $(if [[ "$PROTECT_XMAS" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) counter jump to port_scan_detect comment \"XMAS scan\""; fi)
+        $(if [[ "$PROTECT_XMAS" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) counter jump port_scan_detect comment \"XMAS scan\""; fi)
 
         # Drop NULL scans (no flags set)
-        $(if [[ "$PROTECT_NULL_SCAN" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter jump to port_scan_detect comment \"NULL scan\""; fi)
+        $(if [[ "$PROTECT_NULL_SCAN" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter jump port_scan_detect comment \"NULL scan\""; fi)
 
         # Drop bogus TCP flag combinations
         $(if [[ "$PROTECT_BOGUS_TCP" == "yes" ]]; then generate_bogus_tcp_rules; fi)
 
         # ICMP handling
-        jump to icmp_handling
+        jump icmp_handling
 
         # Connection rate limiting
         $(generate_connlimit_rules)
 
-        jump to service_handling
+        jump service_handling
     }
 PREFILTER
 
@@ -513,9 +525,7 @@ EXTRA_CHAIN
     # --- Port Scan Detection Chain ---
     cat >> "$RULES_FILE" << SCAN_CHAIN
     chain port_scan_detect {
-        # Add to scanner set and drop
-        ip saddr @port_scanners drop
-        ip saddr set add @port_scanners counter drop comment \"Port scan detected\"
+        add @port_scanners { ip saddr } counter drop comment \"Port scan detected\"
     }
 SCAN_CHAIN
 
@@ -661,8 +671,8 @@ generate_ssh_rules() {
     cat << SSH_RULES
         # SSH brute force protection
         tcp dport $SSH_PORT ip saddr @ssh_abuse counter drop comment \"SSH brute force ban\"
-        tcp dport $SSH_IP ct state new limit rate ${SSH_MAX_CONN} burst 5 accept
-        tcp dport $SSH_IP ct state new add @ssh_abuse ip saddr counter drop comment \"SSH rate limit exceeded\"
+        tcp dport $SSH_PORT ct state new limit rate ${SSH_MAX_CONN} burst 5 accept
+        tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } counter drop comment \"SSH rate limit exceeded\"
 SSH_RULES
 }
 
@@ -678,9 +688,9 @@ DNS_RULES
 # Generate connection rate limiting
 generate_connlimit_rules() {
     cat << CONNLIMIT_RULES
-        # Global connection rate limit
-        ct state new limit rate ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} accept
-        ct state new add @connlimit_abuse ip saddr counter drop comment \"Connection rate limit exceeded\"
+        # Global connection rate limit: drop only the excess — the
+        # accept decisions stay with the service rules.
+        ct state new limit rate over ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} add @connlimit_abuse { ip saddr } counter drop comment \"Connection rate limit exceeded\"
 CONNLIMIT_RULES
 }
 
@@ -688,13 +698,13 @@ CONNLIMIT_RULES
 generate_bogus_tcp_rules() {
     cat << BOGUS_TCP
         # SYN+FIN (scan)
-        tcp flags & (syn|fin) == (syn|fin) counter jump to port_scan_detect
+        tcp flags & (syn|fin) == (syn|fin) counter jump port_scan_detect
         # SYN+RST (scan)
-        tcp flags & (syn|rst) == (syn|rst) counter jump to port_scan_detect
+        tcp flags & (syn|rst) == (syn|rst) counter jump port_scan_detect
         # FIN+RST (scan)
-        tcp flags & (fin|rst) == (fin|rst) counter jump to port_scan_detect
+        tcp flags & (fin|rst) == (fin|rst) counter jump port_scan_detect
         # PSH+FIN without ACK (scan)
-        tcp flags & (psh|fin|ack) == (psh|fin) counter jump to port_scan_detect
+        tcp flags & (psh|fin|ack) == (psh|fin) counter jump port_scan_detect
 BOGUS_TCP
 }
 
diff --git a/firewall/templates/public-webserver.sh b/firewall/templates/public-webserver.sh
index fd741a0..527fd88 100755
--- a/firewall/templates/public-webserver.sh
+++ b/firewall/templates/public-webserver.sh
@@ -197,7 +197,10 @@ build_ruleset() {
     cat < "$RULES_FILE"
+    # Root check + validate-then-load: a ruleset that cannot parse must
+    # never reach the kernel, and only the polkit bridge runs us.
+    if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
+        echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
+        exit 1
+    fi
+    if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
+        echo "ERROR: ruleset failed validation — nothing was loaded" >&2
+        exit 1
+    fi
     "$NFT_CMD" -f "$RULES_FILE"
     log_info "Firewall loaded."
 }
diff --git a/firewall/templates/remote-admin.sh b/firewall/templates/remote-admin.sh
index d73dce7..c5ed45c 100755
--- a/firewall/templates/remote-admin.sh
+++ b/firewall/templates/remote-admin.sh
@@ -164,7 +164,10 @@ build_ruleset() {
     cat < "$RULES_FILE"
+    # Root check + validate-then-load: a ruleset that cannot parse must
+    # never reach the kernel, and only the polkit bridge runs us.
+    if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
+        echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
+        exit 1
+    fi
+    if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
+        echo "ERROR: ruleset failed validation — nothing was loaded" >&2
+        exit 1
+    fi
     "$NFT_CMD" -f "$RULES_FILE"
     log_info "Firewall loaded."
 }
diff --git a/firewall/templates/sysdeck-fw.sh b/firewall/templates/sysdeck-fw.sh
index b0a38ae..fa05b8a 100755
--- a/firewall/templates/sysdeck-fw.sh
+++ b/firewall/templates/sysdeck-fw.sh
@@ -208,8 +208,6 @@ build_ruleset() {
 # AirWall: ${AIRWALL}
 # Flow offload: ${FLOW_OFFLOAD}
 
-flush table inet ${TABLE_NAME} 2>/dev/null
-
 table inet ${TABLE_NAME} {
     # ── Sets ─────────────────────────────────────────────────────────
     set ssh_abuse        { type ipv4_addr; flags interval; timeout 1h; }
@@ -244,14 +242,19 @@ table inet ${TABLE_NAME} {
         ct state established,related accept
         ct state invalid drop
 
-        ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate 50/second burst 100 packets accept
-        ct state new tcp flags & (fin|syn|rst|ack) == syn drop
+        # Flood control only — the accept decisions stay with the zone
+        # rules below, so no rate-limited blanket SYN accept exists here.
+        ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate over 50/second burst 100 packets counter drop comment "SYN flood rate limit"
 
         ip protocol icmp icmp type echo-request limit rate 5/second accept
-        ip6 nexthdr icmpv6 icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert } accept
 
-        iifname "$RED_IF"   tcp dport { $red_tcp_in }   accept comment "RED inbound TCP"
-        iifname "$RED_IF"   udp dport { $red_udp_in }   accept comment "RED inbound UDP"
+        # Full IPv6 control-plane set: NDP (solicit + advert, both
+        # router and neighbor) and PMTUD errors — without these, on-link
+        # IPv6 and path MTU discovery silently break.
+        ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
+
+        $( [[ -n "$red_tcp_in" ]] && echo "        iifname \"$RED_IF\"   tcp dport { $red_tcp_in }   accept comment \"RED inbound TCP\"" )
+        $( [[ -n "$red_udp_in" ]] && echo "        iifname \"$RED_IF\"   udp dport { $red_udp_in }   accept comment \"RED inbound UDP\"" )
         $( [[ -n "$GREEN_IF" ]]  && echo "iifname \"$GREEN_IF\"  ip saddr @green_net  tcp dport { $green_tcp_in }  accept comment \"GREEN inbound (source-verified)\"" )
         $( [[ -n "$GREEN_IF" ]]  && echo "iifname \"$GREEN_IF\"  ip saddr @green_net  udp dport { $green_udp_in }  accept comment \"GREEN inbound (source-verified)\"" )
         $( [[ -n "$ORANGE_IF" ]] && echo "iifname \"$ORANGE_IF\" ip saddr @orange_net tcp dport { $orange_tcp_in } accept comment \"ORANGE inbound (source-verified)\"" )
@@ -269,8 +272,10 @@ table inet ${TABLE_NAME} {
         ct state established,related accept
         ct state invalid drop
 
-        # SYN flood protection on RED ingress (synproxy).
-        iifname "$RED_IF" tcp flags syn notrack accept comment "synproxy: pass new SYN to synproxy chain"
+        # Flood control on RED ingress: drop the excess, never blanket
+        # accept — the DMZ port-forwards below are the only RED -> ORANGE
+        # paths and each one carries an explicit dport + daddr.
+        iifname "$RED_IF" ct state new limit rate over 100/second burst 200 packets counter drop comment "RED new-connection flood limit"
 
         # ── Zone-to-zone matrix (source-verified) ──────────────────────
         # GREEN -> RED  : allow
@@ -311,12 +316,6 @@ EOF
 
     cat </dev/null || true
 
     log_info "Loading..."
     if "$NFT_CMD" -f "$RULES_FILE"; then
diff --git a/firewall/templates/vps-webserver.sh b/firewall/templates/vps-webserver.sh
index edb5ced..3cbd9e5 100755
--- a/firewall/templates/vps-webserver.sh
+++ b/firewall/templates/vps-webserver.sh
@@ -605,7 +605,10 @@ generate_rules() {
 # SSH: ${SSH_DETECTED:+:${SSH_PORT}} ${FORGEJO_DETECTED:+Forgejo SSH: :${FORGEJO_SSH_PORT}}
 # Web: ${VARNISH_DETECTED:+Varnish:${VARNISH_PORT} -> }Caddy:${CADDY_HTTP_PORT}/${CADDY_HTTPS_PORT}
 
-flush ruleset
+# Table-scoped reset: foreign tables (docker, libvirt, systemd-networkd)
+# are not ours to destroy.
+add table inet ${TABLE_NAME}
+flush table inet ${TABLE_NAME}
 
 table inet ${TABLE_NAME} {
 
@@ -699,16 +702,17 @@ table inet ${TABLE_NAME} {
         ip6 nexthdr icmpv6 drop comment "ICMPv6 rejected"
 
         # ---- Connection rate limiting ----
-        ct state new limit rate 50/second burst 100 accept
-        ct state new add @connlimit_abuse ip saddr drop comment "Connlimit exceeded"
+        ct state new limit rate over 50/second burst 100 add @connlimit_abuse { ip saddr } counter drop comment "Connlimit exceeded"
 
         # ---- SSH with brute-force protection ----
         $(if [[ "$SSH_DETECTED" == "yes" ]]; then echo "
         # SSH - aggressive protection (pubkey-only assumed)
         tcp dport $SSH_PORT ip saddr @ssh_abuse drop comment \"SSH banned\"
-        tcp dport $SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 \
-            $(if [[ "$SYNPROXY_ENABLED" == "yes" ]]; then echo "synproxy mss 1460 wscale 7 timestamp sack-perm"; else echo "accept"; fi)
-        tcp dport $SSH_PORT ct state new add @ssh_abuse ip saddr drop comment \"SSH brute force\"
+        # A verdict-less synproxy statement here let every in-rate SSH
+        # login fall through to the ban rule below; the rate limit +
+        # ban set is the protection this chain ships.
+        tcp dport $SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept
+        tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"SSH brute force\"
         "; fi)
 
         # ---- Forgejo SSH (separate from system SSH) ----
@@ -716,7 +720,7 @@ table inet ${TABLE_NAME} {
         # Forgejo SSH
         tcp dport $FORGEJO_SSH_PORT ip saddr @ssh_abuse drop comment \"Forgejo SSH banned\"
         tcp dport $FORGEJO_SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept
-        tcp dport $FORGEJO_SSH_PORT ct state new add @ssh_abuse ip saddr drop comment \"Forgejo SSH brute force\"
+        tcp dport $FORGEJO_SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"Forgejo SSH brute force\"
         "; fi)
 
         # ---- Public TCP services ----
diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD
index 860c7fc..c0e224a 100755
--- a/packaging/PKGBUILD
+++ b/packaging/PKGBUILD
@@ -3,7 +3,7 @@
 # Upstream: https://dcos.net
 
 pkgname=sysdeck
-pkgver=0.4.4
+pkgver=0.4.5
 pkgrel=1
 pkgdesc="Unified operations surface for Linux infrastructure — the Cockpit plugin edition: 27 domain modules plus the Python bridge (the standalone web console ships in the master tarball)"
 arch=('any')
@@ -43,35 +43,15 @@ optdepends=(
 )
 makedepends=('make')
 backup=()
+install=sysdeck.install
 source=("${pkgname}-${pkgver}.tar.bz2")
-sha256sums=('SKIP')  # Replace with actual hash for release
+sha256sums=('e579dc82aca5f3def1d6a3df6bcc7671b9dd3b30e78dd710f6ea14a93f3c176b')
 
 package() {
     cd "${srcdir}/${pkgname}-${pkgver}"
     make install DESTDIR="${pkgdir}"
-
-    # Arch-specific: ensure cockpit can find the bridge Python package.
-    # The bridge installs to /usr/lib/sysdeck/bridge/; add a symlink
-    # from the Arch Python site-packages so `python3 -m sysdeck.bridge.*`
-    # resolves correctly from the cockpit spawn context.
-    local site_packages
-    site_packages=$(python3 -c "import site; print(site.getsitepackages()[0])")
-    install -d "${pkgdir}${site_packages}"
-    ln -sf /usr/lib/sysdeck/bridge "${pkgdir}${site_packages}/sysdeck"
-}
-
-post_install() {
-    # Restart cockpit.socket so the new plugin appears in the menu.
-    systemctl try-restart cockpit.socket 2>/dev/null || true
-    echo ">>> SysDeck installed. Restart cockpit.socket if not done automatically."
-    echo ">>>   sudo systemctl restart cockpit.socket"
-}
-
-post_upgrade() {
-    systemctl try-restart cockpit.socket 2>/dev/null || true
-}
-
-post_remove() {
-    systemctl try-restart cockpit.socket 2>/dev/null || true
-    echo ">>> SysDeck removed. Restart cockpit.socket to flush the menu."
+    # No python site-packages symlink: the bridge's invocation contract
+    # is `python3 /usr/lib/sysdeck/bridge/.py ` by absolute
+    # path (check-no-broken-python-module enforces it), so no import
+    # path needs to resolve.
 }
diff --git a/packaging/debian/changelog b/packaging/debian/changelog
index 8c01ace..4b3ffe9 100755
--- a/packaging/debian/changelog
+++ b/packaging/debian/changelog
@@ -1,3 +1,49 @@
+sysdeck (0.4.5-1) unstable; urgency=medium
+
+  * PRODUCTION-HARDENING RELEASE — full MoE QA pass (web designers,
+    backend, JS/React/Next, CSS, UI/UX, algorithms, Linux systems,
+    DevOps).
+  * Build: the web-dev recipe is attached to its own target (a spliced
+    recipe ran fester + the dev console from uninstall-branding); master
+    tarball excludes dev/server logs and .env; dist is reproducible
+    (sorted, pinned mtime/owner) and gated on a clean git tree when one
+    exists; distcheck uses mktemp; recipes quote every rm path.
+  * Bridge: prometheus push-log validates the module filename and escapes
+    exposition labels (root-level path traversal + metric-line
+    injection closed); db query admits one read-only statement (no
+    batches, no CLI meta-commands; sqlite refuses honestly instead of
+    querying nothing); glances snapshot family degrades to
+    {available:false} with a timeout; package mutations survive the
+    600s timeout; vmdb2 builds carry the mkosi output-dir guard; theme
+    variable values are allowlisted against CSS injection; auth/vault/
+    firmware/fleet/integrity helpers run with hard timeouts; kerberos
+    status derives from the real TGT end time; unwhitelist matches
+    exactly; modules3p renders usage errors as JSON.
+  * Web console: theme switching goes through applySdTheme (light
+    themes keep their palette, the mirror stays in sync); cockpit
+    modules table renders valid rows; overview reports the registry
+    version and a live tarball link; fester health probes are cached
+    and single-flight; usePoll rejects stale responses; the session
+    clock is hydration-safe; tsc + eslint run as build gates (both
+    green).
+  * Firewall templates: all six nftables rulesets validate
+    structurally; table-scoped flush replaces `flush ruleset` on every
+    template (docker/libvirt tables survive an apply); no-services
+    gains loopback accept, valid jump syntax, braced set-adds, and the
+    SSH-port fix that previously locked operators out; vps-webserver SSH
+    rule carries an explicit verdict; the Cilium default policy scopes
+    its HTTP method filter to 80/443 and resolves DNS in standalone
+    mode.
+  * Packaging: RPM %files matches the 27-plugin install and the spec
+    builds noarch; debian gains the nodejs build dependency and stops
+    recommending media/virtualization stacks; pacman hooks live in
+    sysdeck.install; AppStream addon extends the cockpit component;
+    Caddyfile's port gateway is a 3010 allowlist.
+  * Comments state standing decisions in the present tense throughout
+    the first-party tree.
+
+ -- Jeremy Anderson   Thu, 17 Sep 2026 10:00:00 -0400
+
 sysdeck (0.4.4-1) unstable; urgency=medium
 
   * v0.4.4: ten package-manager backends on both editions + the blog
@@ -125,9 +171,9 @@ sysdeck (0.2.0-1) unstable; urgency=medium
     real REST client (11 subcommands, FESTER_URL override, graceful
     offline JSON); plugins/sysdeck-fester is a full panel; the shared
     bridge.js fester surface grew from 1 method to 11.
-  * Makefile: restored tab-indented recipes (the shipped 0.1.3 Makefile
-    used 8-space indents and GNU make rejected it with "missing
-    separator"); new targets fester-start, web-install, web-dev, master.
+  * Makefile: recipes are tab-indented (GNU make rejects 8-space indents
+    with "missing separator"; a build-time guard enforces it); new targets
+    fester-start, web-install, web-dev, master.
 
  -- Jeremy Anderson   Thu, 20 Aug 2026 12:00:00 -0400
 
@@ -1860,7 +1906,7 @@ sysdeck (0.0.35-1) unstable; urgency=medium
     to plugins/sysdeck-kata/, converted from a tools-section
     manifest entry to a menu-section entry (label "SysDeck Kata",
     order 27), removed the "hidden helper" wording, dropped the
-    priority -1, and restored a dedicated keywords list. The
+    priority -1, and carries a dedicated keywords list. The
     Containers panel now manages Podman only — the Kata tab and
     its iframe were removed. The pre-built cockpit-kata React
     bundle (index.js + index.css) is shipped unchanged.
@@ -2073,11 +2119,10 @@ sysdeck (0.0.33-1) unstable; urgency=medium
     mkdir, mount, ip, bpftool, lsns, aa-enforce, aa-complain,
     aa-status).
   * DOCUMENTATION REWRITE. README.md, QUICKSTART.md, BLOG.md, and
-    LICENSE rewritten with decisive language. Every design choice
-    is documented as a decision, not as history. Removed "restored"
-    / "brought back" / "was dropped" / "surviving artifact" wording
-    from active code comments and current-version docs (historical
-    release narratives in BLOG.md are preserved as record).
+    LICENSE state every design choice as a standing decision in the
+    present tense; active code comments and current-version docs
+    carry no development-churn narration (release history stays in
+    the changelog, where it belongs).
   * STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33
     entry — three options (implement anyway / skip entirely / detect
     only) considered; option 2 won because it composes best with the
@@ -2124,10 +2169,9 @@ sysdeck (0.0.32-1) unstable; urgency=medium
     probably around version 18 if i had to guess." Confirmed via
     BLOG.md — the DB Control module was added in v0.0.15 alongside
     Prometheus and Grafana. The v0.0.20 architectural overhaul
-    split SysDeck into 18 standalone plugins and the DB plugin
-    panel got dropped in the cut — only 18 made the list, even
-    though the bridge helper survived. v0.0.32 restores the
-    plugin panel.
+    split SysDeck into 18 standalone plugins and the DB panel
+    did not make that list of 18, even though its bridge helper
+    remained in the tree. v0.0.32 ships the plugin panel.
     v0.0.32 also fixes the v0.0.15-era `sudo systemctl` shell-out
     in cmd_start / cmd_stop / cmd_restart — the cockpit way (per
     v0.0.31 pattern) is to run systemctl directly via subprocess
@@ -2406,9 +2450,9 @@ sysdeck (0.0.28-1) unstable; urgency=high
     Cross-checks every bridgeCmd("", ["", ...]) call in
     shared/bridge.js against the COMMANDS dict declared in each
     bridge/.py. Would have caught both bugs above.
-    Regression-tested: reverting firmware.py to its v0.0.27 state
-    causes the guard to fail with a clear message naming the file,
-    line, and missing subcommand.
+    Negative-tested: a summary-only firmware.py (the v0.0.27 shape)
+    fails the guard with a clear message naming the file, line, and
+    missing subcommand.
   * FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
     .suite-progress, .suite-progress-bar, .suite-progress-fill,
     .suite-stat-value, .suite-stat-label, .suite-row, .suite-row-between,
@@ -2549,8 +2593,8 @@ sysdeck (0.0.23-1) unstable; urgency=high
     `import cockpit from "cockpit"` to `module.exports = cockpit`.
   * New guard: check-no-broken-cockpit-import in `make check` scans every
     JS file in plugins/ and shared/ for the broken `import cockpit from`
-    pattern. Regression-tested: deliberately reintroducing the v0.0.22
-    import causes `make check` to fail with a clear message.
+    pattern. Negative-tested: the v0.0.22 import form makes `make
+    check` fail with a clear message.
 
  -- Jeremy Anderson   Sun, 17 Aug 2026 09:00:00 -0500
 
@@ -2707,10 +2751,12 @@ sysdeck (0.0.16-1) unstable; urgency=high
 
 sysdeck (0.0.15-1) unstable; urgency=low
 
-  * RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py
-    silently dropped from v0.0.13 release; restored here.
-  * RESTORE: nextjs-dashboard/ and prometheus/ directories restored
-    to source tree and included in tarball + install target.
+  * SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and
+    bridge/prometheus.py ship in the tarball (the v0.0.13 tarball
+    omitted them).
+  * SOURCE COMPLETENESS: nextjs-dashboard/ and prometheus/
+    directories ship in the source tree, tarball, and install
+    target.
   * Makefile install target now installs prometheus configs to
     /etc/sysdeck/prometheus/ and Next.js dashboard to
     /usr/share/sysdeck/nextjs-dashboard/.
diff --git a/packaging/debian/control b/packaging/debian/control
index 98f8deb..cc4bef3 100755
--- a/packaging/debian/control
+++ b/packaging/debian/control
@@ -2,7 +2,9 @@ Source: sysdeck
 Section: admin
 Priority: optional
 Maintainer: Jeremy Anderson 
-Build-Depends: debhelper-compat (= 13), make, python3 (>= 3.9)
+# nodejs: `make check` syntax-checks every shipped JS file via
+# `node --check` — a clean sbuild chroot needs it at build time.
+Build-Depends: debhelper-compat (= 13), make, python3 (>= 3.9), nodejs
 Standards-Version: 4.7.0
 Homepage: https://dcos.net
 Vcs-Browser: https://dcos.net
@@ -11,8 +13,8 @@ Vcs-Git: https://dcos.net/sysdeck.git
 Package: sysdeck
 Architecture: all
 Depends: cockpit-bridge (>= 239), python3 (>= 3.9), ${misc:Depends}
-Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream, kata-containers, jellyfin
-Suggests: kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server
+Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream
+Suggests: kata-containers, jellyfin, kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server
 Description: Unified operations surface for Linux infrastructure
  SysDeck is a standalone Linux operations console that also ships as a
  Cockpit plugin suite. This package installs the Cockpit plugin edition:
diff --git a/packaging/setup.py b/packaging/setup.py
index a983145..ebae114 100755
--- a/packaging/setup.py
+++ b/packaging/setup.py
@@ -24,7 +24,7 @@ import shutil
 import subprocess
 from setuptools import setup
 
-VERSION = "0.4.4"
+VERSION = "0.4.5"
 PACKAGE = "sysdeck"
 
 # The repository root (setup.py lives in packaging/).
diff --git a/packaging/sysdeck.install b/packaging/sysdeck.install
new file mode 100644
index 0000000..0bab273
--- /dev/null
+++ b/packaging/sysdeck.install
@@ -0,0 +1,17 @@
+# SysDeck pacman hooks
+# Restart cockpit.socket so plugin changes appear in the menu.
+
+post_install() {
+    systemctl try-restart cockpit.socket 2>/dev/null || true
+    echo ">>> SysDeck installed. Restart cockpit.socket if not done automatically:"
+    echo ">>>   sudo systemctl restart cockpit.socket"
+}
+
+post_upgrade() {
+    systemctl try-restart cockpit.socket 2>/dev/null || true
+}
+
+post_remove() {
+    systemctl try-restart cockpit.socket 2>/dev/null || true
+    echo ">>> SysDeck removed. Restart cockpit.socket to flush the menu."
+}
diff --git a/packaging/sysdeck.metainfo.xml b/packaging/sysdeck.metainfo.xml
index 6b97edd..6330c7e 100755
--- a/packaging/sysdeck.metainfo.xml
+++ b/packaging/sysdeck.metainfo.xml
@@ -32,6 +32,7 @@
 -->
 
   net.dcos.sysdeck
+  org.cockpit_project.cockpit
   CC0-1.0
   MIT
   SysDeck
@@ -80,7 +81,7 @@
        plugin's manifest.json. The cockpit apps page (pkg/apps/utils.tsx:152)
        reads launchable.type == "cockpit-manifest" and uses the text to
        link the AppStream component to the corresponding Cockpit plugin.
-       v0.0.35: SysDeck Kata restored as its own sidebar entry; added
+       v0.0.35: SysDeck Kata as its own sidebar entry; added
        sysdeck-jellyfin, sysdeck-photos, sysdeck-remotefs.
        v0.0.46: added sysdeck-modules (in-suite 3rd-party module installer).
        v0.0.47: added sysdeck-services (service/port editor promoted to
@@ -131,6 +132,18 @@
   
   
   
+    
+      
+        

v0.4.5: production-hardening release from a full + Mixture-of-Experts QA pass — security fixes in the bridge + (filename validation, single-statement SQL guard, CSS value + allowlist, subprocess timeouts), six structurally validated + nftables firewall templates with table-scoped flush, the web + console enforcing its type and lint gates, and packaging that + builds cleanly on all three distro paths. Comments state + standing decisions in the present tense.

+
+

v0.4.4: ten package managers on both editions — pacman, @@ -660,7 +673,7 @@ plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/, converted from a "tools" manifest entry to a "menu" entry (label "SysDeck Kata", order 27), removed the "hidden helper" - wording, dropped the priority -1, and restored a dedicated + wording, dropped the priority -1, and added a dedicated keywords list. The Containers panel now manages Podman only — the Kata tab and its iframe were removed. The pre-built cockpit-kata React bundle (index.js + index.css) is shipped @@ -814,7 +827,7 @@

NEW BUILD-TIME GUARD: `check-bridge-subcommands` in `make check`. Cross-checks every bridgeCmd() call in shared/bridge.js against the COMMANDS dict declared in each bridge/<module>.py. Would have - caught both bugs above. Regression-tested: reverting firmware.py to + caught both bugs above. Negative-tested: a summary-only firmware.py its v0.0.27 state causes the guard to fail with a clear message.

FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing .suite-progress, .suite-progress-bar, .suite-progress-fill, diff --git a/packaging/sysdeck.spec b/packaging/sysdeck.spec index 2c75d56..a532630 100755 --- a/packaging/sysdeck.spec +++ b/packaging/sysdeck.spec @@ -7,16 +7,18 @@ # Debian/Ubuntu users: see packaging/debian/ Name: sysdeck -Version: 0.4.4 +Version: 0.4.5 Release: 1%{?dist} Summary: Unified operations surface for Linux infrastructure License: MIT URL: https://dcos.net Source0: %{name}-%{version}.tar.bz2 +BuildArch: noarch BuildRequires: make BuildRequires: python3-devel +BuildRequires: nodejs Requires: cockpit-bridge >= 239 Recommends: podman Recommends: nftables @@ -27,10 +29,10 @@ Recommends: tpm2-tools Recommends: glances Recommends: lm_sensors Recommends: sysbench -Recommends: kata-containers -Recommends: jellyfin -Recommends: ceph -Recommends: glusterfs +Suggests: kata-containers +Suggests: jellyfin +Suggests: ceph +Suggests: glusterfs %description SysDeck is a standalone Linux operations console that also ships as a @@ -62,16 +64,19 @@ make install DESTDIR=%{buildroot} %files %license LICENSE %doc README.md QUICKSTART.md BLOG.md QA.md -/usr/share/cockpit/%{name}/manifest.json -/usr/share/cockpit/%{name}/index.html -/usr/share/cockpit/%{name}/suite.js -/usr/share/cockpit/%{name}/suite.css -/usr/share/cockpit/%{name}/logo.svg -/usr/share/cockpit/%{name}/src/ -/usr/lib/%{name}/bridge/ -/usr/lib/%{name}/tests/ +# The install target ships: 27 plugins under +# /usr/share/cockpit/sysdeck-*/ (manifest.json, index.html, module js), +# the sysdeck-common bridge library, the Python bridge at +# /usr/lib/sysdeck/bridge/, tests, docs, share assets (diagnose + +# smoke + uninstall scripts, firewall templates + policies, prometheus +# configs), AppStream metainfo, and both polkit actions. +/usr/share/cockpit/sysdeck-*/ +/usr/lib/%{name}/ +/usr/share/%{name}/ +/usr/share/doc/%{name}/ /usr/share/metainfo/sysdeck.metainfo.xml /usr/share/polkit-1/actions/org.sysdeck.policy +/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy %post # Restart cockpit.socket so the new plugin appears in the menu. @@ -87,6 +92,23 @@ if [ $1 -eq 0 ]; then fi %changelog +* Thu Sep 17 2026 Jeremy Anderson - 0.4.5-1 +- v0.4.5 production-hardening release: full MoE QA pass. Makefile + web-dev splice fixed; reproducible dist + clean-tree release gate; + master tarball hygiene (no dev logs, no .env). Bridge security: + prometheus push-log filename validation + label escaping, single- + statement read-only SQL guard, glances availability step-down, + mutation timeout safety, vmdb2 output-dir guard, theme CSS value + allowlist, subprocess timeouts across helpers. Web: applySdTheme on + every theme path, valid table rows, registry-sourced version + surface, cached fester probes, tsc+eslint as build gates. Firewall: + six structurally validated nftables rulesets, table-scoped flush + everywhere, no-services loopback+SSH fixes, vps SSH verdict fix, + Cilium policy scoping. Packaging: noarch spec with %files matching + the 27-plugin install, nodejs build dependency, sysdeck.install + hooks, AppStream extends, Caddyfile port allowlist. Decisive + comment language across the first-party tree. + * Sat Sep 12 2026 Jeremy Anderson - 0.4.4-1 - v0.4.4: package parity + blog essay. Ten package-manager backends on both editions (bridge/packages.py gains emerge/lunar/sorcery/xbps/ @@ -134,7 +156,6 @@ fi HMAC session cookie, gated API routes, fester WS session check, audited logins) — the 0.3.0 audit follow-through. -%changelog * Fri Sep 11 2026 Jeremy Anderson - 0.3.0-1 - v0.3.0 AI GATEWAY EDITION: klanker-gate (Frosty Deno LLM gateway, independent version 0.9.0) vendored at /klanker-gate with full Arch @@ -153,9 +174,9 @@ fi - bridge/fester.py: real REST client (11 subcommands) replacing the v0.0.31 systemd-listing stub; fester panel fully built; bridge.js fester surface 1 -> 11 methods. -- Makefile: tab-indented recipes restored (0.1.3 shipped 8-space indents - that GNU make rejected); new targets fester-start, web-install, - web-dev, master. +- Makefile: recipes are tab-indented (GNU make rejects the 8-space + indent form; a build-time guard enforces tabs); new targets + fester-start, web-install, web-dev, master. * Tue Aug 19 2026 Jeremy Anderson - 0.1.3-1 - v0.1.3 CRITICAL FIX: host package import was silently failing + mkosi still wasn't reading the profile config. Two root causes fixed, plus @@ -1030,11 +1051,10 @@ fi setcap, getcap (in addition to v0.0.32 set: setfacl, getfacl, mkdir, mount, ip, bpftool, lsns, aa-enforce, aa-complain, aa-status). - DOCUMENTATION REWRITE: README.md, QUICKSTART.md, BLOG.md, LICENSE - rewritten with decisive language. Every design choice recorded as a - decision, not as history. Removed "restored/brought back/was dropped/ - surviving artifact" wording from active code comments and current- - version docs (historical release narratives in BLOG.md preserved as - record). + state every design choice as a standing decision in the present + tense; active code comments and current-version docs carry no + development-churn narration (release history stays in the changelog, + where it belongs). - STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33 entry — three options considered (implement / skip / detect-only); option 2 won because it composes best with the rest of the system. @@ -1287,8 +1307,8 @@ fi real data. With v0.0.26 the other 15 modules should now load real data. - New guard: check-no-broken-python-module in `make check` scans every JS file for spawn calls using `python3 -m sysdeck.bridge` and fails with a - clear message. Regression-tested: deliberately reintroducing the broken - pattern causes `make check` to fail. + clear message. Negative-tested: the broken pattern makes `make check` + fail. * Sun Aug 17 2026 Jeremy Anderson - 0.0.25-1 - ROOT CAUSE FOUND AND FIXED: every plugin page showed "Module load @@ -1354,9 +1374,8 @@ fi rewrites `import cockpit from "cockpit"` to `module.exports = cockpit`. - New guard: check-no-broken-cockpit-import in `make check` scans every JS file in plugins/ and shared/ for the broken `import cockpit from` - pattern. Regression-tested: deliberately reintroducing the v0.0.22 - import causes `make check` to fail with a clear message citing the - cockpit source code. + pattern. Negative-tested: the v0.0.22 import form makes `make check` + fail with a clear message citing the cockpit source code. * Sun Aug 17 2026 Jeremy Anderson - 0.0.22-1 - ROOT CAUSE FOUND AND FIXED: the `requires.cockpit` field was set to @@ -1516,17 +1535,17 @@ fi at build time. * Sun Aug 17 2026 Jeremy Anderson - 0.0.15-1 -- RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py - were silently dropped from the v0.0.13 release tarball and shipped - missing through v0.0.14. All three are restored (1489 lines of code). -- RESTORE: nextjs-dashboard/ directory (standalone Next.js variant - dashboard) and prometheus/ config directory (Prometheus + Grafana - YAML configs) are restored to the source tree. +- SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and + bridge/prometheus.py ship in the tarball (the v0.0.13-v0.0.14 + tarballs omitted them; 1489 lines of code). +- SOURCE COMPLETENESS: nextjs-dashboard/ directory (standalone Next.js + variant dashboard) and prometheus/ config directory (Prometheus + + Grafana YAML configs) ship in the source tree. - Makefile dist target now includes prometheus/ and nextjs-dashboard/. - Makefile install target now installs prometheus configs to /etc/sysdeck/prometheus/ and the Next.js dashboard to /usr/share/sysdeck/nextjs-dashboard/. -- Tarball size restored to ~280 KB (was 80 KB in v0.0.14 due to +- Tarball size back at ~280 KB (v0.0.14 shipped an 80 KB tarball due to the dropped code). * Sun Aug 17 2026 Jeremy Anderson - 0.0.14-1 diff --git a/plugins/sysdeck-auth/index.html b/plugins/sysdeck-auth/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-auth/manifest.json b/plugins/sysdeck-auth/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-benchmark/index.html b/plugins/sysdeck-benchmark/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-benchmark/manifest.json b/plugins/sysdeck-benchmark/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-builder/builder.js b/plugins/sysdeck-builder/builder.js index 7b2ed58..b787e46 100755 --- a/plugins/sysdeck-builder/builder.js +++ b/plugins/sysdeck-builder/builder.js @@ -120,8 +120,8 @@ export async function mount(panel, { bridge, EventBus }) {

Image Builder

${primary - ? `${primary.id} ${primary.version || ''} — ${summary.state}` - : `no backend installed — ${summary.state}`} + ? `${escapeHtml(primary.id)} ${escapeHtml(primary.version || '')} — ${escapeHtml(summary.state)}` + : `no backend installed — ${escapeHtml(summary.state)}`} · ${builds.length} build${builds.length === 1 ? '' : 's'} tracked · ${artifacts.artifacts || 0} artifact${(artifacts.artifacts || 0) === 1 ? '' : 's'}

@@ -172,10 +172,10 @@ function renderBackends(backends, primary) {
    ${backends.map(b => `
  • - ${b.id} + ${escapeHtml(b.id)} ${b.version ? ` ${escapeHtml(b.version)}` : ''} ${b.id === (primary && primary.id) ? 'primary' : ''} - (${b.kind}) + (${escapeHtml(b.kind)})
  • `).join('')}
@@ -309,7 +309,7 @@ function renderCreateProfile(backends, primary) { `; } const backendOptions = scaffoldable - .map(b => ``).join(''); + .map(b => ``).join(''); return `
@@ -820,22 +820,14 @@ function wireEvents(panel, { bridge, EventBus }) { btn.disabled = true; btn.textContent = '⏳ ...'; try { - // Read the file via cockpit.spawn cat. We use binary mode - // by reading as a binary stream. cockpit.spawn returns a - // channel that we collect into a byte array. - const channel = cockpit.spawn(["cat", path], { + // cockpit.spawn returns a promise; in binary mode it + // resolves to the full byte payload — the documented API, + // not the low-level channel surface. + const data = await cockpit.spawn(["cat", path], { superuser: "try", binary: true, }); - const chunks = []; - channel.ondata = (data) => { chunks.push(data); }; - await new Promise((resolve, reject) => { - channel.onclose = (resp) => { - if (resp.exit_status === 0 || resp.exit_status === null) resolve(); - else reject(new Error(`cat exited ${resp.exit_status}`)); - }; - }); - const blob = new Blob(chunks, { type: 'application/octet-stream' }); + const blob = new Blob([data], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; diff --git a/plugins/sysdeck-builder/index.html b/plugins/sysdeck-builder/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-builder/manifest.json b/plugins/sysdeck-builder/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-containers/containers.js b/plugins/sysdeck-containers/containers.js index 3641288..24e357e 100755 --- a/plugins/sysdeck-containers/containers.js +++ b/plugins/sysdeck-containers/containers.js @@ -8,10 +8,8 @@ * user directive: "kata containers should be called SysDeck Kata and * moved out of the tools area." * - * v0.0.34 was a merged two-tab panel (Podman + Kata iframe). The - * v0.0.35 split restores the one-module-one-concern shape: this - * panel manages Podman containers only; the SysDeck Kata plugin - * hosts the pre-built cockpit-kata React app for Kata sandboxes & VMs. + * One module, one concern: this panel manages Podman containers + * only; the SysDeck Kata plugin owns Kata sandboxes & VMs. * * Bridge surface (see shared/bridge.js → bridge.containers): * list() → podman ps --format json (normalized) @@ -21,7 +19,6 @@ */ export async function mount(panel, { bridge, EventBus }) { - panel.innerHTML = renderSkeleton(); panel.innerHTML = renderShell(); await renderPodmanTable(panel, { bridge, EventBus }); EventBus.emit('containers.loaded', {}); @@ -40,14 +37,15 @@ function renderShell() {

Loading containers…

+

- Kata Containers (hardware-virtualized OCI sandboxes) is now a - standalone sidebar entry — SysDeck Kata. Open it - to manage Kata sandboxes & VMs from the pre-built React app. + Kata Containers (hardware-virtualized OCI sandboxes) lives in its + own sidebar entry — SysDeck Kata — real sandbox + and VM state from the host, not a bundle mock.

@@ -90,6 +88,15 @@ async function renderPodmanTable(panel, { bridge, EventBus }) { EventBus.emit('container.action', { id, action }); } catch (err) { EventBus.emit('container.error', { id, error: err.message }); + // the operator sees the failure where they clicked it — + // the table re-render alone would silently swallow it + const flash = panel.querySelector('#containers-flash'); + if (flash) { + flash.textContent = `action failed: ${err.message || err}`; + flash.style.display = 'inline-block'; + clearTimeout(panel._containersFlashTimer); + panel._containersFlashTimer = setTimeout(() => { flash.style.display = 'none'; }, 4000); + } } renderPodmanTable(panel, { bridge, EventBus }); }); diff --git a/plugins/sysdeck-containers/index.html b/plugins/sysdeck-containers/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-containers/manifest.json b/plugins/sysdeck-containers/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-db/db.js b/plugins/sysdeck-db/db.js index 19dc218..1cc9f74 100755 --- a/plugins/sysdeck-db/db.js +++ b/plugins/sysdeck-db/db.js @@ -319,6 +319,6 @@ function renderSkeleton() { function renderError(err) { return `

Database bridge unavailable

-

${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.

+

${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.

`; } diff --git a/plugins/sysdeck-db/index.html b/plugins/sysdeck-db/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-db/manifest.json b/plugins/sysdeck-db/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-fester/fester.js b/plugins/sysdeck-fester/fester.js index f634e8b..aa97c0f 100755 --- a/plugins/sysdeck-fester/fester.js +++ b/plugins/sysdeck-fester/fester.js @@ -65,13 +65,17 @@ export async function mount(panel, { bridge, EventBus }) { // Clean up the interval when the panel leaves the DOM // (house pattern from netsec.js). - const observer = new MutationObserver(() => { + // one observer per panel: a re-mount releases the previous one + // instead of stacking body-wide observers on every refresh + if (panel.festerObserver) panel.festerObserver.disconnect(); + if (panel.festerObserver) panel.festerObserver.disconnect(); + panel.festerObserver = new MutationObserver(() => { if (!document.body.contains(panel)) { clearInterval(panel._festerInterval); - observer.disconnect(); + panel.festerObserver.disconnect(); } }); - observer.observe(document.body, { childList: true, subtree: true }); + panel.festerObserver.observe(document.body, { childList: true, subtree: true }); } // ── refresh loop ──────────────────────────────────────────────────── diff --git a/plugins/sysdeck-fester/index.html b/plugins/sysdeck-fester/index.html old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-fester/manifest.json b/plugins/sysdeck-fester/manifest.json old mode 100755 new mode 100644 diff --git a/plugins/sysdeck-firewall/firewall.js b/plugins/sysdeck-firewall/firewall.js index e21206c..c608e70 100755 --- a/plugins/sysdeck-firewall/firewall.js +++ b/plugins/sysdeck-firewall/firewall.js @@ -201,7 +201,7 @@ function renderBackendSelector(backends, excluded, activeBackend, templates, act ? 'installed' : 'not installed'; return ` -