SysDeck 4.4 QA fixes

This commit is contained in:
Jeremy Anderson 2026-09-16 21:37:02 -04:00
parent 3aff21b7b6
commit 7d27d1b5d1
145 changed files with 1465 additions and 1156 deletions

103
Makefile
View File

@ -14,11 +14,11 @@
# /usr/lib/sysdeck/bridge/ (called via cockpit.spawn). # /usr/lib/sysdeck/bridge/ (called via cockpit.spawn).
# #
# Targets: # Targets:
# make install - install all 26 plugins + bridge + scripts + firewall templates # make install - install all 27 plugins + bridge + scripts + firewall templates
# make uninstall - remove all 26 plugins + bridge + scripts # make uninstall - remove all 27 plugins + bridge + scripts
# make check - validate all manifests against cockpit-podman pattern, # make check - validate all manifests against cockpit-podman pattern,
# syntax-check JS/Python/shell sources, run unit tests # 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 clean - remove build artifacts
# make dist - build the source tarball (runs check first) # make dist - build the source tarball (runs check first)
# make distcheck - extract the tarball into a clean dir and run check inside # 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. # Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
PACKAGE := sysdeck PACKAGE := sysdeck
VERSION := 0.4.4 VERSION := 0.4.5
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE) LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
PYTHON_DIR := $(LIB_DIR)/bridge PYTHON_DIR := $(LIB_DIR)/bridge
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE) 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 .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: plugins:
@echo ">>> Regenerating plugins/ and shared/ from $(GENERATOR)" @echo ">>> Verifying plugins/ and shared/ against the catalog in $(GENERATOR)"
python3 $(GENERATOR) python3 $(GENERATOR)
# ─── install: 26 visible plugins + shared/ + bridge + scripts + metainfo ──── # ─── install: 27 visible plugins + shared/ + bridge + scripts + metainfo ────
install: install:
@echo ">>> Installing $(PACKAGE) $(VERSION): 26 standalone Cockpit plugins" @echo ">>> Installing $(PACKAGE) $(VERSION): 27 standalone Cockpit plugins"
# Each plugin: /usr/share/cockpit/sysdeck-<name>/{manifest.json,index.html,<module>.js} # Each plugin: /usr/share/cockpit/sysdeck-<name>/{manifest.json,index.html,<module>.js}
@for plugin in plugins/sysdeck-*; do \ @for plugin in plugins/sysdeck-*; do \
[ -d "$$plugin" ] || continue; \ [ -d "$$plugin" ] || continue; \
@ -166,16 +169,20 @@ install:
install -m 0644 $(POLKIT_FILE) $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy 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) # 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/ install -m 0644 packaging/polkit/org.sysdeck.modules3p.policy $(DESTDIR)/usr/share/polkit-1/actions/
# Reload polkit + refresh AppStream cache. # Host integration (polkit reload, AppStream refresh) runs only
-@if command -v systemctl >/dev/null 2>&1; then \ # on a real install — a DESTDIR staging install (deb/rpm/pacman
systemctl reload polkit 2>/dev/null || true; \ # package build) must never mutate the build host.
fi -@if [ -z "$(DESTDIR)" ]; then \
-@if command -v appstreamcli >/dev/null 2>&1; then \ if command -v systemctl >/dev/null 2>&1; then \
appstreamcli refresh-cache 2>/dev/null || true; \ 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 fi
@echo ">>> Done. Restart cockpit.socket to pick up the new plugins:" @echo ">>> Done. Restart cockpit.socket to pick up the new plugins:"
@echo " sudo systemctl restart cockpit.socket" @echo " sudo systemctl restart cockpit.socket"
@echo ">>> 26 sidebar entries should appear under 'SysDeck <Name>' in Cockpit." @echo ">>> 27 sidebar entries should appear under 'SysDeck <Name>' in Cockpit."
# ─── uninstall: remove EVERY trace of EVERY prior version ────────── # ─── uninstall: remove EVERY trace of EVERY prior version ──────────
# This target is deliberately over-aggressive. It removes: # This target is deliberately over-aggressive. It removes:
@ -212,17 +219,17 @@ uninstall:
rm -rf "$$dir"; \ rm -rf "$$dir"; \
done done
# Python bridge helpers (all versions) # Python bridge helpers (all versions)
rm -rf $(LIB_DIR) rm -rf "$(LIB_DIR)"
# Diagnostic + smoke-test scripts + firewall templates (v0.0.19+) # Diagnostic + smoke-test scripts + firewall templates (v0.0.19+)
# v0.0.31: firewall/templates/*.sh live under here too. # 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+) # Documentation (v0.0.20+)
rm -rf $(DESTDIR)/usr/share/doc/$(PACKAGE) rm -rf "$(DESTDIR)/usr/share/doc/$(PACKAGE)"
# AppStream metainfo (v0.0.17+) # 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+) # 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.policy"
rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy rm -f "$(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy"
# Python site-packages symlink (all versions) # Python site-packages symlink (all versions)
@SITE_PACKAGES=$$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null); \ @SITE_PACKAGES=$$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null); \
if [ -n "$$SITE_PACKAGES" ] && [ -L "$(DESTDIR)$$SITE_PACKAGES/$(PACKAGE)" ]; then \ 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) # - menu keys other than `index` (the magic key)
# - `path` field inside menu entries # - `path` field inside menu entries
check-manifest-consistency: 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 @python3 tests/check_manifest_consistency.py
check-makefile-recipes: check-makefile-recipes:
@ -323,6 +330,23 @@ check-no-broken-python-module:
fi fi
echo " OK: no JS file uses the broken python3 -m sysdeck.bridge pattern" 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: check-version-sync:
@echo ">>> Checking version consistency across release surfaces" @echo ">>> Checking version consistency across release surfaces"
@v=$(VERSION); \ @v=$(VERSION); \
@ -341,7 +365,7 @@ check-version-sync:
done; \ done; \
echo " OK: all release surfaces report v$$v" 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" @echo ">>> Syntax-checking Python sources"
@python3 -m py_compile bridge/*.py bridge/modules/*.py @python3 -m py_compile bridge/*.py bridge/modules/*.py
@echo ">>> Syntax-checking JS sources (node --check)" @echo ">>> Syntax-checking JS sources (node --check)"
@ -367,7 +391,10 @@ clean:
dist: check dist: check
@echo ">>> Building $(PACKAGE)-$(VERSION).tar.bz2" @echo ">>> Building $(PACKAGE)-$(VERSION).tar.bz2"
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true 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='__pycache__' \
--exclude='*.pyc' \ --exclude='*.pyc' \
--exclude='*.tar.bz2' \ --exclude='*.tar.bz2' \
@ -378,21 +405,25 @@ dist: check
sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh
@echo ">>> $(PACKAGE)-$(VERSION).tar.bz2 ready" @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 <package>-<version>/ and # distcheck: verify the tarball extracts into <package>-<version>/ and
# passes `make check` from inside the extracted tree. # passes `make check` from inside the extracted tree.
distcheck: dist distcheck: dist
@echo ">>> Distcheck: extracting $(PACKAGE)-$(VERSION).tar.bz2" @echo ">>> Distcheck: extracting $(PACKAGE)-$(VERSION).tar.bz2"
rm -rf /tmp/sysdeck-distcheck-$$ DISTCHECK_DIR=$$(mktemp -d /tmp/sysdeck-distcheck.XXXXXX)
mkdir -p /tmp/sysdeck-distcheck-$$ tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C $$DISTCHECK_DIR
tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C /tmp/sysdeck-distcheck-$$ @if [ ! -d $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) ]; then \
@if [ ! -d /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) ]; then \
echo "FAIL: tarball did not extract into $(PACKAGE)-$(VERSION)/"; \ echo "FAIL: tarball did not extract into $(PACKAGE)-$(VERSION)/"; \
rm -rf /tmp/sysdeck-distcheck-$$; \ rm -rf $$DISTCHECK_DIR; \
exit 1; \ exit 1; \
fi fi
@echo ">>> Distcheck: running make check inside extracted tree" @echo ">>> Distcheck: running make check inside extracted tree"
@(cd /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) && make check) @(cd $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) && make check)
@rm -rf /tmp/sysdeck-distcheck-$$ @rm -rf $$DISTCHECK_DIR
@echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct." @echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct."
# ─── v0.3.0 master edition: web + fester + klanker-gate ───────────────────────────── # ─── v0.3.0 master edition: web + fester + klanker-gate ─────────────────────────────
@ -413,6 +444,10 @@ web-install:
cd $(FESTER_DIR) && bun install cd $(FESTER_DIR) && bun install
web-dev: web-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 ──── # ─── branding: theme the Cockpit SHELL chrome to the web-edition look ────
# /usr/share/cockpit/branding.css is Cockpit's documented override point # /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"; \ echo " reinstall the cockpit-bridge package to restore defaults"; \
fi 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: rebuild the master tarball from this tree (cockpit + web + fester + klanker-gate)
master: master:
@echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester + klanker-gate)" @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='__pycache__' --exclude='*.pyc' --exclude='*.tar.bz2' \
--exclude='*node_modules*' --exclude='*.next' --exclude='*.tsbuildinfo' \ --exclude='*node_modules*' --exclude='*.next' --exclude='*.tsbuildinfo' \
--exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \ --exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \
--exclude='dev.log' --exclude='server.log' --exclude='*.log' --exclude='.env' \
--transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \ --transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \
bridge plugins shared tests packaging compat standalone-plugins \ bridge plugins shared tests packaging compat standalone-plugins \
prometheus scripts firewall docs web klanker-gate \ prometheus scripts firewall docs web klanker-gate \

213
QA.md
View File

@ -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: direct SdUser row insert with the same scrypt format. Fix options:
rename to `manage-users.ts` or strip the annotations — left as a rename to `manage-users.ts` or strip the annotations — left as a
code change for the next patch release. 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 <sql>`, 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=<any>` 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.

View File

@ -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 live-ruleset tab: `nft -j list ruleset` / `iptables-save`) and the
template catalog covers all seven shipped topologies. template catalog covers all seven shipped topologies.
- The bridge `DataSource` type no longer admits a `'demo'` value — the - 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. render honest empty inventories with install guidance.
### 10.6 The MoE QA pass (v0.4.3) ### 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. class vocabulary, the bridge, and every module are untouched.
```bash ```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 sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css
# also theme the Cockpit SHELL chrome (sidebar, header, login) to match: # also theme the Cockpit SHELL chrome (sidebar, header, login) to match:

View File

@ -1,7 +1,7 @@
# SysDeck # SysDeck
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![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) [![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) [![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)](#) [![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.** **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** · <info@dcos.net> · <https://dcos.net> · [github.com/dcosnet/SysDeck](https://github.com/dcosnet/SysDeck) Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net> · [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) ![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` | | **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://<host>:9090` | | **Cockpit plugin suite** (optional) | Drop the same 27 domain modules into an existing Cockpit install | `/usr/share/cockpit/sysdeck-*/` · `https://<host>: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. 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-<name>/index.html`. The web console discovers the same modules from its own registry. - **Cockpit manifest.** Each plugin's `manifest.json` registers under the `index` menu key; cockpit serves the page at `/cockpit/@localhost/sysdeck-<name>/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. - **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 ## 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) ### Standalone console (default — no Cockpit required)
```bash ```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 cd sysdeck-0.4.4-master
make web-dev # bun install + db:push + fester (:3010) + next dev (:3000) make web-dev # bun install + db:push + fester (:3010) + next dev (:3000)
``` ```

View File

@ -7,11 +7,9 @@ via cockpit.spawn. Each module is standalone and runnable as a CLI:
python3 /usr/lib/sysdeck/bridge/containers.py list python3 /usr/lib/sysdeck/bridge/containers.py list
(v0.0.26+ invocation: absolute path, no PYTHONPATH, no -m flag. Invocation contract: absolute path, no PYTHONPATH, no -m flag.
The earlier `python3 -m sysdeck.bridge.containers` pattern was broken — The installed layout is flat files at /usr/lib/sysdeck/bridge/<mod>.py,
it required a nested Python package layout (sysdeck/bridge/containers.py) and bridge.js calls each helper by absolute path.
that the Makefile install target never produced. bridge.js calls each
helper by absolute path.)
The helpers exist for operations that are too complex for a single CLI The helpers exist for operations that are too complex for a single CLI
call — e.g. cross-referencing podman and systemd, or aggregating TPM call — e.g. cross-referencing podman and systemd, or aggregating TPM
@ -22,7 +20,7 @@ import os
import subprocess import subprocess
from typing import Literal from typing import Literal
__version__ = "0.4.4" __version__ = "0.4.5"
__author__ = "Jeremy Anderson" __author__ = "Jeremy Anderson"
__url__ = "https://dcos.net" __url__ = "https://dcos.net"

View File

@ -55,13 +55,14 @@ KLIST_PRINCIPAL_RE = re.compile(r"^\s*Default principal:\s+(?P<principal>\S+)")
KLIST_TICKET_RE = re.compile(r"^\s*(?P<start>\S+)\s+(?P<end>\S+)\s+(?P<renew>\S+)\s+(?P<kvno>\S+)\s+(?P<principal>\S+)") KLIST_TICKET_RE = re.compile(r"^\s*(?P<start>\S+)\s+(?P<end>\S+)\s+(?P<renew>\S+)\s+(?P<kvno>\S+)\s+(?P<principal>\S+)")
def run(argv: list[str]) -> str: def run(argv: list[str], timeout: int = 20) -> str:
"""Run a command, returning stdout. Returns '' on failure.""" """Run a command, returning stdout. Returns '' on failure."""
try: try:
return subprocess.run( return subprocess.run(
argv, capture_output=True, text=True, check=True, argv, capture_output=True, text=True, check=True, timeout=timeout,
).stdout ).stdout
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
FileNotFoundError):
return "" return ""
@ -92,10 +93,8 @@ def readers() -> list[dict[str, str]]:
def certs() -> dict[str, Any]: def certs() -> dict[str, Any]:
"""PKCS#11 objects of type cert via pkcs11-tool. """PKCS#11 objects of type cert via pkcs11-tool.
v0.1.4: the auth panel's "List Certificates" button used to call a Fixed argv list, no shell — the same spawn discipline every
bridge.spawn() that bridge.js never exported — the button has helper in this suite follows.
always thrown. The listing now lives here (fixed argv list, no
shell), matching every other spawn in this suite.
""" """
if not shutil.which("pkcs11-tool"): if not shutil.which("pkcs11-tool"):
return {"available": False, return {"available": False,
@ -184,12 +183,26 @@ def kerberos() -> list[dict[str, Any]]:
if m: if m:
default_principal = m.group("principal") 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: if default_principal:
user, realm = default_principal.split("@") if "@" in default_principal else (default_principal, "") user, realm = default_principal.split("@") if "@" in default_principal else (default_principal, "")
principals.append({ principals.append({
"principal": default_principal, "principal": default_principal,
"realm": realm, "realm": realm,
"kdc": "", "kdc": "",
"startTime": tgt_start,
"endTime": tgt_end,
}) })
return principals return principals
@ -236,13 +249,21 @@ def identities() -> dict[str, Any]:
# Kerberos principals as identity objects # Kerberos principals as identity objects
for i, p in enumerate(krb): 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({ all_identities.append({
"id": f"krb-{i}", "id": f"krb-{i}",
"type": "kerberos-principal", "type": "kerberos-principal",
"name": p.get("principal", f"principal-{i}"), "name": p.get("principal", f"principal-{i}"),
"status": "active" if p.get("endTime") else "expired", "status": status,
"createdAt": p.get("startTime", ""), "createdAt": p.get("startTime", ""),
"expiresAt": p.get("endTime", ""), "expiresAt": end,
"details": p, "details": p,
}) })

View File

@ -140,7 +140,7 @@ def _parse_sysbench(output: str) -> dict[str, Any]:
result["events_per_sec"] = float(line.split(":")[-1].strip()) result["events_per_sec"] = float(line.split(":")[-1].strip())
except ValueError: except ValueError:
pass 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() parts = line.split()
for i, p in enumerate(parts): for i, p in enumerate(parts):
if p == "avg:" and i + 1 < len(parts): if p == "avg:" and i + 1 < len(parts):

View File

@ -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 available build profiles / image specs without knowing which tool the
operator picked. operator picked.
v0.0.30 REWRITE: the previous version was a thin `systemctl is-active Target distros: Arch Linux and Debian (the decision that shapes
osbuild-composer.service` shim — which is Fedora/RHEL-only and silently every backend choice below):
returns 'inactive' on Arch and Debian. Per the user's directive, the
target distros are now Arch Linux and Debian:
- Arch Linux → mkosi (systemd's own image builder; pacman -S mkosi) - Arch Linux → mkosi (systemd's own image builder; pacman -S mkosi)
↘ archiso (Arch Live ISO builder; pacman -S archiso) ↘ archiso (Arch Live ISO builder; pacman -S archiso)
- Debian → vmdb2 (Debian project's image builder; apt install vmdb2) - Debian → vmdb2 (Debian project's image builder; apt install vmdb2)
↘ live-build (Debian Live ISO builder; apt install live-build) ↘ live-build (Debian Live ISO builder; apt install live-build)
Fedora/RHEL (osbuild-composer / composer-cli) is no longer targeted. Fedora/RHEL (osbuild-composer / composer-cli) is out of scope:
osbuild-composer is not packaged for Arch or Debian, so the v0.0.29 osbuild-composer is not packaged for Arch or Debian. mkosi and vmdb2
panel was permanently 'inactive' on every distro this suite ships to. are the canonical equivalents and are invoked as
mkosi and vmdb2 are the canonical equivalents and are invoked as
separate processes via subprocess — the suite (MIT) and the image separate processes via subprocess — the suite (MIT) and the image
builders remain independent programs. No builder code is bundled. 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/ # /etc/live-build/, ~/.config/live-build/
MKOSI_DIRS = [ MKOSI_DIRS = [
# v0.1.0: per-profile directories under /etc/mkosi/profiles/<name>/ # Per-profile directories under /etc/mkosi/profiles/<name>/ are the
# are the primary location. Each profile gets its own directory # primary location. Each profile carries a real `mkosi.conf` — the
# containing a real `mkosi.conf` (the only filename mkosi reads # only filename mkosi reads automatically from the cwd. Drop-in
# automatically from the cwd). v0.0.50 wrote drop-in fragments # fragments under /etc/mkosi/mkosi.conf.d/ are ignored unless a
# to /etc/mkosi/mkosi.conf.d/<name>.conf, which mkosi silently # parent /etc/mkosi/mkosi.conf exists to layer them onto, so
# ignores unless a parent /etc/mkosi/mkosi.conf exists to layer # profiles never rely on fragments.
# them onto — so every v0.0.x build ran with empty defaults.
Path("/etc/mkosi/profiles"), Path("/etc/mkosi/profiles"),
Path("/etc/mkosi"), Path("/etc/mkosi"),
Path("/usr/share/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 # Build images, create and delete profiles, list build artifacts,
# ability to actually build images, create and delete profiles, list # and tail build logs.
# build artifacts, and tail build logs.
# #
# Build state lives under /var/lib/sysdeck/builder/: # Build state lives under /var/lib/sysdeck/builder/:
# state/<build-id>.json per-build state record # state/<build-id>.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. `options` may carry: output_dir, image_format, extra_args, force.
Returns the argv list to subprocess.run. 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 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 base mkosi.conf — it does NOT replace the base config. With no
no mkosi.conf in the cwd, mkosi uses defaults and ignores the mkosi.conf in the cwd, mkosi uses defaults and ignores the
--include file entirely. This is why v0.1.2 still produced builds --include file entirely.
with only 2 packages (mkosi's hardcoded base).
The fix: build() now creates a temp directory, symlinks the Therefore build() creates a temp directory, symlinks the profile
profile file into it as `mkosi.conf`, and sets work_dir to that file into it as `mkosi.conf`, and sets work_dir to that
temp dir. mkosi finds `mkosi.conf` (the symlink), follows it, temp dir. mkosi finds `mkosi.conf` (the symlink), follows it,
reads the actual profile. This works for ANY profile path reads the actual profile. This works for ANY profile path
regardless of its filename or location. regardless of its filename or location.
@ -684,6 +677,34 @@ def _backend_build_command(backend_id: str, profile: dict[str, Any], options: di
return [] 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: def _prepare_mkosi_work_dir(profile: dict[str, Any]) -> Path | None:
"""Create a temp dir with mkosi.conf symlinked to the profile file. """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/, # or /var/tmp/. This blocks /etc/, /usr/, /boot/, /bin/, /sbin/,
# /lib/, /root/, /home/, etc. — anywhere a stray image.raw would # /lib/, /root/, /home/, etc. — anywhere a stray image.raw would
# corrupt the system or pollute a user's home. # 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)) resolved_output_dir = Path(options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname))
try: try:
resolved = resolved_output_dir.resolve() 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, cwd=work_dir if work_dir else None,
stdout=logf, stderr=subprocess.STDOUT, stdout=logf, stderr=subprocess.STDOUT,
check=False, timeout=3600, check=False, timeout=3600,
env=BUILD_ENV,
) )
rc = r.returncode rc = r.returncode
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc: except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:

View File

@ -23,14 +23,13 @@ Subcommands:
backup <id> - trigger a backup backup <id> - trigger a backup
connections <id> - list active connections connections <id> - list active connections
Cockpit way (v0.0.31+ pattern, applied here in v0.0.32): mutating Privilege model: mutating ops (start / stop / restart / query) run
ops (start / stop / restart / query) run via subprocess directly — via subprocess directly — no `sudo` shell-out. The JS panel passes
no `sudo` shell-out. The JS panel passes { superuser: 'try' } to { superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts
cockpit.spawn so the cockpit bridge prompts the operator via polkit the operator via polkit for the org.sysdeck.db.modify action
for the org.sysdeck.db.modify action (added in v0.0.32 — authorizes (authorizes /usr/bin/systemctl for engine service control). The
/usr/bin/systemctl for engine service control). The bridge runs as bridge runs as the cockpit user and gets root privileges via polkit
the cockpit user and gets root privileges via polkit when the when the operator authenticates.
operator authenticates.
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
""" """
@ -98,11 +97,16 @@ ENGINE_REGISTRY = [
def run(cmd, timeout=10): 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: try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip() return r.stdout.strip()
except Exception: except Exception as exc:
print(f"[db.run] {cmd[0] if cmd else '?'}: {exc}", file=sys.stderr)
return "" return ""
@ -294,17 +298,15 @@ def _engine_registered(engine_id):
through the registry, but start/stop/restart passed the raw id into through the registry, but start/stop/restart passed the raw id into
`systemctl <verb> {engine_id}.service` — letting any cockpit `systemctl <verb> {engine_id}.service` — letting any cockpit
session stop/start ARBITRARY system units as root (db stop sshd). 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) return any(e[0] == engine_id for e in ENGINE_REGISTRY)
def cmd_start(engine_id): def cmd_start(engine_id):
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from # The cockpit way: the JS panel passes { superuser: 'try' } to
# the bridge fails when the cockpit user has no passwordless sudo # cockpit.spawn so the cockpit bridge prompts the operator via
# (the typical case). The cockpit way: the JS panel passes # polkit for the org.sysdeck.db.modify action. The bridge runs
# { superuser: 'try' } to cockpit.spawn so the cockpit bridge # systemctl directly as root (the cockpit
# 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 # superuser channel escalates privileges via polkit when the
# operator authenticates). # operator authenticates).
# v0.1.4 SECURITY: registry check added (see _engine_registered). # 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): 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 Security contract (enforced in code, not in comments):
existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as * first token must be a read verb — SELECT/WITH/SHOW/EXPLAIN/
root through psql/mysql/sqlite. The guard is now real: only DESCRIBE/PRAGMA/TABLE/ANALYZE
SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements * exactly one statement — any semicolon, psql meta-command, or
pass. Mutations belong in the engine's own tooling, not in a null byte rejects the request, so stacked statements cannot
dashboard query box. 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: for e in ENGINE_REGISTRY:
if e[0] == engine_id: if e[0] == engine_id:
family, cli = e[2], e[5] family, cli = e[2], e[5]
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"): if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
return {"error": "Query only supported for SQL-family engines"} return {"error": "Query only supported for SQL-family engines"}
# v0.1.4 SECURITY: read-only statement guard. sql = (sql or "").strip().rstrip(";").strip()
tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1) 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 "" first_token = tokens[0].upper() if tokens else ""
read_only = first_token in ( if first_token not in ("SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE",
"SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC", "DESC", "PRAGMA", "TABLE", "ANALYZE"):
"PRAGMA", "TABLE", "ANALYZE",
)
if not read_only:
return {"error": "read-only queries only — DDL/DML is rejected " return {"error": "read-only queries only — DDL/DML is rejected "
"(first token was not a read statement)"} "(first token was not a read statement)"}
if cli == "psql": query_cmds = {
out = run(["psql", "-tAc", sql], timeout=30) "psql": ["psql", "-tAc", sql],
elif cli in ("mysql", "mariadb"): "mysql": ["mysql", "-e", sql],
out = run(["mysql", "-e", sql], timeout=30) "mariadb": ["mysql", "-e", sql],
elif cli == "cockroach": "cockroach": ["cockroach", "sql", "-e", sql],
out = run(["cockroach", "sql", "-e", sql], timeout=30) "clickhouse-client": ["clickhouse-client", "-q", sql],
elif cli == "clickhouse-client": }
out = run(["clickhouse-client", "-q", sql], timeout=30) if cli == "sqlite3":
elif cli == "sqlite3": # sqlite3 treats a lone argument as a database filename,
out = run(["sqlite3", sql], timeout=30) # so running SQL through it queries nothing. The registry
else: # 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 <db> \".read\" style exports from a shell'"}
if cli not in query_cmds:
return {"error": f"No query handler for {cli}"} return {"error": f"No query handler for {cli}"}
out = run(query_cmds[cli], timeout=30)
return {"output": out, "engine": engine_id, "query": sql} return {"output": out, "engine": engine_id, "query": sql}
return {"error": f"Unknown engine: {engine_id}"} return {"error": f"Unknown engine: {engine_id}"}

View File

@ -3,13 +3,9 @@
SysDeck - Fester Bridge Helper SysDeck - Fester Bridge Helper
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
v0.2.0 REAL INTEGRATION. The v0.1.x helper was a stub: its only REAL INTEGRATION against the vendored fester service (the same one
subcommand (`build-jobs`) listed systemd units whose name contained the Web Edition runs): web/mini-services/fester, a distributed DAG
"fester" or "build" — it never talked to a build orchestrator. The build orchestrator speaking REST + WebSocket on 127.0.0.1:3010.
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.
This helper is a thin stdlib-only REST client (urllib.request + json, This helper is a thin stdlib-only REST client (urllib.request + json,
4s timeout — no requests library, no curl dependency). Every 4s timeout — no requests library, no curl dependency). Every
@ -36,8 +32,6 @@ Subcommands:
replay <buildId> [--label <l>] replay <buildId> [--label <l>]
POST /api/sessions → session POST /api/sessions → session
The old `build-jobs` subcommand is REMOVED.
Usage: Usage:
python3 /usr/lib/sysdeck/bridge/fester.py status python3 /usr/lib/sysdeck/bridge/fester.py status
python3 /usr/lib/sysdeck/bridge/fester.py builds python3 /usr/lib/sysdeck/bridge/fester.py builds

View File

@ -33,11 +33,10 @@ Also adds the service/port editor (4 new bridge subcommands):
restart-service <id> just restart the service (no port change). restart-service <id> just restart the service (no port change).
Useful for "I edited the config by hand" flows. Useful for "I edited the config by hand" flows.
v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY. FULL MANAGER, NOT A MONITOR: the bridge can start, stop, restart,
The v0.0.30 bridge exposed only `ruleset` and `chains` subcommands: the apply a template, ban an IP, unban an IP, show the ban list, and show
panel could list active nftables rules but could not start, stop, service detection — every operation the panel offers is wired to a
restart, apply a template, ban an IP, unban an IP, show the ban list, subcommand below.
or show service detection. The panel was a monitor, not a manager.
v0.0.31 turned the firewall panel into a full manager (apply / stop / v0.0.31 turned the firewall panel into a full manager (apply / stop /
restart / ban / unban / clear-bans / detect / check). restart / ban / unban / clear-bans / detect / check).
@ -112,7 +111,7 @@ Subcommands added in v0.0.36:
applied to this bridge (for display in applied to this bridge (for display in
the panel's Security Card) the panel's Security Card)
Subcommands kept from v0.0.31: Subcommands:
ruleset / chains / templates / template-info / detect / apply / ruleset / chains / templates / template-info / detect / apply /
stop / restart / status / ban / unban / banned / clear-bans / check 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 Templates must be POSIX-compliant bash and work on both Arch and
Debian. The templates shipped in this release are: Debian. The templates shipped in this release are:
vps-webserver.sh (v0.0.31, kept) vps-webserver.sh (public VPS web tier)
no-services.sh (v0.0.31, kept) no-services.sh (SSH-only hardened host)
cilium.sh (v0.0.36, Cilium eBPF backend) cilium.sh (Cilium eBPF backend)
sysdeck-fw.sh (v0.0.37, unified nftables zone firewall) sysdeck-fw.sh (unified nftables zone firewall)
remote-admin.sh (v0.0.44, SSH + Cockpit public-server variant) remote-admin.sh (v0.0.44, SSH + Cockpit public-server variant)
public-webserver.sh (v0.0.44, Caddy + Varnish + MariaDB variant) public-webserver.sh (v0.0.44, Caddy + Varnish + MariaDB variant)
ai-llm.sh (v0.0.44, Ollama + OpenWebUI + Hermes + Odysseus 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) return 127, "", str(exc)
# ── Ruleset parser (v0.0.30 logic, kept) ─────────────────────────── # ── Ruleset parser ────────────────────────────────────────────────
def parse_ruleset(output: str) -> list[dict[str, Any]]: def parse_ruleset(output: str) -> list[dict[str, Any]]:
@ -1201,12 +1200,10 @@ def cmd_status(_args: list[str]) -> dict[str, Any]:
# ── Subcommand: ban ───────────────────────────────────────────────── # ── Subcommand: ban ─────────────────────────────────────────────────
# #
# v0.0.36: _validate_ip is defined in the validation helpers section # _validate_ip (validation helpers above) uses ipaddress.ip_address
# above (line ~373). It uses ipaddress.ip_address for strict IPv4 + IPv6 # for strict IPv4 + IPv6 validation. Leading zeros (e.g.
# validation — replacing the v0.0.31 hand-rolled IPv4-only validator. # "010.010.010.010") are rejected — some systems interpret them as
# The old validator accepted leading zeros (e.g. "010.010.010.010") which # octal, a subtle attack vector. CVE-2024-2947 lesson.
# some systems interpret as octal — a subtle attack vector. The new
# validator rejects them. CVE-2024-2947 lesson.
def cmd_ban(args: list[str]) -> dict[str, Any]: 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]]: 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) return parse_ruleset(out)
# ── Subcommand: chains (v0.0.30, kept) ─────────────────────────────── # ── Subcommand: chains ─────────────────────────────────────────────
def cmd_chains(_args: list[str]) -> list[str]: 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. # Varnish's listen port is set via -a :6081 or VARNISH_LISTEN_PORT=6081.
"port_regex": re.compile( "port_regex": re.compile(
r"(?:(\-a\s*:?[a-z0-9.]*:)|VARNISH_LISTEN_PORT\s*=\s*)(\d+)", r"(?P<pre>-a\s*:?[a-z0-9.]*:|VARNISH_LISTEN_PORT\s*=\s*)(?P<port>\d+)",
re.MULTILINE, re.MULTILINE,
), ),
"port_replace_template": r"\g<1>{port}", "port_replace_template": r"\g<pre>{port}",
"default_port": 6081, "default_port": 6081,
"description": "Varnish HTTP cache. Default listen port 6081 (overridden to 8080 in the public-webserver template).", "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 ───────────────────────────────────────────────── # ── Dispatch table ─────────────────────────────────────────────────
COMMANDS = { COMMANDS = {
# v0.0.30 read-only subcommands (kept for back-compat): # Read-only subcommands:
"ruleset": cmd_ruleset, "ruleset": cmd_ruleset,
"chains": cmd_chains, "chains": cmd_chains,
# v0.0.31 manager subcommands: # Manager subcommands:
"templates": cmd_templates, "templates": cmd_templates,
"template-info": cmd_template_info, "template-info": cmd_template_info,
"detect": cmd_detect, "detect": cmd_detect,

View File

@ -20,7 +20,7 @@ def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure.""" """Run a command, returning stdout. Returns '' on failure."""
try: try:
return subprocess.run( return subprocess.run(
argv, capture_output=True, text=True, check=True, argv, capture_output=True, text=True, check=True, timeout=60,
).stdout ).stdout
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
return "" return ""

View File

@ -28,7 +28,7 @@ def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure.""" """Run a command, returning stdout. Returns '' on failure."""
try: try:
return subprocess.run( return subprocess.run(
argv, capture_output=True, text=True, check=True, argv, capture_output=True, text=True, check=True, timeout=20,
).stdout ).stdout
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
return "" return ""
@ -85,10 +85,11 @@ def peers() -> list[dict[str, str]]:
def summary() -> dict[str, Any]: def summary() -> dict[str, Any]:
"""Local host info plus peer list.""" """Local host info plus peer list."""
peers_list = peers()
return { return {
"local": local_host(), "local": local_host(),
"peers": peers(), "peers": peers_list,
"peerCount": len(peers()), "peerCount": len(peers_list),
} }

View File

@ -6,9 +6,8 @@ Author: Jeremy Anderson (https://dcos.net)
Aggregates system monitoring data from the Glances CLI Aggregates system monitoring data from the Glances CLI
(https://github.com/nicolargo/glances) into a structured JSON document. (https://github.com/nicolargo/glances) into a structured JSON document.
v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI. The user directive: The bridge integrates the Glances built-in web UI. Glances ships a
"glances is not integrated yet i just assumed you would integrate the webserver via
built in webui as a module." Glances ships a webserver via
`glances -w` (default port 61208, 127.0.0.1). The bridge starts that `glances -w` (default port 61208, 127.0.0.1). The bridge starts that
webserver as a background process; the JS panel iframes the running 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, 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. re-implementing any of it.
Subcommands: Subcommands:
snapshot — full system snapshot (kept from v0.0.11) snapshot — full system snapshot
cpu — CPU metrics subset (kept) cpu — CPU metrics subset
memory — memory metrics subset (kept) memory — memory metrics subset
network — network metrics subset (kept) network — network metrics subset
start-web — start glances -w on 127.0.0.1:61208 (background) start-web — start glances -w on 127.0.0.1:61208 (background)
writes the PID to /var/lib/sysdeck/glances/web.pid writes the PID to /var/lib/sysdeck/glances/web.pid
stop-web — kill the background webserver (read PID file) stop-web — kill the background webserver (read PID file)
web-status — return {running, pid, port, url} web-status — return {running, pid, port, url}
web-port — return the actual listening port (defaults to 61208) web-port — return the actual listening port (defaults to 61208)
Cockpit way (v0.0.31+ pattern): the bridge runs glances via subprocess Privilege model: the bridge runs glances via subprocess directly —
directly — no `sudo` shell-out. The JS panel passes { superuser: 'try' } no `sudo` shell-out. The JS panel passes { superuser: 'try' } to
to cockpit.spawn so the cockpit bridge prompts the operator via polkit cockpit.spawn so the cockpit bridge prompts the operator via polkit
for the org.sysdeck.system.manage action (shipped since v0.0.17 — for the org.sysdeck.system.manage action (authorizes
authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by /usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by extension any
extension any system-level subprocess the bridge runs). system-level subprocess the bridge runs).
Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes
it as a separate process via subprocess — the suite (MIT) and Glances 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 return False
def run_glances(args: list[str]) -> str: GLANCES_INSTALL_HINT = (
"""Run glances with the given args, returning stdout.""" "pip install glances # or: pacman -S glances / apt install glances / dnf install glances"
return subprocess.run( )
["glances", *args], capture_output=True, text=True, check=True,
).stdout
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]: def snapshot() -> dict[str, Any]:
"""Full system snapshot from glances JSON export. """Full system snapshot from glances JSON export.
Calls: glances --time 1 --quiet --export json --once 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() lines = output.strip().splitlines()
if not lines: if not lines:
return {} return {"available": False, "reason": "glances produced no output"}
return json.loads(lines[-1]) 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]: def cpu() -> dict[str, Any]:
"""CPU metrics subset from a glances snapshot.""" """CPU metrics subset from a glances snapshot."""
data = snapshot() data = snapshot()
if _available_false(data):
return data
return data.get("cpu", {}) return data.get("cpu", {})
def memory() -> dict[str, Any]: def memory() -> dict[str, Any]:
"""Memory metrics subset from a glances snapshot.""" """Memory metrics subset from a glances snapshot."""
data = snapshot() data = snapshot()
if _available_false(data):
return data
return { return {
"mem": data.get("mem", {}), "mem": data.get("mem", {}),
"memswap": data.get("memswap", {}), "memswap": data.get("memswap", {}),
@ -156,6 +190,8 @@ def memory() -> dict[str, Any]:
def network() -> dict[str, Any]: def network() -> dict[str, Any]:
"""Network interface metrics subset from a glances snapshot.""" """Network interface metrics subset from a glances snapshot."""
data = snapshot() data = snapshot()
if _available_false(data):
return data
return data.get("network", {}) return data.get("network", {})

View File

@ -40,7 +40,8 @@ GRAFANA_LICENSE = "AGPL-3.0"
GRAFANA_AUTHORS = "Grafana Labs" GRAFANA_AUTHORS = "Grafana Labs"
GRAFANA_URL = "https://grafana.com" 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)) sys.path.insert(0, str(Path(__file__).parent))
try: try:
from firewall import ( # type: ignore from firewall import ( # type: ignore
@ -374,7 +375,11 @@ def plugins() -> list[dict[str, Any]]:
def search(args: list[str]) -> list[dict[str, Any]]: def search(args: list[str]) -> list[dict[str, Any]]:
"""Search dashboards by query string.""" """Search dashboards by query string."""
query = args[0] if args else "" 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) data = _grafana_api_get(endpoint)
if not isinstance(data, list): if not isinstance(data, list):
return [] return []

View File

@ -512,12 +512,10 @@ def _device_path_ok(device_id):
paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/..., paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/...,
/sys/bus/pci/devices/...). block/unblock write to <id>/authorized as /sys/bus/pci/devices/...). block/unblock write to <id>/authorized as
root, so the id must resolve inside one of those scanned bases — root, so the id must resolve inside one of those scanned bases —
otherwise cmd_block was an arbitrary file-overwrite ('0') and anything else would make cmd_block an arbitrary file-overwrite ('0')
cmd_unblock was a root shell injection via `sudo sh -c` with the and cmd_unblock a shell-injection primitive. The cockpit superuser
f-string path (found by the 0.3.0 security audit; both sudo channel escalates this helper via polkit; no sudo shell-out exists
fallbacks are also gone: the cockpit superuser channel already anywhere in this helper."""
escalates this helper via polkit, so shelling out through sudo
only ever added the injection primitive)."""
if not isinstance(device_id, str) or not device_id.startswith("/"): if not isinstance(device_id, str) or not device_id.startswith("/"):
return False return False
bases = ( bases = (
@ -551,10 +549,9 @@ def cmd_unblock(device_id):
return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"} return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"}
auth_path = os.path.join(device_id, "authorized") auth_path = os.path.join(device_id, "authorized")
if os.path.exists(auth_path): if os.path.exists(auth_path):
# v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a # Direct write, never a shell: the helper already runs
# device_id containing shell metacharacters was literal root RCE. # privileged through the cockpit superuser channel when the
# Direct write (this helper already runs privileged through the # operator approves polkit.
# cockpit superuser channel when the operator approves polkit).
try: try:
with open(auth_path, 'w') as f: with open(auth_path, 'w') as f:
f.write('1') f.write('1')
@ -594,14 +591,21 @@ def cmd_whitelist(device_id):
def cmd_unwhitelist(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() whitelist = load_whitelist()
# Remove by matching serial or vendorId:productId new_wl = [
new_wl = [] entry for entry in whitelist
for entry in whitelist: if not (
if entry.get("serial") and device_id in entry.get("serial", ""): device_id == entry.get("serial")
continue or device_id == entry.get("id")
new_wl.append(entry) or device_id == entry.get("deviceId")
or device_id == f"{entry.get('vendorId', '')}:{entry.get('productId', '')}"
)
]
save_whitelist(new_wl) save_whitelist(new_wl)
return {"action": "unwhitelist", "deviceId": device_id, "remaining": len(new_wl)} return {"action": "unwhitelist", "deviceId": device_id, "remaining": len(new_wl)}

View File

@ -32,10 +32,15 @@ def score() -> int | None:
def scan() -> dict[str, Any]: def scan() -> dict[str, Any]:
"""Run a fresh lynis audit and return the parsed result.""" """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: try:
subprocess.run( subprocess.run(
["lynis", "audit", "system"], capture_output=True, text=True, check=True, ["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: except (subprocess.CalledProcessError, FileNotFoundError) as exc:
return {"error": str(exc), "score": None} return {"error": str(exc), "score": None}
return {"score": score()} return {"score": score()}

View File

@ -3,19 +3,10 @@
SysDeck - Kata Bridge Helper SysDeck - Kata Bridge Helper
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
v0.0.38 PRODUCTION REWRITE. The v0.0.35-v0.0.37 Kata panel shipped a ZERO-DEMO CONTRACT. Every subcommand calls the real Kata Containers
pre-built React bundle from the upstream cockpit-kata sub-project. That 3.x APIs and reads the real host state — sandbox lists, metrics, bundle
bundle displayed HARDCODED MOCK DATA — 5 fake sandboxes (web-frontend- catalogs, and PXE status are live data or honest unavailability, never
prod, api-gateway-staging, etc.) with synthetic UUIDs and createdAt fabricated records:
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:
list enumerate kata sandboxes via: list enumerate kata sandboxes via:
1. kata-monitor HTTP /sandboxes (if running) 1. kata-monitor HTTP /sandboxes (if running)
@ -543,8 +534,7 @@ def cmd_check(_args: list[str]) -> dict[str, Any]:
# ── Subcommand: pxe-status ───────────────────────────────────────── # ── Subcommand: pxe-status ─────────────────────────────────────────
# #
# Real PXE/TFTP status — replaces the v0.0.37 mock that always # Real PXE/TFTP status straight from systemctl and /srv/tftp.
# returned dnsmasqRunning:true.
def cmd_pxe_status(_args: list[str]) -> dict[str, Any]: 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 ──────────────────────────────────────── # ── Subcommand: qcrows-list ────────────────────────────────────────
# #
# QCrows kernel bundles are the real feature for kata kernel/module # QCrows kernel bundles are the kata kernel/module compilation
# compilation. The v0.0.37 React bundle had a mock catalog; this # surface; the listing reads the real filesystem.
# reads the real filesystem.
def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]: def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]:

View File

@ -3,9 +3,8 @@
SysDeck - Mining Bridge Helper SysDeck - Mining Bridge Helper
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive: 1999 POWER-TOOL STYLE: maximum UI control over every mining surface.
"themes and mining they need to be expanded for maximum ui The Mining Dashboard
control. think 1999 power tool style here." The Mining Dashboard
panel surfaces every XMRig REST API knob: panel surfaces every XMRig REST API knob:
summary — GET /1/summary (live hashrate, pool, threads) 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: if len(args) < 2:
return {"error": "usage: pool-config-set <url> <username> [password]"} return {"error": "usage: pool-config-set <url> <username> [password]"}
url, username = args[0], args[1] 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") cfg = _http_get("/1/config")
if cfg is None: if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"} 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"): if not cfg.get("pools"):
cfg["pools"] = [new_pool] cfg["pools"] = [new_pool]
else: else:
@ -233,7 +234,7 @@ def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
"set": result is not None, "set": result is not None,
"url": url, "url": url,
"username": username, "username": username,
"password_set": password != "x", "password_set": password is not None,
"raw": result, "raw": result,
} }

View File

@ -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 right next to a 1-click Install button. Clicking Install IS the
operator's acceptance of the inline-displayed license. 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 1. The catalog is the single source of truth — no per-module code
branches. Adding a module = appending a dict to CATALOG. branches. Adding a module = appending a dict to CATALOG.
2. No pulls are executed without an explicit install call from 2. No pulls are executed without an explicit install call from
@ -640,16 +640,27 @@ def status() -> list[dict[str, Any]]:
# ── CLI ────────────────────────────────────────────────────────────────────── # ── 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]] = { COMMANDS: dict[str, Callable[[list[str]], Any]] = {
"catalog": lambda _a: [e.to_public_dict() for e in catalog_entries()], "catalog": lambda _a: [e.to_public_dict() for e in catalog_entries()],
"status": lambda _a: status(), "status": lambda _a: status(),
"preflight": lambda a: preflight(a[0]), "preflight": lambda a: preflight(_arg(a)),
"install": lambda a: install( "install": lambda a: install(
a[0], _arg(a),
accept_license=("--accept-license" in a) or ("--accept-license=1" in a), accept_license=("--accept-license" in a) or ("--accept-license=1" in a),
), ),
"uninstall": lambda a: uninstall(a[0]), "uninstall": lambda a: uninstall(_arg(a)),
"audit": lambda a: audit(int(a[0]) if a else 200), "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: if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr) print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2 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)) print(json.dumps(result, indent=2, default=str))
return 0 if (not isinstance(result, dict) or result.get("ok", True)) else 1 return 0 if (not isinstance(result, dict) or result.get("ok", True)) else 1

View File

@ -3,11 +3,10 @@
SysDeck - Netsec Bridge Helper SysDeck - Netsec Bridge Helper
Author: Jeremy Anderson (https://dcos.net) 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. The monitor reads the same kernel sources iptraf-ng reads from
v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources directly — no fragile ncurses parsing, no static socket list.
iptraf-ng reads from directly — no fragile ncurses parsing.
Data sources: Data sources:
/proc/net/dev per-interface RX/TX byte + packet counters /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) connections active TCP/UDP flows with PID mapping (like iptraf-ng IP monitor)
interfaces per-interface detailed stats (cumulative counters) interfaces per-interface detailed stats (cumulative counters)
protocols /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters protocols /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters
sockets listening TCP+UDP sockets (kept from v0.0.10 for back-compat) sockets listening TCP+UDP sockets
established established TCP connections (kept from v0.0.10 for back-compat) established established TCP connections
Usage: Usage:
python3 /usr/lib/sysdeck/bridge/netsec.py traffic 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]]: 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]]: 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"])) return parse_ss(_run(["ss", "-tulpn"]))
def established() -> list[dict[str, Any]]: 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"])) return parse_ss(_run(["ss", "-tnp", "state", "established"]))

View File

@ -1065,13 +1065,7 @@ def install(args: list[str]) -> dict[str, str]:
# Actually run it. The cockpit bridge runs as the cockpit user; the # Actually run it. The cockpit bridge runs as the cockpit user; the
# JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit # JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit
# prompt the operator for auth and run us as root via polkit. # prompt the operator for auth and run us as root via polkit.
r = subprocess.run( return _run_mutation(cmd, "install", pkg)
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}
def remove(args: list[str]) -> dict[str, str]: def remove(args: list[str]) -> dict[str, str]:
@ -1087,13 +1081,7 @@ def remove(args: list[str]) -> dict[str, str]:
if not cmd: if not cmd:
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER, return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no remove command for {PKG_MANAGER}"} "success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
r = subprocess.run( return _run_mutation(cmd, "remove", pkg)
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}
def update(args: list[str]) -> dict[str, str]: def update(args: list[str]) -> dict[str, str]:
@ -1116,11 +1104,53 @@ def update(args: list[str]) -> dict[str, str]:
if not cmd: if not cmd:
return {"action": "update", "package": pkg, "manager": PKG_MANAGER, return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no update command for {PKG_MANAGER}"} "success": False, "stderr": f"no update command for {PKG_MANAGER}"}
r = subprocess.run( return _run_mutation(cmd, "update", pkg)
cmd, capture_output=True, text=True, check=False,
timeout=600, env=SCRUBBED_ENV,
) def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]:
return {"action": "update", "package": pkg, "manager": PKG_MANAGER, """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, "command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr} "rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
@ -1137,13 +1167,7 @@ def update_all() -> dict[str, str]:
if not cmd: if not cmd:
return {"action": "update-all", "manager": PKG_MANAGER, return {"action": "update-all", "manager": PKG_MANAGER,
"success": False, "stderr": f"no update-all command for {PKG_MANAGER}"} "success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
r = subprocess.run( return _run_mutation(cmd, "update-all", "")
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}
def dry_run(args: list[str]) -> dict[str, str]: def dry_run(args: list[str]) -> dict[str, str]:

View File

@ -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 and exposes the built-in admin web UI for iframe embedding in the
SysDeck panel. SysDeck panel.
v0.0.35 directive: "as well as a photo manager of equal quality. A photo manager of equal quality to the Jellyfin module, as its own
with its own module." Following the Jellyfin pattern: start/stop/ module. Following the Jellyfin pattern: start/stop/restart the
restart the service via systemctl; the panel iframes the running service via systemctl; the panel iframes the running
admin web UI. Equal quality means the photo manager module ships admin web UI. Equal quality means the photo manager module ships
with the same service-control + iframe-load shape as Jellyfin. with the same service-control + iframe-load shape as Jellyfin.

View File

@ -3,8 +3,8 @@
SysDeck - Policy & Permissions Bridge SysDeck - Policy & Permissions Bridge
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
v0.0.32 NEW MODULE — modern policy management and permissions Modern policy management and permissions manager for groups.
manager for groups. The user directive: Design contract:
"modern policy management and permissions manager for groups. "modern policy management and permissions manager for groups.
such as acl, cgroups, vlans, ebpf namespace separation and 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") lsns = shutil.which("lsns")
if not lsns: if not lsns:
return {"available": False, "reason": "lsns (util-linux) not installed"} 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(): if rc == 0 and out.strip():
try: try:
data = json.loads(out) listing = json.loads(out)
return {"available": True, "info": data, "format": "json"} 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: except json.JSONDecodeError:
pass pass
rc2, out2, _ = _run([lsns, "-t", nsid]) rc2, out2, _ = _run([lsns])
return {"available": True, "raw": out2, "format": "text"} return {"available": True, "raw": out2, "format": "text"}

View File

@ -39,8 +39,8 @@ PROM_LICENSE = "Apache-2.0"
PROM_AUTHORS = "Prometheus Authors" PROM_AUTHORS = "Prometheus Authors"
PROM_URL = "https://prometheus.io" PROM_URL = "https://prometheus.io"
# v0.0.39: import v0.0.37 security helpers from firewall.py (single # Security helpers come from firewall.py — one source of truth for
# source of truth for hardening). # hardening across the suite.
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
try: try:
from firewall import ( # type: ignore from firewall import ( # type: ignore
@ -60,14 +60,11 @@ except ImportError:
def _validate_filename(name: str) -> bool: def _validate_filename(name: str) -> bool:
return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name)) return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
# Prometheus API endpoint from environment or default # Prometheus API endpoint from environment or default. Port 9090
# v0.0.40: Prometheus defaults to port 9090, which is the SAME port # belongs to cockpit-ws on every SysDeck host, so Prometheus defaults
# Cockpit-ws uses. Since Cockpit is already running on 9090 on every # to 9095 — same 909x range, clear of Pushgateway (9091), Alertmanager
# SysDeck host, Prometheus MUST be moved to a different port. We # (9093), and Cockpit (9090). Operators running Prometheus on a custom
# default to 9095 — it's in the familiar 909x range, doesn't conflict # port override it via PROMETHEUS_API_URL.
# 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.
PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095") PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095")
# Pushgateway URL for log pipeline # Pushgateway URL for log pipeline
PUSHGATEWAY_URL = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL", "http://localhost:9091") 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 # Metadata JSON malformed: reject push request
return {"error": "metadata_json must be valid JSON"} 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() ts = time.time()
iso_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts)) 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 = ( metrics = (
f'# TYPE sysdeck_log_total counter\n' '# TYPE sysdeck_log_total counter\n'
f'sysdeck_log_total{{module="{module}",level="{level}"}} 1\n' f'sysdeck_log_total{{module="{mod_l}",level="{lvl_l}"}} 1\n'
f'# TYPE sysdeck_log_timestamp_seconds gauge\n' '# TYPE sysdeck_log_timestamp_seconds gauge\n'
f'sysdeck_log_timestamp_seconds{{module="{module}",level="{level}"}} {ts:.3f}\n' f'sysdeck_log_timestamp_seconds{{module="{mod_l}",level="{lvl_l}"}} {ts:.3f}\n'
f'# TYPE sysdeck_log_message_info gauge\n' '# TYPE sysdeck_log_message_info gauge\n'
f'sysdeck_log_message_info{{module="{module}",level="{level}",msg="{message[:128]}"}} 1\n' f'sysdeck_log_message_info{{module="{mod_l}",level="{lvl_l}",msg="{msg_l}"}} 1\n'
) )
pushed = _pushgateway_post("sysdeck_logs", metrics) pushed = _pushgateway_post("sysdeck_logs", metrics)
# Persist to local audit log # Persist to local audit log. Path stays inside LOG_DIR by
LOG_DIR.mkdir(parents=True, exist_ok=True) # construction (validated module name, fixed directory).
log_entry = { log_entry = {
"module": module, "module": module,
"level": level, "level": level,
@ -399,10 +408,15 @@ def push_log(args: list[str]) -> dict[str, Any]:
"pushedToPrometheus": pushed, "pushedToPrometheus": pushed,
} }
log_file = LOG_DIR / f"{module}.jsonl" log_file = LOG_DIR / f"{module}.jsonl"
with open(log_file, "a") as f: try:
f.write(json.dumps(log_entry) + "\n") 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]: def log_summary() -> dict[str, Any]:

View File

@ -313,11 +313,30 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
return {"error": f"Unknown backend: {bid}"} return {"error": f"Unknown backend: {bid}"}
(bid2, name, family, port, unit, cli, configs, lic, homepage, install_hint) = e (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": if bid2 == "ceph":
out = subprocess.run(
["ceph", "status", "--format=json"],
capture_output=True, text=True, timeout=15,
)
if out.returncode != 0: if out.returncode != 0:
return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"} return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"}
try: try:
@ -337,55 +356,12 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
except json.JSONDecodeError: except json.JSONDecodeError:
return {"backend": bid2, "rawText": out.stdout[:4000]} return {"backend": bid2, "rawText": out.stdout[:4000]}
if bid2 == "glusterfs": return {
out = subprocess.run( "backend": bid2,
["gluster", "pool", "list"], "rawText": out.stdout[:4000] if out.returncode == 0 else "",
capture_output=True, text=True, timeout=15, "stderr": out.stderr.strip() if out.returncode != 0 else "",
) "rc": out.returncode,
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}"}
def main(argv: list[str]) -> int: def main(argv: list[str]) -> int:

View File

@ -3,9 +3,8 @@
SysDeck - Theme Engine Bridge Helper SysDeck - Theme Engine Bridge Helper
Author: Jeremy Anderson (https://dcos.net) Author: Jeremy Anderson (https://dcos.net)
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive: 1999 POWER-TOOL STYLE: maximum UI control over every theme surface.
"themes and mining they need to be expanded for maximum ui control. The Theme Engine panel surfaces
think 1999 power tool style here." The Theme Engine panel surfaces
the full set of cockpit.conf theming knobs plus a preset gallery the full set of cockpit.conf theming knobs plus a preset gallery
and live-preview CSS-variable overrides. 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) spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
if spec is None: if spec is None:
return {"error": f"variable {name} not in the surface"} 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: try:
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True) SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
# Read existing overrides, replace or append this variable. # Read existing overrides, replace or append this variable.

View File

@ -21,7 +21,7 @@ def list_luks() -> list:
try: try:
r = subprocess.run( r = subprocess.run(
["lsblk", "-o", "NAME,FSTYPE,MOUNTPOINT,SIZE,TYPE", "-J"], ["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 {} data = json.loads(r.stdout) if r.stdout.strip() else {}
devices = [] devices = []

View File

@ -1,6 +1,6 @@
{ {
"_comment": "Compatibility Manifest — sysdeck v0.1.3", "_comment": "Compatibility Manifest — sysdeck v0.1.3",
"version": "0.4.4", "version": "0.4.5",
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" }, "suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
"modules": { "modules": {
"containers": { "containers": {
@ -18,7 +18,7 @@
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" } "distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
}, },
"kata": { "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" }, "requires": { "cockpit": ">=286" },
"conditions": [{ "path-or-exists": "/usr/bin/kata-runtime" }, { "path-or-exists": "/usr/bin/kata-containerd-shim-v2" }], "conditions": [{ "path-or-exists": "/usr/bin/kata-runtime" }, { "path-or-exists": "/usr/bin/kata-containerd-shim-v2" }],
"config": { "config": {

View File

@ -203,7 +203,7 @@ The JS bridge client calls each helper by absolute path — `python3 /usr/lib/sy
### Content Security Policy violations ### 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 ### Web console: login loop or 401 on every route

View File

@ -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 <info@dcos.net> # Author: Jeremy Anderson <info@dcos.net>
# #
# This policy is applied by firewall/templates/cilium.sh `start` action # This policy is applied by firewall/templates/cilium.sh `start` action
@ -6,9 +6,11 @@
# panel. It implements a sensible default: # panel. It implements a sensible default:
# #
# - default-deny ingress + egress at the cluster level # - default-deny ingress + egress at the cluster level
# - allow DNS (UDP/TCP 53) to kube-dns / systemd-resolved # - allow DNS (UDP/TCP 53) to kube-dns (K8s) or any local resolver
# - allow SSH (TCP 22) from anywhere # (standalone installs run systemd-resolved without kube labels)
# - allow HTTP/HTTPS (TCP 80/443) from anywhere # - 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 # Operators can drop a custom policy at
# /etc/sysdeck/firewall/cilium-policy.yaml to override. # /etc/sysdeck/firewall/cilium-policy.yaml to override.
@ -22,27 +24,37 @@ metadata:
namespace: default namespace: default
annotations: annotations:
sysdeck.io/managed-by: "sysdeck-firewall-cilium" sysdeck.io/managed-by: "sysdeck-firewall-cilium"
sysdeck.io/version: "0.0.36" sysdeck.io/version: "0.4.5"
spec: spec:
description: "SysDeck default policy — deny all ingress + egress, then re-allow DNS/SSH/HTTP/HTTPS" description: "SysDeck default policy — deny all ingress + egress, then re-allow DNS/SSH/HTTP/HTTPS"
endpointSelector: {} endpointSelector: {}
ingress: 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: - toPorts:
- ports: - ports:
- port: "22" - port: "22"
protocol: TCP protocol: TCP
# HTTP/HTTPS carry the method allow-list — scoped to these ports
# alone, never to the whole toPorts block.
- toPorts:
- ports:
- port: "80" - port: "80"
protocol: TCP protocol: TCP
rules:
http:
- method: "GET"
- method: "POST"
- method: "HEAD"
- port: "443" - port: "443"
protocol: TCP protocol: TCP
rules: rules:
http: http:
- method: "GET" - method: "GET"
- method: "POST" - method: "POST"
- method: "HEAD" - method: "HEAD"
egress: egress:
# Allow DNS to kube-dns (K8s) or systemd-resolved (standalone). # DNS to kube-dns on Kubernetes deployments.
- toEndpoints: - toEndpoints:
- matchLabels: - matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system k8s:io.kubernetes.pod.namespace: kube-system
@ -56,7 +68,18 @@ spec:
rules: rules:
dns: dns:
- matchPattern: "*" - 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: - toPorts:
- ports: - ports:
- port: "22" - port: "22"
@ -65,7 +88,8 @@ spec:
protocol: TCP protocol: TCP
- port: "443" - port: "443"
protocol: TCP 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: - toCIDRSet:
- cidr: 0.0.0.0/0 - cidr: 0.0.0.0/0
except: except:

View File

@ -207,7 +207,10 @@ build_ruleset() {
cat <<RULESET cat <<RULESET
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
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} { table inet ${TABLE_NAME} {
@ -322,6 +325,16 @@ fw_start() {
log_info " hermes=${HERMES_PORT} (public)" log_info " hermes=${HERMES_PORT} (public)"
log_info " odysseus=${ODYSSEUS_PORT} (public)" log_info " odysseus=${ODYSSEUS_PORT} (public)"
build_ruleset > "$RULES_FILE" build_ruleset > "$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" "$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded." log_info "Firewall loaded."
} }

View File

@ -251,9 +251,12 @@ fw_stop() {
return 0 return 0
fi 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 # This does NOT unload the BPF programs — cilium-agent keeps running
# so the operator can re-apply a policy without reinstalling. # 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?)." "$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 if [[ -x "$CILIUM_AGENT_BIN" ]] && systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then

View File

@ -84,9 +84,9 @@ SSH_BAN_TIME="300"
# --- Allowed TCP Services --- # --- Allowed TCP Services ---
# Format: "port" or "port:interface" for interface-specific rules # 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=( TCP_SERVICES=(
"80"
"443"
"${SSH_PORT}" "${SSH_PORT}"
) )
@ -297,7 +297,7 @@ generate_rules() {
all_ifs=$(get_all_interfaces) all_ifs=$(get_all_interfaces)
# Start with table definition # Start with table definition
cat > "$RULES_FILE" << 'TABLE_HEADER' cat > "$RULES_FILE" << TABLE_HEADER
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
# ============================================================================ # ============================================================================
@ -305,7 +305,10 @@ generate_rules() {
# Generated by: nftables-firewall.sh # 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 TABLE_HEADER
@ -325,11 +328,15 @@ table inet ${TABLE_NAME} {
} }
# Trusted networks # Trusted networks
set trusted_nets { $(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then
type ipv4_addr printf 'set trusted_nets {\n'
flags interval printf ' type ipv4_addr\n'
elements = { $(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//') } 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 # TCP ports to allow
set tcp_allowed { set tcp_allowed {
@ -422,11 +429,16 @@ COUNTER_MAP
chain input { chain input {
type filter hook input priority 0; policy ${POLICY_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 # Update interface counters
meta iifname @iface_input_policy counter name @iface_counters meta iifname @iface_input_policy counter name @iface_counters
# Jump to interface-specific handling # Jump to interface-specific handling
meta iifname @iface_input_policy meta iifname @iface_input_policy
}
INPUT_CHAIN INPUT_CHAIN
# --- Loopback 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) $(if [[ "$PROTECT_FRAGMENTS" == "yes" ]]; then echo "ip frag-off != 0 counter drop comment \"Fragmented packet\""; fi)
# Drop XMAS tree scans (all flags set) # 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) # 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 # Drop bogus TCP flag combinations
$(if [[ "$PROTECT_BOGUS_TCP" == "yes" ]]; then generate_bogus_tcp_rules; fi) $(if [[ "$PROTECT_BOGUS_TCP" == "yes" ]]; then generate_bogus_tcp_rules; fi)
# ICMP handling # ICMP handling
jump to icmp_handling jump icmp_handling
# Connection rate limiting # Connection rate limiting
$(generate_connlimit_rules) $(generate_connlimit_rules)
jump to service_handling jump service_handling
} }
PREFILTER PREFILTER
@ -513,9 +525,7 @@ EXTRA_CHAIN
# --- Port Scan Detection Chain --- # --- Port Scan Detection Chain ---
cat >> "$RULES_FILE" << SCAN_CHAIN cat >> "$RULES_FILE" << SCAN_CHAIN
chain port_scan_detect { chain port_scan_detect {
# Add to scanner set and drop add @port_scanners { ip saddr } counter drop comment \"Port scan detected\"
ip saddr @port_scanners drop
ip saddr set add @port_scanners counter drop comment \"Port scan detected\"
} }
SCAN_CHAIN SCAN_CHAIN
@ -661,8 +671,8 @@ generate_ssh_rules() {
cat << SSH_RULES cat << SSH_RULES
# SSH brute force protection # SSH brute force protection
tcp dport $SSH_PORT ip saddr @ssh_abuse counter drop comment \"SSH brute force ban\" 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_PORT 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 add @ssh_abuse { ip saddr } counter drop comment \"SSH rate limit exceeded\"
SSH_RULES SSH_RULES
} }
@ -678,9 +688,9 @@ DNS_RULES
# Generate connection rate limiting # Generate connection rate limiting
generate_connlimit_rules() { generate_connlimit_rules() {
cat << CONNLIMIT_RULES cat << CONNLIMIT_RULES
# Global connection rate limit # Global connection rate limit: drop only the excess — the
ct state new limit rate ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} accept # accept decisions stay with the service rules.
ct state new add @connlimit_abuse ip saddr counter drop comment \"Connection rate limit exceeded\" 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 CONNLIMIT_RULES
} }
@ -688,13 +698,13 @@ CONNLIMIT_RULES
generate_bogus_tcp_rules() { generate_bogus_tcp_rules() {
cat << BOGUS_TCP cat << BOGUS_TCP
# SYN+FIN (scan) # 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) # 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) # 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) # 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 BOGUS_TCP
} }

View File

@ -197,7 +197,10 @@ build_ruleset() {
cat <<RULESET cat <<RULESET
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
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} { table inet ${TABLE_NAME} {
@ -329,6 +332,16 @@ fw_start() {
log_info " caddy-admin=${CADDY_ADMIN_PORT} (loopback only)" log_info " caddy-admin=${CADDY_ADMIN_PORT} (loopback only)"
log_info " mariadb=${MARIADB_PORT} (loopback only, defense-in-depth drop)" log_info " mariadb=${MARIADB_PORT} (loopback only, defense-in-depth drop)"
build_ruleset > "$RULES_FILE" build_ruleset > "$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" "$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded." log_info "Firewall loaded."
} }

View File

@ -164,7 +164,10 @@ build_ruleset() {
cat <<RULESET cat <<RULESET
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
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} { table inet ${TABLE_NAME} {
@ -294,6 +297,16 @@ fw_start() {
detect_cockpit_port detect_cockpit_port
log_info "Starting remote-admin firewall: ssh=${SSH_PORT}, cockpit=${COCKPIT_PORT}" log_info "Starting remote-admin firewall: ssh=${SSH_PORT}, cockpit=${COCKPIT_PORT}"
build_ruleset > "$RULES_FILE" build_ruleset > "$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" "$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded." log_info "Firewall loaded."
} }

View File

@ -208,8 +208,6 @@ build_ruleset() {
# AirWall: ${AIRWALL} # AirWall: ${AIRWALL}
# Flow offload: ${FLOW_OFFLOAD} # Flow offload: ${FLOW_OFFLOAD}
flush table inet ${TABLE_NAME} 2>/dev/null
table inet ${TABLE_NAME} { table inet ${TABLE_NAME} {
# ── Sets ───────────────────────────────────────────────────────── # ── Sets ─────────────────────────────────────────────────────────
set ssh_abuse { type ipv4_addr; flags interval; timeout 1h; } 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 established,related accept
ct state invalid drop ct state invalid drop
ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate 50/second burst 100 packets accept # Flood control only — the accept decisions stay with the zone
ct state new tcp flags & (fin|syn|rst|ack) == syn drop # 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 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" # Full IPv6 control-plane set: NDP (solicit + advert, both
iifname "$RED_IF" udp dport { $red_udp_in } accept comment "RED inbound UDP" # 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 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 "$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)\"" ) $( [[ -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 established,related accept
ct state invalid drop ct state invalid drop
# SYN flood protection on RED ingress (synproxy). # Flood control on RED ingress: drop the excess, never blanket
iifname "$RED_IF" tcp flags syn notrack accept comment "synproxy: pass new SYN to synproxy chain" # 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) ────────────────────── # ── Zone-to-zone matrix (source-verified) ──────────────────────
# GREEN -> RED : allow # GREEN -> RED : allow
@ -311,12 +316,6 @@ EOF
cat <<EOF cat <<EOF
} }
# ── synproxy chain (SYN flood mitigation) ────────────────────────
chain synproxy {
tcp flags syn notrack accept
}
# ── Output ─────────────────────────────────────────────────────── # ── Output ───────────────────────────────────────────────────────
chain output { chain output {
type filter hook output priority filter; policy accept; type filter hook output priority filter; policy accept;
@ -393,9 +392,15 @@ fw_start() {
log_info "Validating..." log_info "Validating..."
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
cp "$RULES_FILE" /tmp/sysdeck-fw-failed.nft # mktemp, never a fixed /tmp path a local user could pre-place
die "Validation failed. Ruleset saved to /tmp/sysdeck-fw-failed.nft" local failed_nft
failed_nft="$(mktemp /tmp/sysdeck-fw-failed.XXXXXX.nft)" || die "mktemp failed"
cp "$RULES_FILE" "$failed_nft"
die "Validation failed. Ruleset saved to ${failed_nft}"
fi fi
# Fresh table on every apply: the shell owns the redirect, and a
# missing table is not an error here (first apply).
"$NFT_CMD" delete table inet "${TABLE_NAME}" 2>/dev/null || true
log_info "Loading..." log_info "Loading..."
if "$NFT_CMD" -f "$RULES_FILE"; then if "$NFT_CMD" -f "$RULES_FILE"; then

View File

@ -605,7 +605,10 @@ generate_rules() {
# SSH: ${SSH_DETECTED:+:${SSH_PORT}} ${FORGEJO_DETECTED:+Forgejo SSH: :${FORGEJO_SSH_PORT}} # 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} # 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} { table inet ${TABLE_NAME} {
@ -699,16 +702,17 @@ table inet ${TABLE_NAME} {
ip6 nexthdr icmpv6 drop comment "ICMPv6 rejected" ip6 nexthdr icmpv6 drop comment "ICMPv6 rejected"
# ---- Connection rate limiting ---- # ---- Connection rate limiting ----
ct state new limit rate 50/second burst 100 accept ct state new limit rate over 50/second burst 100 add @connlimit_abuse { ip saddr } counter drop comment "Connlimit exceeded"
ct state new add @connlimit_abuse ip saddr drop comment "Connlimit exceeded"
# ---- SSH with brute-force protection ---- # ---- SSH with brute-force protection ----
$(if [[ "$SSH_DETECTED" == "yes" ]]; then echo " $(if [[ "$SSH_DETECTED" == "yes" ]]; then echo "
# SSH - aggressive protection (pubkey-only assumed) # SSH - aggressive protection (pubkey-only assumed)
tcp dport $SSH_PORT ip saddr @ssh_abuse drop comment \"SSH banned\" 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 \ # A verdict-less synproxy statement here let every in-rate SSH
$(if [[ "$SYNPROXY_ENABLED" == "yes" ]]; then echo "synproxy mss 1460 wscale 7 timestamp sack-perm"; else echo "accept"; fi) # login fall through to the ban rule below; the rate limit +
tcp dport $SSH_PORT ct state new add @ssh_abuse ip saddr drop comment \"SSH brute force\" # 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) "; fi)
# ---- Forgejo SSH (separate from system SSH) ---- # ---- Forgejo SSH (separate from system SSH) ----
@ -716,7 +720,7 @@ table inet ${TABLE_NAME} {
# Forgejo SSH # Forgejo SSH
tcp dport $FORGEJO_SSH_PORT ip saddr @ssh_abuse drop comment \"Forgejo SSH banned\" 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 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) "; fi)
# ---- Public TCP services ---- # ---- Public TCP services ----

View File

@ -3,7 +3,7 @@
# Upstream: https://dcos.net # Upstream: https://dcos.net
pkgname=sysdeck pkgname=sysdeck
pkgver=0.4.4 pkgver=0.4.5
pkgrel=1 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)" 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') arch=('any')
@ -43,35 +43,15 @@ optdepends=(
) )
makedepends=('make') makedepends=('make')
backup=() backup=()
install=sysdeck.install
source=("${pkgname}-${pkgver}.tar.bz2") source=("${pkgname}-${pkgver}.tar.bz2")
sha256sums=('SKIP') # Replace with actual hash for release sha256sums=('e579dc82aca5f3def1d6a3df6bcc7671b9dd3b30e78dd710f6ea14a93f3c176b')
package() { package() {
cd "${srcdir}/${pkgname}-${pkgver}" cd "${srcdir}/${pkgname}-${pkgver}"
make install DESTDIR="${pkgdir}" make install DESTDIR="${pkgdir}"
# No python site-packages symlink: the bridge's invocation contract
# Arch-specific: ensure cockpit can find the bridge Python package. # is `python3 /usr/lib/sysdeck/bridge/<mod>.py <sub>` by absolute
# The bridge installs to /usr/lib/sysdeck/bridge/; add a symlink # path (check-no-broken-python-module enforces it), so no import
# from the Arch Python site-packages so `python3 -m sysdeck.bridge.*` # path needs to resolve.
# 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."
} }

View File

@ -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 <info@dcos.net> Thu, 17 Sep 2026 10:00:00 -0400
sysdeck (0.4.4-1) unstable; urgency=medium sysdeck (0.4.4-1) unstable; urgency=medium
* v0.4.4: ten package-manager backends on both editions + the blog * 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 real REST client (11 subcommands, FESTER_URL override, graceful
offline JSON); plugins/sysdeck-fester is a full panel; the shared offline JSON); plugins/sysdeck-fester is a full panel; the shared
bridge.js fester surface grew from 1 method to 11. bridge.js fester surface grew from 1 method to 11.
* Makefile: restored tab-indented recipes (the shipped 0.1.3 Makefile * Makefile: recipes are tab-indented (GNU make rejects 8-space indents
used 8-space indents and GNU make rejected it with "missing with "missing separator"; a build-time guard enforces it); new targets
separator"); new targets fester-start, web-install, web-dev, master. fester-start, web-install, web-dev, master.
-- Jeremy Anderson <info@dcos.net> Thu, 20 Aug 2026 12:00:00 -0400 -- Jeremy Anderson <info@dcos.net> 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 to plugins/sysdeck-kata/, converted from a tools-section
manifest entry to a menu-section entry (label "SysDeck Kata", manifest entry to a menu-section entry (label "SysDeck Kata",
order 27), removed the "hidden helper" wording, dropped the 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 Containers panel now manages Podman only — the Kata tab and
its iframe were removed. The pre-built cockpit-kata React its iframe were removed. The pre-built cockpit-kata React
bundle (index.js + index.css) is shipped unchanged. 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, mkdir, mount, ip, bpftool, lsns, aa-enforce, aa-complain,
aa-status). aa-status).
* DOCUMENTATION REWRITE. README.md, QUICKSTART.md, BLOG.md, and * DOCUMENTATION REWRITE. README.md, QUICKSTART.md, BLOG.md, and
LICENSE rewritten with decisive language. Every design choice LICENSE state every design choice as a standing decision in the
is documented as a decision, not as history. Removed "restored" present tense; active code comments and current-version docs
/ "brought back" / "was dropped" / "surviving artifact" wording carry no development-churn narration (release history stays in
from active code comments and current-version docs (historical the changelog, where it belongs).
release narratives in BLOG.md are preserved as record).
* STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33 * STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33
entry — three options (implement anyway / skip entirely / detect entry — three options (implement anyway / skip entirely / detect
only) considered; option 2 won because it composes best with the 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 probably around version 18 if i had to guess." Confirmed via
BLOG.md — the DB Control module was added in v0.0.15 alongside BLOG.md — the DB Control module was added in v0.0.15 alongside
Prometheus and Grafana. The v0.0.20 architectural overhaul Prometheus and Grafana. The v0.0.20 architectural overhaul
split SysDeck into 18 standalone plugins and the DB plugin split SysDeck into 18 standalone plugins and the DB panel
panel got dropped in the cut — only 18 made the list, even did not make that list of 18, even though its bridge helper
though the bridge helper survived. v0.0.32 restores the remained in the tree. v0.0.32 ships the plugin panel.
plugin panel.
v0.0.32 also fixes the v0.0.15-era `sudo systemctl` shell-out 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 in cmd_start / cmd_stop / cmd_restart — the cockpit way (per
v0.0.31 pattern) is to run systemctl directly via subprocess 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("<module>", ["<sub>", ...]) call in Cross-checks every bridgeCmd("<module>", ["<sub>", ...]) call in
shared/bridge.js against the COMMANDS dict declared in each shared/bridge.js against the COMMANDS dict declared in each
bridge/<module>.py. Would have caught both bugs above. bridge/<module>.py. Would have caught both bugs above.
Regression-tested: reverting firmware.py to its v0.0.27 state Negative-tested: a summary-only firmware.py (the v0.0.27 shape)
causes the guard to fail with a clear message naming the file, fails the guard with a clear message naming the file, line, and
line, and missing subcommand. missing subcommand.
* FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing * FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
.suite-progress, .suite-progress-bar, .suite-progress-fill, .suite-progress, .suite-progress-bar, .suite-progress-fill,
.suite-stat-value, .suite-stat-label, .suite-row, .suite-row-between, .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`. `import cockpit from "cockpit"` to `module.exports = cockpit`.
* New guard: check-no-broken-cockpit-import in `make check` scans every * New guard: check-no-broken-cockpit-import in `make check` scans every
JS file in plugins/ and shared/ for the broken `import cockpit from` JS file in plugins/ and shared/ for the broken `import cockpit from`
pattern. Regression-tested: deliberately reintroducing the v0.0.22 pattern. Negative-tested: the v0.0.22 import form makes `make
import causes `make check` to fail with a clear message. check` fail with a clear message.
-- Jeremy Anderson <info@dcos.net> Sun, 17 Aug 2026 09:00:00 -0500 -- Jeremy Anderson <info@dcos.net> 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 sysdeck (0.0.15-1) unstable; urgency=low
* RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py * SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and
silently dropped from v0.0.13 release; restored here. bridge/prometheus.py ship in the tarball (the v0.0.13 tarball
* RESTORE: nextjs-dashboard/ and prometheus/ directories restored omitted them).
to source tree and included in tarball + install target. * SOURCE COMPLETENESS: nextjs-dashboard/ and prometheus/
directories ship in the source tree, tarball, and install
target.
* Makefile install target now installs prometheus configs to * Makefile install target now installs prometheus configs to
/etc/sysdeck/prometheus/ and Next.js dashboard to /etc/sysdeck/prometheus/ and Next.js dashboard to
/usr/share/sysdeck/nextjs-dashboard/. /usr/share/sysdeck/nextjs-dashboard/.

View File

@ -2,7 +2,9 @@ Source: sysdeck
Section: admin Section: admin
Priority: optional Priority: optional
Maintainer: Jeremy Anderson <info@dcos.net> Maintainer: Jeremy Anderson <info@dcos.net>
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 Standards-Version: 4.7.0
Homepage: https://dcos.net Homepage: https://dcos.net
Vcs-Browser: https://dcos.net Vcs-Browser: https://dcos.net
@ -11,8 +13,8 @@ Vcs-Git: https://dcos.net/sysdeck.git
Package: sysdeck Package: sysdeck
Architecture: all Architecture: all
Depends: cockpit-bridge (>= 239), python3 (>= 3.9), ${misc:Depends} 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 Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream
Suggests: kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server 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 Description: Unified operations surface for Linux infrastructure
SysDeck is a standalone Linux operations console that also ships as a SysDeck is a standalone Linux operations console that also ships as a
Cockpit plugin suite. This package installs the Cockpit plugin edition: Cockpit plugin suite. This package installs the Cockpit plugin edition:

View File

@ -24,7 +24,7 @@ import shutil
import subprocess import subprocess
from setuptools import setup from setuptools import setup
VERSION = "0.4.4" VERSION = "0.4.5"
PACKAGE = "sysdeck" PACKAGE = "sysdeck"
# The repository root (setup.py lives in packaging/). # The repository root (setup.py lives in packaging/).

17
packaging/sysdeck.install Normal file
View File

@ -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."
}

View File

@ -32,6 +32,7 @@
--> -->
<component type="addon"> <component type="addon">
<id>net.dcos.sysdeck</id> <id>net.dcos.sysdeck</id>
<extends>org.cockpit_project.cockpit</extends>
<metadata_license>CC0-1.0</metadata_license> <metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license> <project_license>MIT</project_license>
<name>SysDeck</name> <name>SysDeck</name>
@ -80,7 +81,7 @@
plugin's manifest.json. The cockpit apps page (pkg/apps/utils.tsx:152) plugin's manifest.json. The cockpit apps page (pkg/apps/utils.tsx:152)
reads launchable.type == "cockpit-manifest" and uses the text to reads launchable.type == "cockpit-manifest" and uses the text to
link the AppStream component to the corresponding Cockpit plugin. 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. sysdeck-jellyfin, sysdeck-photos, sysdeck-remotefs.
v0.0.46: added sysdeck-modules (in-suite 3rd-party module installer). v0.0.46: added sysdeck-modules (in-suite 3rd-party module installer).
v0.0.47: added sysdeck-services (service/port editor promoted to v0.0.47: added sysdeck-services (service/port editor promoted to
@ -131,6 +132,18 @@
</keywords> </keywords>
<content_rating type="oars-1.1" /> <content_rating type="oars-1.1" />
<releases> <releases>
<release version="0.4.5" date="2026-09-17">
<description>
<p>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.</p>
</description>
</release>
<release version="0.4.4" date="2026-09-12"> <release version="0.4.4" date="2026-09-12">
<description> <description>
<p>v0.4.4: ten package managers on both editions — pacman, <p>v0.4.4: ten package managers on both editions — pacman,
@ -660,7 +673,7 @@
plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/, plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/,
converted from a "tools" manifest entry to a "menu" entry converted from a "tools" manifest entry to a "menu" entry
(label "SysDeck Kata", order 27), removed the "hidden helper" (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 — keywords list. The Containers panel now manages Podman only —
the Kata tab and its iframe were removed. The pre-built the Kata tab and its iframe were removed. The pre-built
cockpit-kata React bundle (index.js + index.css) is shipped cockpit-kata React bundle (index.js + index.css) is shipped
@ -814,7 +827,7 @@
<p>NEW BUILD-TIME GUARD: `check-bridge-subcommands` in `make check`. <p>NEW BUILD-TIME GUARD: `check-bridge-subcommands` in `make check`.
Cross-checks every bridgeCmd() call in shared/bridge.js against the Cross-checks every bridgeCmd() call in shared/bridge.js against the
COMMANDS dict declared in each bridge/&lt;module&gt;.py. Would have COMMANDS dict declared in each bridge/&lt;module&gt;.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.</p> its v0.0.27 state causes the guard to fail with a clear message.</p>
<p>FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing <p>FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
.suite-progress, .suite-progress-bar, .suite-progress-fill, .suite-progress, .suite-progress-bar, .suite-progress-fill,

View File

@ -7,16 +7,18 @@
# Debian/Ubuntu users: see packaging/debian/ # Debian/Ubuntu users: see packaging/debian/
Name: sysdeck Name: sysdeck
Version: 0.4.4 Version: 0.4.5
Release: 1%{?dist} Release: 1%{?dist}
Summary: Unified operations surface for Linux infrastructure Summary: Unified operations surface for Linux infrastructure
License: MIT License: MIT
URL: https://dcos.net URL: https://dcos.net
Source0: %{name}-%{version}.tar.bz2 Source0: %{name}-%{version}.tar.bz2
BuildArch: noarch
BuildRequires: make BuildRequires: make
BuildRequires: python3-devel BuildRequires: python3-devel
BuildRequires: nodejs
Requires: cockpit-bridge >= 239 Requires: cockpit-bridge >= 239
Recommends: podman Recommends: podman
Recommends: nftables Recommends: nftables
@ -27,10 +29,10 @@ Recommends: tpm2-tools
Recommends: glances Recommends: glances
Recommends: lm_sensors Recommends: lm_sensors
Recommends: sysbench Recommends: sysbench
Recommends: kata-containers Suggests: kata-containers
Recommends: jellyfin Suggests: jellyfin
Recommends: ceph Suggests: ceph
Recommends: glusterfs Suggests: glusterfs
%description %description
SysDeck is a standalone Linux operations console that also ships as a SysDeck is a standalone Linux operations console that also ships as a
@ -62,16 +64,19 @@ make install DESTDIR=%{buildroot}
%files %files
%license LICENSE %license LICENSE
%doc README.md QUICKSTART.md BLOG.md QA.md %doc README.md QUICKSTART.md BLOG.md QA.md
/usr/share/cockpit/%{name}/manifest.json # The install target ships: 27 plugins under
/usr/share/cockpit/%{name}/index.html # /usr/share/cockpit/sysdeck-*/ (manifest.json, index.html, module js),
/usr/share/cockpit/%{name}/suite.js # the sysdeck-common bridge library, the Python bridge at
/usr/share/cockpit/%{name}/suite.css # /usr/lib/sysdeck/bridge/, tests, docs, share assets (diagnose +
/usr/share/cockpit/%{name}/logo.svg # smoke + uninstall scripts, firewall templates + policies, prometheus
/usr/share/cockpit/%{name}/src/ # configs), AppStream metainfo, and both polkit actions.
/usr/lib/%{name}/bridge/ /usr/share/cockpit/sysdeck-*/
/usr/lib/%{name}/tests/ /usr/lib/%{name}/
/usr/share/%{name}/
/usr/share/doc/%{name}/
/usr/share/metainfo/sysdeck.metainfo.xml /usr/share/metainfo/sysdeck.metainfo.xml
/usr/share/polkit-1/actions/org.sysdeck.policy /usr/share/polkit-1/actions/org.sysdeck.policy
/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy
%post %post
# Restart cockpit.socket so the new plugin appears in the menu. # Restart cockpit.socket so the new plugin appears in the menu.
@ -87,6 +92,23 @@ if [ $1 -eq 0 ]; then
fi fi
%changelog %changelog
* Thu Sep 17 2026 Jeremy Anderson <info@dcos.net> - 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 <info@dcos.net> - 0.4.4-1 * Sat Sep 12 2026 Jeremy Anderson <info@dcos.net> - 0.4.4-1
- v0.4.4: package parity + blog essay. Ten package-manager backends on - v0.4.4: package parity + blog essay. Ten package-manager backends on
both editions (bridge/packages.py gains emerge/lunar/sorcery/xbps/ 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, HMAC session cookie, gated API routes, fester WS session check,
audited logins) — the 0.3.0 audit follow-through. audited logins) — the 0.3.0 audit follow-through.
%changelog
* Fri Sep 11 2026 Jeremy Anderson <info@dcos.net> - 0.3.0-1 * Fri Sep 11 2026 Jeremy Anderson <info@dcos.net> - 0.3.0-1
- v0.3.0 AI GATEWAY EDITION: klanker-gate (Frosty Deno LLM gateway, - v0.3.0 AI GATEWAY EDITION: klanker-gate (Frosty Deno LLM gateway,
independent version 0.9.0) vendored at /klanker-gate with full Arch 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 - bridge/fester.py: real REST client (11 subcommands) replacing the
v0.0.31 systemd-listing stub; fester panel fully built; bridge.js v0.0.31 systemd-listing stub; fester panel fully built; bridge.js
fester surface 1 -> 11 methods. fester surface 1 -> 11 methods.
- Makefile: tab-indented recipes restored (0.1.3 shipped 8-space indents - Makefile: recipes are tab-indented (GNU make rejects the 8-space
that GNU make rejected); new targets fester-start, web-install, indent form; a build-time guard enforces tabs); new targets
web-dev, master. fester-start, web-install, web-dev, master.
* Tue Aug 19 2026 Jeremy Anderson <info@dcos.net> - 0.1.3-1 * Tue Aug 19 2026 Jeremy Anderson <info@dcos.net> - 0.1.3-1
- v0.1.3 CRITICAL FIX: host package import was silently failing + mkosi - v0.1.3 CRITICAL FIX: host package import was silently failing + mkosi
still wasn't reading the profile config. Two root causes fixed, plus 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, setcap, getcap (in addition to v0.0.32 set: setfacl, getfacl, mkdir,
mount, ip, bpftool, lsns, aa-enforce, aa-complain, aa-status). mount, ip, bpftool, lsns, aa-enforce, aa-complain, aa-status).
- DOCUMENTATION REWRITE: README.md, QUICKSTART.md, BLOG.md, LICENSE - DOCUMENTATION REWRITE: README.md, QUICKSTART.md, BLOG.md, LICENSE
rewritten with decisive language. Every design choice recorded as a state every design choice as a standing decision in the present
decision, not as history. Removed "restored/brought back/was dropped/ tense; active code comments and current-version docs carry no
surviving artifact" wording from active code comments and current- development-churn narration (release history stays in the changelog,
version docs (historical release narratives in BLOG.md preserved as where it belongs).
record).
- STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33 - STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33
entry — three options considered (implement / skip / detect-only); entry — three options considered (implement / skip / detect-only);
option 2 won because it composes best with the rest of the system. 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. 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 - 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 file for spawn calls using `python3 -m sysdeck.bridge` and fails with a
clear message. Regression-tested: deliberately reintroducing the broken clear message. Negative-tested: the broken pattern makes `make check`
pattern causes `make check` to fail. fail.
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.25-1 * Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.25-1
- ROOT CAUSE FOUND AND FIXED: every plugin page showed "Module load - 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`. rewrites `import cockpit from "cockpit"` to `module.exports = cockpit`.
- New guard: check-no-broken-cockpit-import in `make check` scans every - New guard: check-no-broken-cockpit-import in `make check` scans every
JS file in plugins/ and shared/ for the broken `import cockpit from` JS file in plugins/ and shared/ for the broken `import cockpit from`
pattern. Regression-tested: deliberately reintroducing the v0.0.22 pattern. Negative-tested: the v0.0.22 import form makes `make check`
import causes `make check` to fail with a clear message citing the fail with a clear message citing the cockpit source code.
cockpit source code.
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.22-1 * Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.22-1
- ROOT CAUSE FOUND AND FIXED: the `requires.cockpit` field was set to - ROOT CAUSE FOUND AND FIXED: the `requires.cockpit` field was set to
@ -1516,17 +1535,17 @@ fi
at build time. at build time.
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.15-1 * Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.15-1
- RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py - SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and
were silently dropped from the v0.0.13 release tarball and shipped bridge/prometheus.py ship in the tarball (the v0.0.13-v0.0.14
missing through v0.0.14. All three are restored (1489 lines of code). tarballs omitted them; 1489 lines of code).
- RESTORE: nextjs-dashboard/ directory (standalone Next.js variant - SOURCE COMPLETENESS: nextjs-dashboard/ directory (standalone Next.js
dashboard) and prometheus/ config directory (Prometheus + Grafana variant dashboard) and prometheus/ config directory (Prometheus +
YAML configs) are restored to the source tree. Grafana YAML configs) ship in the source tree.
- Makefile dist target now includes prometheus/ and nextjs-dashboard/. - Makefile dist target now includes prometheus/ and nextjs-dashboard/.
- Makefile install target now installs prometheus configs to - Makefile install target now installs prometheus configs to
/etc/sysdeck/prometheus/ and the Next.js dashboard to /etc/sysdeck/prometheus/ and the Next.js dashboard to
/usr/share/sysdeck/nextjs-dashboard/. /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). the dropped code).
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.14-1 * Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.14-1

0
plugins/sysdeck-auth/index.html Executable file → Normal file
View File

0
plugins/sysdeck-auth/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-benchmark/index.html Executable file → Normal file
View File

0
plugins/sysdeck-benchmark/manifest.json Executable file → Normal file
View File

View File

@ -120,8 +120,8 @@ export async function mount(panel, { bridge, EventBus }) {
<h2 class="suite-panel-title">Image Builder</h2> <h2 class="suite-panel-title">Image Builder</h2>
<p class="suite-panel-subtitle"> <p class="suite-panel-subtitle">
${primary ${primary
? `${primary.id} ${primary.version || ''} — <span class="suite-badge success">${summary.state}</span>` ? `${escapeHtml(primary.id)} ${escapeHtml(primary.version || '')} — <span class="suite-badge success">${escapeHtml(summary.state)}</span>`
: `no backend installed — <span class="suite-badge danger">${summary.state}</span>`} : `no backend installed — <span class="suite-badge danger">${escapeHtml(summary.state)}</span>`}
· ${builds.length} build${builds.length === 1 ? '' : 's'} tracked · ${builds.length} build${builds.length === 1 ? '' : 's'} tracked
· ${artifacts.artifacts || 0} artifact${(artifacts.artifacts || 0) === 1 ? '' : 's'} · ${artifacts.artifacts || 0} artifact${(artifacts.artifacts || 0) === 1 ? '' : 's'}
</p> </p>
@ -172,10 +172,10 @@ function renderBackends(backends, primary) {
<ul class="suite-list"> <ul class="suite-list">
${backends.map(b => ` ${backends.map(b => `
<li class="suite-mono"> <li class="suite-mono">
<strong>${b.id}</strong> <strong>${escapeHtml(b.id)}</strong>
${b.version ? `<span class="suite-muted"> ${escapeHtml(b.version)}</span>` : ''} ${b.version ? `<span class="suite-muted"> ${escapeHtml(b.version)}</span>` : ''}
${b.id === (primary && primary.id) ? '<span class="suite-badge success">primary</span>' : ''} ${b.id === (primary && primary.id) ? '<span class="suite-badge success">primary</span>' : ''}
<span class="suite-muted">(${b.kind})</span> <span class="suite-muted">(${escapeHtml(b.kind)})</span>
</li> </li>
`).join('')} `).join('')}
</ul> </ul>
@ -309,7 +309,7 @@ function renderCreateProfile(backends, primary) {
`; `;
} }
const backendOptions = scaffoldable const backendOptions = scaffoldable
.map(b => `<option value="${b.id}">${b.id}</option>`).join(''); .map(b => `<option value="${escapeHtml(b.id)}">${escapeHtml(b.id)}</option>`).join('');
return ` return `
<div class="suite-card"> <div class="suite-card">
<div class="suite-card-header"> <div class="suite-card-header">
@ -820,22 +820,14 @@ function wireEvents(panel, { bridge, EventBus }) {
btn.disabled = true; btn.disabled = true;
btn.textContent = '⏳ ...'; btn.textContent = '⏳ ...';
try { try {
// Read the file via cockpit.spawn cat. We use binary mode // cockpit.spawn returns a promise; in binary mode it
// by reading as a binary stream. cockpit.spawn returns a // resolves to the full byte payload — the documented API,
// channel that we collect into a byte array. // not the low-level channel surface.
const channel = cockpit.spawn(["cat", path], { const data = await cockpit.spawn(["cat", path], {
superuser: "try", superuser: "try",
binary: true, binary: true,
}); });
const chunks = []; const blob = new Blob([data], { type: 'application/octet-stream' });
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 url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;

0
plugins/sysdeck-builder/index.html Executable file → Normal file
View File

0
plugins/sysdeck-builder/manifest.json Executable file → Normal file
View File

View File

@ -8,10 +8,8 @@
* user directive: "kata containers should be called SysDeck Kata and * user directive: "kata containers should be called SysDeck Kata and
* moved out of the tools area." * moved out of the tools area."
* *
* v0.0.34 was a merged two-tab panel (Podman + Kata iframe). The * One module, one concern: this panel manages Podman containers
* v0.0.35 split restores the one-module-one-concern shape: this * only; the SysDeck Kata plugin owns Kata sandboxes & VMs.
* panel manages Podman containers only; the SysDeck Kata plugin
* hosts the pre-built cockpit-kata React app for Kata sandboxes & VMs.
* *
* Bridge surface (see shared/bridge.js → bridge.containers): * Bridge surface (see shared/bridge.js → bridge.containers):
* list() → podman ps --format json (normalized) * list() → podman ps --format json (normalized)
@ -21,7 +19,6 @@
*/ */
export async function mount(panel, { bridge, EventBus }) { export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
panel.innerHTML = renderShell(); panel.innerHTML = renderShell();
await renderPodmanTable(panel, { bridge, EventBus }); await renderPodmanTable(panel, { bridge, EventBus });
EventBus.emit('containers.loaded', {}); EventBus.emit('containers.loaded', {});
@ -40,14 +37,15 @@ function renderShell() {
<h3 class="suite-card-title" id="containers-summary">Loading containers…</h3> <h3 class="suite-card-title" id="containers-summary">Loading containers…</h3>
<button class="suite-btn suite-btn-ghost" id="btn-containers-refresh">↻ Refresh</button> <button class="suite-btn suite-btn-ghost" id="btn-containers-refresh">↻ Refresh</button>
</div> </div>
<div id="containers-flash" class="suite-badge danger" style="display:none; margin-bottom:0.5rem; padding:0.4rem 0.6rem;"></div>
<div id="containers-table-host"></div> <div id="containers-table-host"></div>
</div> </div>
<div class="suite-card"> <div class="suite-card">
<div class="suite-card-body"> <div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem"> <p class="suite-muted" style="font-size:0.85rem">
Kata Containers (hardware-virtualized OCI sandboxes) is now a Kata Containers (hardware-virtualized OCI sandboxes) lives in its
standalone sidebar entry — <strong>SysDeck Kata</strong>. Open it own sidebar entry — <strong>SysDeck Kata</strong> — real sandbox
to manage Kata sandboxes & VMs from the pre-built React app. and VM state from the host, not a bundle mock.
</p> </p>
</div> </div>
</div> </div>
@ -90,6 +88,15 @@ async function renderPodmanTable(panel, { bridge, EventBus }) {
EventBus.emit('container.action', { id, action }); EventBus.emit('container.action', { id, action });
} catch (err) { } catch (err) {
EventBus.emit('container.error', { id, error: err.message }); 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 }); renderPodmanTable(panel, { bridge, EventBus });
}); });

0
plugins/sysdeck-containers/index.html Executable file → Normal file
View File

0
plugins/sysdeck-containers/manifest.json Executable file → Normal file
View File

View File

@ -319,6 +319,6 @@ function renderSkeleton() {
function renderError(err) { function renderError(err) {
return `<div class="suite-card"> return `<div class="suite-card">
<h3 class="suite-card-title">Database bridge unavailable</h3> <h3 class="suite-card-title">Database bridge unavailable</h3>
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.</p> <p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.</p>
</div>`; </div>`;
} }

0
plugins/sysdeck-db/index.html Executable file → Normal file
View File

0
plugins/sysdeck-db/manifest.json Executable file → Normal file
View File

View File

@ -65,13 +65,17 @@ export async function mount(panel, { bridge, EventBus }) {
// Clean up the interval when the panel leaves the DOM // Clean up the interval when the panel leaves the DOM
// (house pattern from netsec.js). // (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)) { if (!document.body.contains(panel)) {
clearInterval(panel._festerInterval); 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 ──────────────────────────────────────────────────── // ── refresh loop ────────────────────────────────────────────────────

0
plugins/sysdeck-fester/index.html Executable file → Normal file
View File

0
plugins/sysdeck-fester/manifest.json Executable file → Normal file
View File

View File

@ -201,7 +201,7 @@ function renderBackendSelector(backends, excluded, activeBackend, templates, act
? '<span class="suite-badge success" style="margin-left:0.25rem">installed</span>' ? '<span class="suite-badge success" style="margin-left:0.25rem">installed</span>'
: '<span class="suite-badge danger" style="margin-left:0.25rem">not installed</span>'; : '<span class="suite-badge danger" style="margin-left:0.25rem">not installed</span>';
return ` return `
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(6,102,204,0.08);' : ''}"> <label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(63,201,176,0.08);' : ''}">
<input type="radio" name="fw-backend" value="${escapeHtml(b.id)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" /> <input type="radio" name="fw-backend" value="${escapeHtml(b.id)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
<strong>${escapeHtml(b.name)}</strong> <strong>${escapeHtml(b.name)}</strong>
${techBadge} ${techBadge}
@ -431,7 +431,7 @@ function renderTemplateSelector(templates, activeTemplate, state, activeBackend,
const items = filteredTemplates.map((t) => { const items = filteredTemplates.map((t) => {
const isActive = t.name === activeTemplate; const isActive = t.name === activeTemplate;
return ` return `
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(6,102,204,0.08);' : ''}"> <label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(63,201,176,0.08);' : ''}">
<input type="radio" name="fw-template" value="${escapeHtml(t.name)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" /> <input type="radio" name="fw-template" value="${escapeHtml(t.name)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
<strong>${escapeHtml(t.name)}</strong> <strong>${escapeHtml(t.name)}</strong>
${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''} ${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''}
@ -542,7 +542,7 @@ function renderBans(bans, total) {
</tbody> </tbody>
</table> </table>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem"> <p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
Ban sets: <code>${sets.join(', ')}</code>. Ban sets: <code>${escapeHtml(sets.join(', '))}</code>.
ssh_abuse = SSH brute-force ban (1h timeout), ssh_abuse = SSH brute-force ban (1h timeout),
port_scanners = port scan detection (1h timeout), port_scanners = port scan detection (1h timeout),
connlimit_abuse = connection rate limit exceeded (10m timeout). connlimit_abuse = connection rate limit exceeded (10m timeout).

0
plugins/sysdeck-firewall/index.html Executable file → Normal file
View File

0
plugins/sysdeck-firewall/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-firmware/index.html Executable file → Normal file
View File

0
plugins/sysdeck-firmware/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-fleet/index.html Executable file → Normal file
View File

0
plugins/sysdeck-fleet/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-glances/index.html Executable file → Normal file
View File

0
plugins/sysdeck-glances/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-integrity/index.html Executable file → Normal file
View File

0
plugins/sysdeck-integrity/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-jellyfin/index.html Executable file → Normal file
View File

View File

@ -79,10 +79,10 @@ function renderServiceCard(summary, available, running, webUrl) {
<h3 class="suite-card-title">Jellyfin Service</h3> <h3 class="suite-card-title">Jellyfin Service</h3>
<div class="suite-card-body"> <div class="suite-card-body">
<p class="suite-muted"> <p class="suite-muted">
${escapeHtml(summary?.reason || webStatus?.reason || 'Jellyfin is not installed.')} ${escapeHtml(summary?.reason || 'Jellyfin is not installed.')}
</p> </p>
${(summary?.install || webStatus?.install) ${summary?.install
? `<p class="suite-muted" style="margin-top:0.5rem"><code>${escapeHtml(summary?.install || webStatus?.install)}</code></p>` ? `<p class="suite-muted" style="margin-top:0.5rem"><code>${escapeHtml(summary.install)}</code></p>`
: ''} : ''}
<p class="suite-muted" style="margin-top:0.5rem"> <p class="suite-muted" style="margin-top:0.5rem">
Jellyfin is GPL-2.0 licensed by the Jellyfin contributors — Jellyfin is GPL-2.0 licensed by the Jellyfin contributors —

0
plugins/sysdeck-jellyfin/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-kata/index.html Executable file → Normal file
View File

0
plugins/sysdeck-kata/kata.js Executable file → Normal file
View File

0
plugins/sysdeck-kata/manifest.json Executable file → Normal file
View File

View File

@ -74,13 +74,17 @@ export async function mount(panel, { bridge, EventBus }) {
// Clean up the interval when the panel leaves the DOM // Clean up the interval when the panel leaves the DOM
// (house pattern from netsec.js / fester.js). // (house pattern from netsec.js / fester.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.klankerObserver) panel.klankerObserver.disconnect();
if (panel.klankerObserver) panel.klankerObserver.disconnect();
panel.klankerObserver = new MutationObserver(() => {
if (!document.body.contains(panel)) { if (!document.body.contains(panel)) {
clearInterval(panel._klankerInterval); clearInterval(panel._klankerInterval);
observer.disconnect(); panel.klankerObserver.disconnect();
} }
}); });
observer.observe(document.body, { childList: true, subtree: true }); panel.klankerObserver.observe(document.body, { childList: true, subtree: true });
} }
// ── refresh loop ──────────────────────────────────────────────────── // ── refresh loop ────────────────────────────────────────────────────

0
plugins/sysdeck-mesh/index.html Executable file → Normal file
View File

0
plugins/sysdeck-mesh/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-mining/index.html Executable file → Normal file
View File

0
plugins/sysdeck-mining/manifest.json Executable file → Normal file
View File

View File

@ -60,7 +60,7 @@
border: 1px solid var(--sysdeck-border); border-radius: 4px; border: 1px solid var(--sysdeck-border); border-radius: 4px;
padding: 0.35rem 0.5rem; font-size: 0.85rem; padding: 0.35rem 0.5rem; font-size: 0.85rem;
} }
.sysdeck-badge.info { background: rgba(0, 102, 204, 0.18); color: #6cb6ff; } .sysdeck-badge.info { background: rgba(63, 201, 176, 0.14); color: var(--sysdeck-accent, #3fc9b0); }
</style> </style>
</head> </head>
<body> <body>

View File

@ -326,7 +326,14 @@ async function refresh(panel, bridge) {
try { try {
catalog = await bridge.modules3p.status(); catalog = await bridge.modules3p.status();
} catch (e) { } catch (e) {
showRowFlash(panel, `Refresh failed: ${e.message || e}`, "danger"); // the catalog stays rendered; the failure is a banner, not a
// replacement of the whole panel
const flash = panel.querySelector('#modules-flash');
if (flash) {
flash.textContent = `Refresh failed: ${e.message || e}`;
flash.style.display = 'block';
setTimeout(() => { flash.style.display = 'none'; }, 4000);
}
return; return;
} }
panel.innerHTML = renderShell(catalog); panel.innerHTML = renderShell(catalog);

4
plugins/sysdeck-monitoring/index.html Executable file → Normal file
View File

@ -51,11 +51,11 @@
transition: all 0.15s; transition: all 0.15s;
} }
.monitoring-tab:hover { .monitoring-tab:hover {
background: rgba(6,102,204,0.08); background: rgba(63,201,176,0.08);
color: var(--sysdeck-fg, #e0e0e0); color: var(--sysdeck-fg, #e0e0e0);
} }
.monitoring-tab.active { .monitoring-tab.active {
background: rgba(6,102,204,0.12); background: rgba(63,201,176,0.12);
color: var(--sysdeck-accent, #0666cc); color: var(--sysdeck-accent, #0666cc);
border-color: var(--sysdeck-border, #333); border-color: var(--sysdeck-border, #333);
border-bottom: 2px solid var(--sysdeck-accent, #0666cc); border-bottom: 2px solid var(--sysdeck-accent, #0666cc);

0
plugins/sysdeck-monitoring/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-netsec/index.html Executable file → Normal file
View File

0
plugins/sysdeck-netsec/manifest.json Executable file → Normal file
View File

View File

@ -11,7 +11,7 @@
* *
* The panel has three sections (matching iptraf-ng's views): * The panel has three sections (matching iptraf-ng's views):
* 1. Interface Overview — live RX/TX rate cards (bytes/s, packets/s) * 1. Interface Overview — live RX/TX rate cards (bytes/s, packets/s)
* per interface. Auto-refreshes every 3s. The traffic subcommand * per interface. Auto-refreshes every 5s. The traffic subcommand
* samples /proc/net/dev twice (1s apart) to compute live rates. * samples /proc/net/dev twice (1s apart) to compute live rates.
* 2. IP Traffic Monitor — active TCP/UDP connections table (proto, * 2. IP Traffic Monitor — active TCP/UDP connections table (proto,
* state, local addr:port, remote addr:port, TX/RX queue). Reads * state, local addr:port, remote addr:port, TX/RX queue). Reads

0
plugins/sysdeck-packages/index.html Executable file → Normal file
View File

0
plugins/sysdeck-packages/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-photos/index.html Executable file → Normal file
View File

0
plugins/sysdeck-photos/manifest.json Executable file → Normal file
View File

View File

@ -247,6 +247,6 @@ function renderSkeleton() {
function renderError(err) { function renderError(err) {
return `<div class="suite-card"> return `<div class="suite-card">
<h3 class="suite-card-title">Photos bridge unavailable</h3> <h3 class="suite-card-title">Photos bridge unavailable</h3>
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.</p> <p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.</p>
</div>`; </div>`;
} }

0
plugins/sysdeck-policy/index.html Executable file → Normal file
View File

0
plugins/sysdeck-policy/manifest.json Executable file → Normal file
View File

0
plugins/sysdeck-remotefs/index.html Executable file → Normal file
View File

0
plugins/sysdeck-remotefs/manifest.json Executable file → Normal file
View File

View File

@ -291,6 +291,6 @@ function renderSkeleton() {
function renderError(err) { function renderError(err) {
return `<div class="suite-card"> return `<div class="suite-card">
<h3 class="suite-card-title">Remote FS bridge unavailable</h3> <h3 class="suite-card-title">Remote FS bridge unavailable</h3>
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/remotefs.py.</p> <p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/remotefs.py.</p>
</div>`; </div>`;
} }

Some files were not shown because too many files have changed in this diff Show More