187 KiB
Executable File
SysDeck — Release Notes
Author: Jeremy Anderson · info@dcos.net · https://dcos.net
v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated)
(latest release: v0.4.1 — cockpit module detection; see the last entry below)
v0.3.0 answers the operator's question: "lets take a look at this project klanker-gate, i believe its mainly coded for a windows platform. how much work would it be to port it to arch linux and into the sysdeck as a module." The answer surprised the premise, so this entry records both the verdict and the evidence.
The verdict: zero source changes (it was never a Windows codebase)
klanker-gate — internally "Frosty Deno" — is a Deno 2 + TypeScript LLM gateway by TykoDev (https://github.com/TykoDev/klanker-gate, Apache-2.0; not SysDeck code — full credit and license notes in klanker-gate/ATTRIBUTION.md and THIRD_PARTY.md). Deno runs identically on every platform. The Windows mentions in its 448-file tree are accommodations for a second-class Windows dev platform, not Windows-first code:
apps/gateway/cluster.ts—reusePortSupported()returns true only forlinuxanddarwin. Windows is locked to single-process serving, and the failure message literally directs the operator to "Use Docker/Linux for multi-process".Dockerfile— both stages are Linux images (denoland/deno:2.9.3,denoland/deno:alpine-2.9.3). The Windows comment in it is about Defender locking files on a Windows host.deploy/docker-entrypoint.sh— a POSIX/bin/shscript.scripts/—.shand Deno.tstasks; not a single.bat/.ps1in the tree.deno.lockwin32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS.
So the "port" is a packaging exercise: install Deno + PostgreSQL, manage the process with systemd. On top of that, moving to Arch unlocks a feature Windows cannot have — FROSTY_WORKERS multi-process serving through SO_REUSEPORT (Linux/darwin only).
What ships in sysdeck-0.3.0-master.tar.bz2
/— the cockpit edition: 27 plugins (new: sysdeck-klanker), 28 bridge modules./web— the SysDeck Web Edition: 29 bridge modules (new: klanker, the AI Gateway panel)./web/mini-services/fester— Fester, vendored + pre-integrated (independent version 0.2.1), unchanged from v0.2.0./klanker-gate— klanker-gate vendored + pre-integrated (by TykoDev — https://github.com/TykoDev/klanker-gate — independent version 0.9.0, Apache-2.0, credited in itsATTRIBUTION.md), including the newarch/packaging directory.
Run without Cockpit — the complete runbook (0.3.0)
v0.3.0 also answers: "make better instructions on running sysdeck without cockpit with just the nextjs backend." The web edition is fully standalone — no cockpit, no Python bridge, no systemd, no root — and the instructions now exist in three kept-in-sync forms:
- the "Run without Cockpit" panel in the web console (system group, right under Overview): prerequisites, the one-command
make web-devquickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the.envreference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard. web/README.mdin the master tarball — the same runbook as plain markdown.- QUICKSTART §11 — the condensed version, plus §12 for the skin below.
The web-edition skin for Cockpit (0.3.0)
"…or completely theme cockpit to look like the web edition 0.3.0 as demoed" — done, without touching a single module:
shared/sysdeck-web.css— the skin: every plugin'sindex.htmllinks it after the base stylesheet. It ports the Next.js console's midnight/teal design (accent#3fc9b0, soft-tinted badges, 10px radii, tabular numerals, teal-edged scrollbars, reduced-motion support) onto the.sysdeck-*/.suite-*vocabulary. Revert: delete the file.sudo make install-branding— themes the Cockpit shell chrome itself (sidebar, header, login) via/usr/share/cockpit/branding.css, Cockpit's documented override point; covers PatternFly v4 (pf-c-*) and v5 (pf-v5-*) generations, backs up any distrobranding.cssfirst,make uninstall-brandingrestores it.
The Arch packaging (klanker-gate/arch/)
PKGBUILD— self-packaging (files come from the tree the file lives in): gateway tree →/usr/share/klanker-gate, wrapper →/usr/bin/klanker-gate, env →/etc/klanker-gate/env(pacmanbackup=()), unit + sysusers + tmpfiles in their canonical locations.depends=('deno>=2.9'); postgres is anoptdepends(remoteFROSTY_PG_URLis equally supported).klanker-gate.service— hardened systemd unit:StateDirectory=klanker-gate,WorkingDirectory=/var/lib/klanker-gate(so the permission contract's--allow-write=dataresolves to/var/lib/klanker-gate/data),ProtectSystem=full,PrivateTmp, emptyCapabilityBoundingSet. Deliberately noMemoryDenyWriteExecute(V8's JIT needs W^X pages).run.sh— the ExecStart wrapper: one-timedeno cache --frozenwarmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus--allow-runscoped to the Deno binary only whenFROSTY_WORKERS>1— mirroring the upstream entrypoint's own escalation policy.INSTALL-ARCH.md— the full runbook:makepkg -si, local postgres provisioning (role + database), env configuration,systemctl enable --now, healthcheck curls, the multi-worker bonus, the optional control-UI build, and the SysDeck wiring for both editions.
The klanker module (both editions)
bridge/klanker.py— stdlib-only REST client (urllib, 4s timeout — never outliving the panel's 5s poll), 10 subcommands:status(healthz + version, merged + enriched),providers,models,vkeys,logs --limit,analytics --window,runtime,service <action>(systemctl wrapper for klanker-gate.service),journal [N](journalctl tail, ANSI-stripped, token-masked, 32 KB cap),localstack(probes ollama/llama.cpp/koboldcpp/LM Studio/SGLang/vLLM/v1/modelson this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples).KLANKER_URL(defaulthttp://127.0.0.1:8080) andKLANKER_ADMIN_TOKEN(header-only, never echoed) drive it. Connection failures are graceful JSON with a remediation hint — same contract as fester.py.plugins/sysdeck-klanker/— full panel: gateway status card, stat grid (providers, virtual keys, requests 24h, spend 24h — upstream money is integer micro-USD, displayed as$X.XXXX), providers table with health dots, virtual keys table, recent-requests table, model catalog, local stack wiring card (live-probed backend matrix + copyable env/curl recipes), runtime topology card, service control (start/stop/restart with confirm) and a journal viewer. 5s auto-refresh that preserves the service/journal card state.- Web edition — the AI Gateway panel under Integrations: hybrid live/demo. It probes the gateway first (
KLANKER_URL, 1.5s timeout, BearerKLANKER_ADMIN_TOKEN); unreachable → seeded demo rows, clearly badged, with a note explaining that this sandbox has no Deno/Postgres. On a host running the gateway, it flips tosource: liveuntouched. shared/bridge.js— the klanker surface: 11 methods.check-bridge-subcommandsnow verifies 218 calls across 28 bridge modules (was 216/27 — the audit pass added auth readers+certs).
The local stack is first-class (0.3.0 follow-up)
A fair question after the integration: "it seems to be mainly for SaaS
account linking, i personally only run a local stack such as ollama,
llama.cpp, koboldcpp" — can all features be met locally? Yes: the
upstream provider registry has five keyless self-hosted types —
ollama, lmstudio, sgl, and the generic openai-compatible /
anthropic-compatible ("user supplies the base URL and an optional
key", per the registry's own comment) — and llama-server/KoboldCpp/vLLM
all speak the OpenAI wire. Governance, virtual keys, budgets, fallback
- weighted load-balancing, model auto-discovery (
refresh-models), the request ring and analytics are all provider-agnostic, so they work unchanged over a local stack — spend just reads ~$0.
What the release adds on top: the localstack probe + the Local
stack wiring cards in both editions, env/admin-API recipes with the
8080 port-collision warning (llama-server's default = the gateway's
port), QUICKSTART §10.1, and a local-first re-seeded web demo dataset
(ollama + llama-server + koboldcpp + lmstudio + sglang + one groq
overflow row — 24h spend ≈ $0.001, all of it the cloud fallback).
And the follow-up question — "it should be easy to toggle this ai
gateway on or off in case i decide to use something like pi" — is now
a first-class shell feature: module visibility toggles (web
edition). Every sidebar row carries a power control; a disabled module
vanishes from navigation and the command palette, lands in a
Disabled (N) section for one-click re-enable, and the state
persists in SQLite (shell.disabled, new shell bridge module,
audited). The POST /api/bridge envelope also got a correctness fix:
command-level fail() responses now pass through top-level instead of
being double-wrapped, so panels actually render command errors.
The 0.3.0 security audit (follow-up)
"do a security audit on klankergate code and the full sysdeck codebase afterwards" — done, end to end, and the release is better for it. Four audit passes covered the vendored gateway (Deno routes, crypto, admin surface, compose), the cockpit bridge (28 helpers), the 27 plugin panels, and the web edition (bridge dispatcher, 31 modules, panels, fester service).
SysDeck code: fixed, not filed. The two criticals in the cockpit
bridge were both "the guard exists 100 lines away and this command
forgot it" — policy.py cgroup-set wrote <any-path>/<control-file>
as root (arbitrary file overwrite → one-prompt persistent root via
/etc/cron.d), and builder.py artifacts-clear rmtree'd an
unvalidated profile argument. Both now resolve-and-bound exactly like
their siblings; the same treatment went to build-log/build-delete/
artifacts/profile-create (traversal + config injection into
root-executed build configs). hwalert carried the tree's only sudo sh -c f-string — a literal root shell injection, latent only because
the module isn't wired to a panel yet — now a direct write behind a
sysfs device-path guard. The db module's start/stop/restart accepted
arbitrary unit names (db stop sshd) and its query promised
DDL-refusal in a comment while running any statement; both are honest
now. The 8 oldest panels rendered live tool output unescaped into
innerHTML (package names from repos, USB descriptors, fwupd metadata
— the XSS→cockpit-session→bridge-RCE chain); all escape now, and the
27 manifests dropped unsafe-eval. The web edition bound every
surface to 0.0.0.0 unauthenticated — dev server, fester service, and
the port gateway's wildcard transform — so everything now pins
loopback, the bridge gained a body cap + per-IP rate limit, and
unexpected errors return generic text (details to the server log).
klanker-gate: audited, credited, and left unmodified. The vendoring
contract (byte-identical upstream tree, SysDeck adds only arch/)
holds — so the audit's 10 upstream findings (3 critical: the admin API
runs unauthenticated when FROSTY_ADMIN_TOKEN is unset, the gateway
binds 0.0.0.0 by default, and /v1/* is open until the first virtual
key exists) are documented in klanker-gate/arch/SECURITY-UPSTREAM.md
as an advisory that can travel upstream, while the SysDeck packaging
layer mitigates what packaging can: the systemd unit now refuses to
start without the token (fail-closed ExecStartPre, opt-out via
drop-in), and INSTALL-ARCH.md §9 carries the firewall + create-a-vkey
-immediately runbook. The audit also recorded what upstream does
well — AES-256-GCM envelope key storage, SHA-256-hashed vkeys,
redacted provider views, an opt-in-only content log with deep secret
redaction, and a properly sandboxed Code Mode worker.
Verified: make check 254/254, check-bridge-subcommands 218/28 (the audit pass added auth readers+certs),
python compile checks on all 8 touched bridge files, panel import
checks, bun run lint clean, and the tarball rebuilt with new builder
guards that fail the build if any of the fixes regress.
Verification
- Cockpit tree:
make check— all 254 tests + guards pass with the new module (bridge subcommands cross-check, manifest consistency, version sync across all 9 surfaces now reporting 0.3.0). - Bridge offline smoke:
status/service status/journalall return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed). - Web edition:
bun run lintclean; bridge smoke — all 7 klanker commands returnok:truewithsource:'demo'; panel render + mobile verified with zero page/console errors. - Master tarball rebuilt by
scripts/make-master-tarball.shwith the vendoredklanker-gate/; verified by extraction, file count, sha256.
Notes
- klanker-gate's version (0.9.0) is intentionally independent from SysDeck's (0.3.0), same as Fester's (0.2.1) — each mirrors its own repository's version line inside the bundle.
- The gateway cannot run inside the web edition's dev sandbox (no Deno runtime, no PostgreSQL); that is what the honest DEMO badge communicates. On the operator's Arch host,
arch/INSTALL-ARCH.mdis the 5-minute path to the LIVE badge.
v0.2.0 — 2026-08-20 (Master Edition: one tarball, two editions, Fester pre-integrated)
v0.2.0 turns SysDeck into a single distributable that ships both editions with Fester vendored and wired in. The release answers the operator's framing directly: "fester exists as a separate repository — it deserves its own. generate a master tarball of sysdeck with fester pre-integrated."
What ships in sysdeck-0.2.0-master.tar.bz2
/— the cockpit edition, unchanged upstream layout: 26 plugins, bridge/, shared/, packaging, tests, docs./web— the NEW SysDeck Web Edition: a standalone Next.js 16 console (28 bridge modules) with real/proc+/syscollectors where the host allows, and clearly badged demo datasets where backends are absent. New panels: Overview landing view, Hardware Alerts (the orphaned bridge that never had a UI), and fully-built Mesh / Vault / Hardware Auth / Firmware./web/mini-services/fester— Fester, vendored + pre-integrated. Fester stays an independent project with its own version line (0.2.1); the master tarball pins a snapshot so no separate checkout is needed.
Fester pre-integration (the cockpit side is real now)
bridge/fester.py— the v0.0.31 systemd-listing stub is gone. 11 real subcommands against the fester REST API (stdlib urllib, 4s timeout,FESTER_URLoverride, defaulthttp://127.0.0.1:3010):status,metrics,builds,build <id>,nodes,targets,timeline <id>,sessions,start-build --project --targets [--no-cache] [--retries] [--fail-action],cancel <id>,replay <id>. Every subcommand degrades to actionable JSON when the service is down ("start it withmake fester-start…").plugins/sysdeck-fester/fester.js— a real panel: service status card, stat grid (builds total/running/succeeded/failed, cache-hit rate), cluster nodes table, live+history builds table with state chips and row actions (Cancel, Replay → inline session id, Timeline → expandable event log), a start-build form built from the target catalog, 5s auto-refresh that preserves form state.shared/bridge.js— the fester surface grew from 1 method to 11;check-bridge-subcommandsnow verifies 206 calls across 27 bridge modules (was 195/26).
Also fixed in this release
- The shipped 0.1.3 Makefile was broken. Its recipes were indented with 8 spaces instead of tabs — GNU make rejects the file outright (
missing separator (did you mean TAB instead of 8 spaces?)), somake install/make distcould not run from the released tarball. v0.2.0 restores tab indentation (the fix the v0.0.28 guard itself recommended) andmake -npasses for every target. - New Makefile targets:
fester-start(vendored fester service on :3010),web-install(bun + prisma setup for the web edition),web-dev(fester in the background + web console on :3000),master(rebuild the master tarball from the tree). - Version surfaces bumped 0.1.3 → 0.2.0 across all release surfaces (Makefile,
bridge/__init__.py, setup.py, PKGBUILD, spec, debian/changelog, compat-manifest, metainfo).
Verification
- Cockpit tree guards:
python3 -m py_compile bridge/fester.pyclean;node --checkclean onfester.jsandbridge.js;tests/check_bridge_subcommands.py→ 206/206 across 27 modules;check-makefile-recipesgreen (tabs restored);make -nparses for install / plugins / dist / master / web-dev / fester-start. - Live integration smoke against the running fester service:
start-build --project gentoo-stage3 --targets mipsel→ buildgentoo-stage3-mtw730qo(7/7 actions, 7 CAS cache hits, 1050 ms critical path); a second build listedrunningthencancel→{"ok": true}(final state cancelled, 11 timeline events);replay→ sessionsess-tl6ax5-uvo7vh. - Web edition end-to-end: all 28 modules clicked through with zero page errors and zero console errors; the Fester sub-app through the gateway shows live WS events, cluster nodes, and mid-flight builds; the theme engine re-tints the whole suite.
- Master tarball assembled by
scripts/make-master-tarball.sh; verified by extraction, file count, and sha256.
Notes
- Fester's version (0.2.1) is intentionally independent from SysDeck's (0.2.0) — it mirrors the separate-repo reality and lets the vendored snapshot track upstream releases without forcing a SysDeck release.
- The web edition's Prisma-backed modules (mesh, vault, containers, mining, …) are demo-badged where the host lacks the backend — the badge is honest, the data shapes are real.
v0.1.3 — 2026-08-19 (critical: import fixed with shutil.which + mkosi reads profile via temp symlink + download/manage UI)
Theme: the import was silently empty and the build still wasn't reading the config
v0.1.3 fixes two more critical bugs that v0.1.2 missed, and adds the download/manage UI the operator asked for. The operator's report:
profile workstation still doesnt import current system pkgs. it trys to build only 2. which is repetitive at this point.
Two root causes. Both are embarrassingly simple.
Import bug: from __init__ import failed in the cockpit context
_detect_host_packages() started with:
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from __init__ import DISTRO, PKG_MANAGER
except Exception:
DISTRO = "unknown"
PKG_MANAGER = "unknown"
The except Exception: PKG_MANAGER = "unknown" was the silent killer.
When the cockpit superuser channel runs the bridge helper, the Python
path context is different — from __init__ import fails, the except
clause silently sets PKG_MANAGER = "unknown", and the function
returns ([], "unknown"). No error, no warning, just an empty list.
The import call in profile_import_packages then wrote nothing (the
empty-list early return), and the build used the profile's original
4 template packages — of which mkosi installed 2 (the base iana-etc
and filesystem).
Fix: use shutil.which() to find the binary directly:
pacman_bin = shutil.which("pacman")
if pacman_bin:
cmd = [pacman_bin, "-Qqe"]
marker = "arch"
No import dependency. Works in any execution context. If pacman
isn't on PATH, it tries apt-mark, then dnf. If none are found,
returns ([], "unknown") — but now the "unknown" is real, not a
silent import failure.
Build bug: --include doesn't replace the base config
v0.1.2 added --include <profile_path> to the mkosi command. I
thought --include told mkosi "load this config file." It doesn't.
mkosi's --include flag includes a drop-in fragment ON TOP OF
the base mkosi.conf. The base config must still exist as
mkosi.conf in the cwd. If it doesn't, mkosi uses defaults and
the --include file is silently ignored.
So for the operator's profile at /etc/mkosi/mkosi.conf.d/arch-workstation.conf:
- mkosi runs in
/etc/mkosi/mkosi.conf.d/ - looks for
mkosi.confthere — doesn't find one - uses empty defaults (2 base packages)
- the
--include arch-workstation.conffile is layered on top of nothing, effectively ignored
Fix: create a temp directory, symlink the profile file as
mkosi.conf inside it, and run mkosi from there:
def _prepare_mkosi_work_dir(profile):
tmpdir = Path(tempfile.mkdtemp(prefix="sysdeck-mkosi-"))
link = tmpdir / "mkosi.conf"
link.symlink_to(Path(profile["path"]).resolve())
return tmpdir
Then build() sets work_dir = tmpdir, so mkosi's cwd is the temp
dir. mkosi finds mkosi.conf (the symlink), follows it, reads the
actual profile file. Works for ANY profile path — v0.0.x drop-ins,
v0.1.0+ per-profile dirs, even profiles in random locations.
The temp dir is cleaned up after the build finishes.
New: download artifacts directly from the panel
Each artifact in the Artifacts panel now has a ⬇ Download button.
The implementation uses cockpit.spawn(["cat", path], { superuser: "try", binary: true }) to read the file as a binary stream, collects
the chunks into a Blob, creates an object URL, and triggers a
browser download via a synthetic <a download> click. Works for
files of any size (streamed, not loaded into memory all at once by
the bridge — the JS side does collect chunks, but cockpit handles
the transport efficiently).
New: manage artifacts — delete per-file, clear per-profile
Each artifact has a 🗑 button that calls artifact-delete <profile> <name>. The Python side resolves the path safely (refuses path
traversal outside BUILDER_ARTIFACTS_DIR) and unlink()s the file.
Each profile's artifacts card has a 🗑 Clear all button that calls
artifacts-clear <profile>. Removes the entire
/var/lib/sysdeck/builder/artifacts/<profile>/ directory. Returns
file count + bytes freed for the log.
The card header now shows total size: myarch artifacts (3, 1.2 GB).
New: manage builds — delete state + log (+ optionally artifacts)
Each build in the Builds table has a 🗑 button. Two-step confirm:
- "Delete build record?" — OK = delete state + log only
- If Cancel: "Also delete ALL artifacts for profile?" — OK = delete state + log + the profile's entire artifacts dir
The Python build-delete <build-id> [--artifacts] reads the state
file FIRST (to get the profile name for artifact cleanup) before
deleting it. Then deletes state + log. If --artifacts, also
shutil.rmtree() the artifacts dir.
Regression tests
11 new unit tests across two new test classes:
-
TestBuilderArtifactManagement(7 tests):test_artifact_delete_removes_filetest_artifact_delete_refuses_path_traversaltest_artifacts_clear_removes_alltest_build_delete_removes_state_and_logtest_build_delete_with_artifacts_flag_clears_artifacts_dirtest_build_delete_nonexistent_returns_errortest_new_subcommands_registered_in_commands
-
TestBuilderMkosiTempWorkDir(3 tests):test_prepare_creates_temp_dir_with_mkosi_conf_symlinktest_prepare_returns_none_for_missing_profile_pathtest_prepare_returns_none_for_nonexistent_file
Existing test_detect_host_packages_pacman rewritten to mock
shutil.which instead of __import__. test_build_success_path
updated to no longer expect --include on the command line.
Total: 254 tests (was 243 in v0.1.2; +11). All pass.
The mkosi command now
$ mkosi build --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force
# work_dir: /tmp/sysdeck-mkosi-abc123
# backend: mkosi
# profile: myarch
# output_dir: /var/lib/sysdeck/builder/artifacts/myarch
work_dir is the temp dir containing mkosi.conf → symlink to the
real profile. mkosi reads the symlink, gets the real config. No
--include needed.
v0.1.2 — 2026-08-19 (critical: mkosi never read the profile — --include + auto-migrate)
Theme: the builder had zero package awareness because mkosi never saw the config
v0.1.2 fixes the critical "zero packages" bug. The operator's report was unambiguous:
the builder absolutely does not work yet. it has zero awareness of packages we tell it to add.
Two compounding root causes were identified and both fixed.
Root cause 1: mkosi never read the profile config file
_backend_build_command() for mkosi was:
cmd = ["mkosi", "build", "--output", name, "--output-dir", dir, "--force"]
No flag tells mkosi WHERE the profile config file is. mkosi's default
behavior: look for a file literally named mkosi.conf in the cwd. If
it doesn't find one, it uses EMPTY defaults — no distribution override,
no packages, no output settings, nothing.
For the operator's profile at /etc/mkosi/mkosi.conf.d/arch-workstation.conf:
work_dir=/etc/mkosi/mkosi.conf.d/(parent of the profile file)- mkosi runs in that cwd
- mkosi looks for
mkosi.confin/etc/mkosi/mkosi.conf.d/ - The file is named
arch-workstation.conf, NOTmkosi.conf - mkosi finds no config → uses empty defaults
- Zero packages installed
Even for v0.1.0+ profiles at /etc/mkosi/profiles/<name>/mkosi.conf,
the file IS named mkosi.conf so mkosi would find it — but only
because of the directory layout, not because sysdeck was explicit
about it. That's fragile.
Fix 1: --include <profile_path> on every mkosi build
cmd = ["mkosi", "build"]
if ppath:
cmd += ["--include", ppath]
cmd += ["--output", output_name, "--output-dir", artifacts_dir, "--force"]
--include tells mkosi to explicitly load the profile config by path,
regardless of its filename or location. This is the fix for "zero
awareness of packages" — mkosi now ALWAYS sees the profile config,
whether it's at /etc/mkosi/profiles/myarch/mkosi.conf (v0.1.0 layout)
or /etc/mkosi/mkosi.conf.d/arch-workstation.conf (v0.0.x layout).
Root cause 2: legacy Packages= syntax silently parsed as garbage
Even when mkosi DID read the profile file (e.g. v0.1.0+ profiles with
correct location), profiles created by v0.0.x used the old indented
Packages= syntax:
[Packages]
Packages=
linux
linux-firmware
systemd
openssh
mkosi v22+ (Arch ships 25.x) only understands single-line:
[Packages]
Packages=linux linux-firmware systemd openssh
The old indented form is silently parsed as a single package name with
embedded newlines ("linux\nlinux-firmware\nsystemd\nopenssh"), which
doesn't exist in any repo — so mkosi installs NOTHING. The build
"succeeds" but the image has zero of the operator's requested packages.
Fix 2: auto-migrate legacy Packages= syntax before every build
New _migrate_legacy_mkosi_packages(conf_path) function:
- Reads the profile file
- Detects the old indented syntax via regex
- Extracts package names from the indented block
- Rewrites the
Packages=line to single-line space-separated form - Writes the file back IN-PLACE
build() calls this automatically on every mkosi build, BEFORE
constructing the command. The migration is logged in:
- Build state JSON (
warningsarray):"packages_migrated: rewrote Packages= from old indented syntax to single-line (N packages: ...)" - Log file header:
# MIGRATED: rewrote Packages= from old indented syntax to single-line (N packages: ...)
If the file already uses modern syntax, the migration is a no-op
(returns {"migrated": False, "reason": "already uses single-line syntax"}).
Regression tests
4 new unit tests in TestBuilderBuildPath:
test_migrate_rewrites_old_indented_syntax— verifies oldPackages=\n linux\n vim\nis rewritten toPackages=linux vimtest_migrate_noop_on_modern_syntax— verifies already-modern files are left unchangedtest_migrate_noop_on_no_packages_section— verifies files without[Packages]are left unchangedtest_migrate_runs_during_build— end-to-end:build()with a profile containing old syntax auto-migrates before mkosi runs, and the migration is recorded in state + log
The existing test_build_success_path was extended to verify
--include is on the command line and points at the profile file.
Total: 243 tests (was 239 in v0.1.1; +4). All build-time guards pass.
The mkosi command line now
$ mkosi build --include /etc/mkosi/profiles/myarch/mkosi.conf --output myarch.raw --output-dir /var/lib/sysdeck/builder/artifacts/myarch --force
# work_dir: /etc/mkosi/profiles/myarch
# backend: mkosi
# profile: myarch
# output_dir: /var/lib/sysdeck/builder/artifacts/myarch
Every flag sysdeck needs is on the CLI. Nothing depends on the
profile's mkosi.conf having the right settings — sysdeck forces
the config path, output name, output dir, and overwrite.
v0.1.1 — 2026-08-19 (output path safety fix: CLI flags force artifacts dir)
Theme: trusting mkosi.conf was the bug
v0.1.1 is the "I should have done this in v0.1.0" release. The v0.1.0
fix added OutputDirectory= to the scaffolded mkosi.conf template,
trusting mkosi to honor it. Two problems with that trust:
- Old v0.0.x profiles have no
OutputDirectory=. The operator'sarch-workstationprofile was created by v0.0.50 — it lives at/etc/mkosi/mkosi.conf.d/arch-workstation.confand has no output directory setting. mkosi defaulted to writingimage.rawinto the cwd (/etc/mkosi/mkosi.conf.d/), a system config directory owned by root. - mkosi then refused to overwrite the existing
image.raw. The error message — "Output path /etc/mkosi/mkosi.conf.d/image.raw exists already. (Use --force to rebuild.)" — blocked every rebuild from the panel, which had no way to pass--force.
The operator's response was unambiguous:
this is NOT a safe output path. fix this now.
The fix: don't trust the profile, force the CLI
_backend_build_command() for mkosi was just:
cmd = ["mkosi", "build"]
It is now:
artifacts_dir = options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname)
output_name = options.get("output_name") or f"{pname}.raw"
cmd = [
"mkosi", "build",
"--output", output_name,
"--output-dir", artifacts_dir,
"--force",
]
CLI flags override mkosi.conf (mkosi's documented precedence: CLI >
config file). So the output path is forced to
/var/lib/sysdeck/builder/artifacts/<name>/<name>.raw regardless of
what the profile says — or doesn't say. --force overwrites any
existing image so rebuilds don't fail.
Belt-and-suspenders: refuse unsafe output paths
Even with the CLI force, I added a safety check in build() that
refuses to proceed if the resolved output_dir is not under
/var/lib/, /tmp/, /var/tmp/, or the configured
BUILDER_ARTIFACTS_DIR. This blocks /etc/, /usr/, /boot/,
/bin/, /sbin/, /lib/, /root/, /home/, etc. — anywhere a
stray image.raw would corrupt the system or pollute a user's home.
If an operator somehow passes options.output_dir=/etc/something via
the JS bridge, the build is refused before subprocess.run is called.
The error message:
refusing to build: output directory '/etc/mkosi/evil' is not under
/var/lib/, /tmp/, or /var/tmp/. Build outputs must go to
/var/lib/sysdeck/builder/artifacts/<profile>/ to avoid corrupting
system config directories.
Legacy profile warning
The operator's build was running against a v0.0.x profile in
/etc/mkosi/mkosi.conf.d/. v0.1.0 already fixed the scaffold location
for NEW profiles (they go in /etc/mkosi/profiles/<name>/mkosi.conf),
but the OLD profile is still there. v0.1.1 doesn't refuse to build it
(the output-path safety is handled), but it records a warning in both
the build state JSON and the log file:
# WARNING: profile is in /etc/mkosi/mkosi.conf.d/ (legacy v0.0.x layout).
# mkosi may silently ignore this drop-in fragment.
# Migrate to /etc/mkosi/profiles/<name>/mkosi.conf for a real profile.
The build state JSON gets a warnings array so the panel can surface
it in the UI too.
Log improvement
The build log header now includes the resolved output_dir so the
operator can see exactly where the image will land before mkosi starts:
$ mkosi build --output arch-workstation.raw --output-dir /var/lib/sysdeck/builder/artifacts/arch-workstation --force
# work_dir: /etc/mkosi/mkosi.conf.d
# backend: mkosi
# profile: arch-workstation
# output_dir: /var/lib/sysdeck/builder/artifacts/arch-workstation
Regression tests
2 new unit tests in TestBuilderBuildPath:
test_build_refuses_output_dir_under_etc— verifies the safety check rejectsoutput_dir=/etc/mkosi/evil.test_build_legacy_v050_profile_records_warning— verifies building a profile in/etc/mkosi/mkosi.conf.d/records the legacy warning in state + log.
The existing test_build_success_path was extended to verify the
mkosi command line includes --output, --output-dir, and --force,
and that --output-dir points at the per-profile artifacts dir.
Total: 239 tests (was 237 in v0.1.0; +2). All build-time guards pass.
What the operator should do
sudo make uninstall && sudo make install && sudo systemctl restart cockpit.socket- Delete the old image.raw that mkosi created in the wrong place:
sudo rm /etc/mkosi/mkosi.conf.d/image.raw - Migrate the old profile:
sudo mkdir -p /etc/mkosi/profiles/arch-workstation && sudo mv /etc/mkosi/mkosi.conf.d/arch-workstation.conf /etc/mkosi/profiles/arch-workstation/mkosi.conf - Click Build on
arch-workstation— output will land at/var/lib/sysdeck/builder/artifacts/arch-workstation/arch-workstation.raw
v0.1.0 — 2026-08-19 (builder profile fixup + host pkg import)
Theme: the empty-image bug that turned out to be three bugs in a trench coat
v0.1.0 is the "fix what v0.0.50 should have caught" release. The v0.0.50
fix (adding the missing import re to bridge/builder.py) unblocked
the build() code path, and the operator immediately ran into the next
layer of problems. The build log went:
‣ Installing Arch Linux
Packages (2) iana-etc-20260530-1 filesystem-2025.10.12-1
‣ Generating disk image
‣ /etc/mkosi/mkosi.conf.d/image.raw size is 33.0M, consumes 32.0M.
Then the operator wrote: "nice try but i think our profile build didnt work. i dont see a final tarball or image file to look at." Two red flags in that single last line:
- The file is
image.raw, notmyimage.raw(the template saidOutput=myimage.raw) - It's in
/etc/mkosi/mkosi.conf.d/, not sysdeck's artifacts dir.
The four packages from the scaffold template (linux, linux-firmware,
systemd, openssh) were silently dropped. Three compounding bugs were
to blame, and only one of them was the "obvious" one.
Bug 1: the scaffolded profile was never read
profile-create (since v0.0.31) wrote
/etc/mkosi/mkosi.conf.d/<name>.conf — a drop-in fragment.
mkosi's drop-in semantics: mkosi.conf.d/*.conf files are layered on
top of a parent mkosi.conf. With no parent, mkosi runs as if the
fragment didn't exist. It auto-detected the host distro, defaulted to
Format=disk with Output=image.raw, and used an empty Packages=
list. That's why the output filename was image.raw not myimage.raw,
and why the only packages that got installed were iana-etc +
filesystem — mkosi's hardcoded Arch base.
Fix: each profile now lives in its own directory
/etc/mkosi/profiles/<name>/mkosi.conf. mkosi.conf is the only
filename mkosi reads automatically from the cwd. MKOSI_DIRS updated
to scan /etc/mkosi/profiles first.
This also makes per-profile mkosi.extra/, mkosi.pkg/, etc. work
naturally — operators can drop in supplementary files alongside the
config and mkosi picks them up.
Bug 2: the Packages= syntax was from mkosi v15
The template was:
[Packages]
Packages=
linux
linux-firmware
systemd
openssh
That indented-continuation form was the old systemd-mkosi (≤v15)
syntax. Modern mkosi (v22+, what Arch ships as mkosi 25.x) wants
either Packages=linux linux-firmware systemd openssh on a single
line, or a separate mkosi.pkg file referenced via
Packages=mkosi.pkg. The v0.0.x form was silently parsed as a single
package named "linux\nlinux-firmware\nsystemd\nopenssh" and failed
to install.
Fix: template + writer now emit the modern single-line form. The reader accepts both forms so v0.0.x profiles migrate cleanly on first append/replace.
This bug was particularly nasty because:
- The writer's tests (
test_bridge_parsers.py:2216-2252) asserted" vim\n"was in the file — passing the test meant emitting the wrong syntax. - The reader only understood the indented form, so even if you fixed
the template by hand, the next
--mode=appendwrite would silently re-break the syntax.
Bug 3: the artifact never reached sysdeck's artifacts directory
build() (line 672-681 of the old code) only scanned
/var/lib/sysdeck/builder/artifacts/<profile>/ for artifacts. But mkosi
writes its output to the cwd (/etc/mkosi/mkosi.conf.d/image.raw). No
copy step. Hence "i dont see a final tarball or image file to look at"
from sysdeck's point of view — even though mkosi technically did
produce one.
Fix: _MKOSI_TEMPLATE now sets
OutputDirectory=/var/lib/sysdeck/builder/artifacts/<name> so mkosi
writes directly there. The artifacts panel's discovery code already
scanned that directory, so once mkosi writes there the panel finds it
automatically.
New feature: profile-import-packages
Per operator request: "import current os pkg list to profile should be an option."
Queries the host's explicitly-installed packages:
- Arch:
pacman -Qqe(explicitly installed; excludes deps) - Debian:
apt-mark showmanual(closest analog topacman -Qqe) - Fedora:
dnf repoquery --userinstalled --queryformat '%{name}'
Then writes the result into the profile via the existing
_write_packages dispatch. Defaults to append mode (the operator
usually wants to layer host packages on top of the profile's existing
baseline like linux/systemd/openssh).
Three flags:
--mode=replace— wipe the baseline first--dry-run— return what would be written, don't touch the file--packages=<json>— override the host query with a JSON-encoded multiline string (useful for importing a list captured on a different host)
The panel exposes a "⇩ Import host pkgs" button on every profile row.
Two-step UX: dry-run preview → window.confirm with package count,
source distro, and first 200 packages → append write. Operator can
cancel cleanly without any file changes.
New polkit exec paths for pacman/apt-mark/dnf added to
org.sysdeck.builder.modify.
Regression tests
7 new unit tests in TestBuilderImportHostPackages:
_detect_host_packagesdispatch (pacman path + dedup)--packagesoverride end-to-end (writes the file)--dry-rundoesn't write- unknown profile / no args / bad mode / COMMANDS-registration error paths
(Actually 8 — one of the dispatch tests splits into two methods, one for the happy path and one for dedup. The class total is 8.)
4 existing tests in TestBuilderPackagesField updated for the new
single-line Packages= syntax. 1 new test
(test_mkosi_modern_single_line_input_parsed) guards against a
regression where the writer emits the new form but the reader only
understands the old one — that would silently break append mode on
profiles created by v0.1.0 itself.
Total: 237 tests (was 228 in v0.0.50; +9). All build-time guards pass.
Why v0.1.0 (and not v0.0.51)
The bug fixes are technically backwards-incompatible: profiles created
by v0.0.x live in /etc/mkosi/mkosi.conf.d/<name>.conf and use the
indented Packages= syntax. v0.1.0's reader accepts both syntaxes
(safe migration), but the scaffold location is different. Operators
upgrading from v0.0.x to v0.1.0 should either:
- Move their profiles from
/etc/mkosi/mkosi.conf.d/<name>.confto/etc/mkosi/profiles/<name>/mkosi.conf, or - Create a parent
/etc/mkosi/mkosi.conf(any non-empty[Distribution]section will do) so the existing drop-ins start being honored.
The minor version bump makes the incompatibility visible.
v0.0.50 — 2026-08-19 (build path NameError fix: import re added to bridge/builder.py)
Theme: the one-line fix that took 18 releases to find
v0.0.50 is a one-line bugfix release. An operator reported:
NameError: name 're' is not defined. Did you forget to import 're'? happens right away on build for a new profile i created.
The traceback pointed at bridge/builder.py line 492, inside
_new_build_id():
safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile)
re wasn't imported at module level. The module-level imports were:
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
No import re. _new_build_id has used re.sub since v0.0.31 —
when the build operations were first added. The bug went undetected
for 18 releases (v0.0.31 through v0.0.49) until an operator actually
clicked Build on a freshly-created profile.
Why it went undetected
Three layers of defense all missed it:
-
python3 -m py_compile(themake checksyntax check) only catches syntax errors. ANameErrorat call time is not a syntax error — the code is syntactically valid Python, it just references a name that isn't in scope when the function runs. -
Unit tests. The existing unit tests covered
profile_create,profile_copy,profile_delete, and the v0.0.49 package-writing helpers. None of them callbuild(), andbuild()is the only caller of_new_build_id. So the function that held the bug was never exercised by any test. -
Manual testing. The operator workflow up to v0.0.48 was "create/copy a profile, then build it from a shell." The v0.0.49 release added the inline package-list field, which made the create-then-build flow smooth enough that the operator clicked Build in the panel for the first time — and hit the bug.
The lesson: functions that are reachable only through a specific code
path (here: build() → _new_build_id()) need explicit tests that
exercise that path, even if the function itself looks trivial. The
make check syntax check is necessary but not sufficient.
The fix
One line added to the module-level imports:
import json
import os
import re # ← added
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
Also removed the now-redundant local import re inside
_write_packages_vmdb2 (it was a v0.0.49 workaround — that function
uses re.compile for the YAML-include regex, and I added a local
import there instead of checking whether re was already module-level.
It wasn't. The local import masked the missing module-level import for
_write_packages_vmdb2's own tests, but did nothing for
_new_build_id).
Regression tests
9 new unit tests in TestBuilderBuildPath:
_new_build_idformat: asserts the build_id matches^<profile>-(\d{14})$._new_build_idsanitizes unsafe chars: profile namemyarch.v2→myarch_v2-<ts>(dot replaced with_)._new_build_idpreserves safe chars: profile namemy-arch_profile→my-arch_profile-<ts>(hyphens + underscores kept)._new_build_id_re_imported_at_module_level: explicitassertIn("re", dir(builder)). This is the regression guard — if anyone ever removes theimport reline in a future refactor, this test fails before the tarball ships. The v0.0.31-v0.0.49 bug can't recur.build()success path: end-to-end with mockedsubprocess.run(rc=0). PatchesBUILDER_STATE_DIR/BUILDER_LOGS_DIR/BUILDER_ARTIFACTS_DIRto tempdirs, patchesBACKENDSto fake a mkosi install, patchesprofiles()to return a fake profile. Verifies response shape (build_id/state/rc/success/duration_s/artifacts/log_path), state file written, log file written,subprocess.runwas actually called.build()unknown profile: returns{error: "profile 'nonexistent' not found"}.build()no args: returns{error: "profile name required"}(no crash).build()backend not installed: returns{error: "backend 'mkosi' is not installed", hint: ...}.build()non-zero returncode: mockedsubprocess.runreturns rc=1 → build state"failed",rc=1,success=False.
All tests mock subprocess.run and the module-level state dirs; none
touch real /var/lib/ or invoke real backends. The tests run in 9
milliseconds.
AST audit
To make sure there weren't other latent NameErrors lurking in
builder.py, I wrote an AST-based audit that walks every function
body, collects Name loads, and checks each against (module-level
names + function locals + builtins). The audit flagged ~50 items,
but every one was a false positive:
- Comprehension locals (
b,v,s,p,logf) — bound by the comprehension itself. - Tuple-unpacking targets (
cid,chint,k,v,backend_id,binary,vargs,kind) — bound byfor ... inloops. - Except-clause targets (
exc) — bound byexcept ... as exc:. __file__— provided by Python in every module.
No real undefined names. The build path is now fully exercisable by tests.
What this release is NOT
- No JS changes. This is a Python-only fix. The bridge.js subcommand cross-check stays at 102 calls.
- No new bridge subcommands.
buildis an existing command; it just works now. - No new plugin / polkit / bridge helper file. Counts unchanged.
- No changes to profile discovery, build invocation, or the package-writing helpers. The fix is purely the missing import.
Process improvement
The v0.0.31-v0.0.49 bug existed because no test exercised the
build() code path. v0.0.50 adds that test coverage — 9 tests that
mock subprocess.run and the state dirs, so they run hermetically in
the make check suite without needing a real backend installed. Any
future regression in the build path (missing imports, broken state-
file writing, wrong response shape, wrong subprocess invocation) will
now be caught before the tarball ships.
v0.0.49 — 2026-08-19 (builder inline package list: textarea + file upload + merge mode for all 4 backends)
Theme: close the loop on profile creation — paste the baseline apps inline
v0.0.49 is a feature release for the Image Builder panel. Per user directive: "we should allow adding a pacman -Sy applist.txt with a literal list of baseline apps for the profile being generated."
The v0.0.48 release fixed the dead-end error that operators of archiso-only or live-build-only hosts were hitting, and added the "Copy shipped profile" form. But both forms still left the operator with a half-finished profile: they scaffolded/copied the config, then had to drop to a shell to edit the package list. v0.0.49 closes that loop — the package list is now part of the creation flow.
1. The field
Both the Create Profile and Copy shipped profile forms now include a
shared renderPackagesField(prefix, defaultMode) block with three
parts:
- A
<textarea>for inline paste. One package per line,#comments allowed. This is the source of truth. - A file
<input type="file">accepting.txt/.list/.conf. When the operator picks a file, the browser'sFileReaderAPI reads it as text and populates the textarea — so the operator can review and edit the uploaded content before submitting. 1 MB cap (anything larger is probably not a package list). - A merge-mode
<select>with two options: append (add to baseline, dedup) and replace (overwrite). Default isreplacefor Create (the scaffold's minimal defaults are replaced by the operator's list) andappendfor Copy (the baseline's packages likelinux/baseare preserved, the operator's list adds to them). The operator can flip the toggle per-operation.
The prefix parameter distinguishes element IDs (cp-create-* vs
cp-copy-*) so both forms coexist on the same page without ID
collisions.
2. All 4 backends supported
Each backend stores its package list in a different place and format. Rather than build a generic "update INI/YAML" abstraction (which would leak format details through it), I wrote four per-backend writers:
- mkosi →
[Packages]section of<name>.conf. mkosi v22+ uses an INI continuation: each package on its own indented line underPackages=. The writer parses the existing section, dedups on append, and rebuilds it. Other sections ([Distribution],[Output], etc.) are preserved. - vmdb2 →
bootstrap.includelist in<name>.yaml. Regex-based surgery on the include block. pyyaml is NOT a hard dependency — vmdb2 isn't typically installed on Arch, and pulling in a YAML parser just to update a list would be overkill. Other YAML sections (partitions,commands) are preserved. - archiso →
packages.x86_64in the profile dir. One package per line,#comments allowed. The writer preserves the comment header on append, dedups, and rewrites. In replace mode, writes a fresh file with a header comment + the operator's packages. - live-build →
config/package-lists/sysdeck.list. live-build merges all.listfiles at build time, so each list file is an independent package set. In replace mode, removes oldsysdeck*.listfiles (does NOT touch baseline.listfiles likebaseline.list). In append mode, just writes/overwritessysdeck.list(the file is the unit).
A dispatcher (_write_packages(profile_path, backend, packages_text, mode)) validates the mode, parses the packages text, and routes to
the right writer.
3. Bridge extensions
profile_create() and profile_copy() in bridge/builder.py now
accept two new optional flags:
--packages=<json>— a JSON-encoded string. JSON is used so newlines, quotes, and unicode survive the argv boundary cleanly. The bridge doesjson.loads(arg)to recover the original multiline text.--mode=append|replace— defaults toreplacefor create,appendfor copy.
Both commands return a new packages field in their success response:
{count, mode, path}. If package-writing fails (e.g. permission
error), the profile is still created/copied and a packages_error
field is included — non-fatal, so the operator can fix the package
file by hand without losing the profile.
A small _extract_opts(args) helper splits the argv into positional +
--key=value opts, so the new flags don't break the existing
positional signatures (<name> <backend> [base] for create,
<src> <new> [backend] for copy).
4. Bridge.js updates
bridge.builder.profileCreate(name, backend, base, packagesText, mode)
and bridge.builder.profileCopy(srcName, newName, backend, packagesText, mode). packagesText is JSON-encoded via JSON.stringify() in the
JS, decoded via json.loads() in the bridge. When packagesText is
omitted/null, the bridge writes no package file — full back-compat
with v0.0.48 callers.
5. 28 new unit tests
A new TestBuilderPackagesField class in tests/test_bridge_parsers.py
covers:
_parse_packages_text(6 tests): empty input, full-line comments, inline comments, dedup, whitespace, blank lines._extract_opts(4 tests): positional-only, key=value, boolean flag, mixed._write_packages_mkosi(3 tests): replace, append+dedup, append-to-empty-section._write_packages_vmdb2(2 tests): replace, append+dedup._write_packages_archiso(2 tests): replace, append+preserve- baseline._write_packages_live_build(3 tests): basic write, replace clears oldsysdeck*.listbut preservesbaseline.list, append overwritessysdeck.listonly._write_packagesdispatcher (3 tests): invalid mode, unknown backend, archiso file path rejected.profile_createend-to-end (2 tests): arg parsing + JSON decode, invalid JSON rejected.profile_copyend-to-end (3 tests): archiso append, live-build replace, back-compat without--packages.
All 28 tests use tempfile.mkdtemp() and unittest.mock.patch.object();
none touch real /etc/ or /usr/share/ paths. The tests run in 13
milliseconds.
6. What this release is NOT
- No new polkit file. Package-writing uses the existing
org.sysdeck.builder.modifyaction — same asprofile-create,profile-copy,profile-delete, andbuild. - No new bridge helper file. All the new code lives inside
bridge/builder.py. Bridge helper count stays at 22. - No new plugin. The packages field is part of the existing
sysdeck-builderpanel. Plugin count stays at 26. - No new bridge subcommand.
profile-createandprofile-copyare existing commands; they just accept new optional flags. The bridge.js subcommand cross-check stays at 102 calls. - No pyyaml dependency. The vmdb2 writer uses regex-based surgery on the include list, not a YAML parser. This keeps the bridge dependency-free.
- No changes to profile discovery or build invocation. Profiles
with written package lists build the same way as before — the
operator runs
▶ Buildon the new entry in the profiles table.
7. Step-down logic
Three options were on the table for where to put the package-writing logic:
-
Embed in
profile-create/profile-copy. Chosen. Atomic (if packages fail to write, the whole create/copy fails — though we made it non-fatal so the profile survives), one JS call instead of two, matches the user's framing ("for the profile being generated"). -
Separate
profile-set-packagescommand. Rejected as the primary path (would require JS to chain two calls; if the second fails, the profile exists but has no packages). However, this would be a natural follow-up if operators want to edit packages on an existing profile without re-creating it — the_write_packageshelper is already factored out and could be called from a new command with ~10 lines of glue. -
File upload only (no textarea). Rejected. The textarea lets the operator quickly add 2-3 packages without creating a file, and it's where file uploads land anyway (so the operator can review before submitting). Two input modes, one source of truth.
Option 1 won because it matches the user's framing and keeps the
creation flow atomic. The _write_packages helper is factored out
so option 2 can be added later without rewriting the writers.
v0.0.48 — 2026-08-19 (builder profile-create bugfix: dropdown gating + new copy-shipped-profile path)
Theme: stop funneling archiso/live-build operators into a dead-end error
v0.0.48 is a targeted bugfix release for the Image Builder panel. An
operator on an archiso-only Arch host (or a live-build-only Debian
host — i.e. a box with archiso/live-build installed but no
mkosi/vmdb2) reported hitting this error when trying to create a
build profile:
Error: profile-create supports ('mkosi', 'vmdb2'); archiso profiles
are not scaffolded (use the shipped ones)
The bridge-side rejection is correct by design — archiso and
live-build use shipped directory-based profile trees (with
profiledef.sh + airootfs/ for archiso, config/ for live-build),
not single-file specs that can be scaffolded from scratch. The bug was
on the panel side: the v0.0.31 Create Profile dropdown's fallback path
offered primary.id when no mkosi/vmdb2 backend was installed.
On an archiso-only host, primary.id is archiso — the operator
picked it, clicked Create, and ran straight into the brick wall.
Worse, the error message told them what to do — "use the shipped ones" — but the panel gave them no affordance to actually do it. There was no Copy button, no shipped-profile picker, no link to the docs. Just a dead end.
1. Fix the dropdown gating
renderCreateProfile in plugins/sysdeck-builder/builder.js no
longer falls back to primary.id. When no mkosi/vmdb2 backend is
installed, the form renders an inline install hint card with the exact
pacman -S --needed mkosi / apt install -y vmdb2 command — no
dropdown at all. When scaffoldable backends ARE installed, the
dropdown only offers those (no archiso/live-build entries to
mislead).
This is the kind of bug that's easy to introduce and embarrassingly
easy to miss in review: the existing
backends.filter(b => b.id === 'mkosi' || b.id === 'vmdb2') line is
correct — the bug was the || fallback that activated when the filter
returned empty. The lesson is: when a filter empties out, don't
silently widen the scope — show a message explaining why the operation
isn't available and what to do about it.
2. Add a "Copy shipped profile" form
The new renderCopyProfile(profiles, primary) form is the panel-side
answer to the bridge's "use the shipped ones" hint. It lists every
shipped archiso and live-build profile discovered via profiles()
— typically baseline and releng for archiso — grouped by backend
in <optgroup> elements. A free-text input takes the new profile
name. A "⎘ Copy" button triggers the new
bridge.builder.profileCopy(srcName, newName, backend) method.
When no shipped archiso/live-build profiles are found (e.g. the
backend isn't installed), the form renders an inline install hint
with the exact pacman -S --needed archiso / apt install -y live-build command.
The form lives directly below the Create Profile form, so the operator sees both paths side-by-side: scaffold a new single-file spec (mkosi/vmdb2) OR copy a shipped directory tree (archiso/live- build). The right tool for each backend.
3. New bridge command: profile-copy
bridge/builder.py gains a profile_copy() function, registered in
the COMMANDS dict as profile-copy. Usage:
python3 /usr/lib/sysdeck/bridge/builder.py profile-copy <src-name> <new-name> [backend]
It copies /usr/share/archiso/configs/<src-name>/ →
/etc/archiso/configs/<new-name>/ (and the live-build equivalent at
/etc/live-build/<new-name>/). Validation:
- new-name safety: rejects
/,., and..to prevent path traversal via crafted names like../../etc/passwd. The polkit actionorg.sysdeck.builder.modifyalready gates access, but defense-in-depth says don't trust the caller with path construction. - source resolution: reuses the existing
profiles()discovery — no new path-walking code. Ifbackendis given, the source must match it; we don't silently cross-copy. - backend-kind guard: refuses mkosi/vmdb2 sources with a clear
"use profile-create to scaffold a new one"hint. The two operations are distinct:profile-createwrites a single file from a template;profile-copycopies a directory tree. Keeping them separate avoids a confusing "create-or-copy?" branch inside one command. - destination-exists guard: refuses if the destination already
exists, with a hint pointing at
profile-deleteas the cleanup path. Matchesprofile-create's "already exists" behavior.
Returns structured {copied, backend, source, source_path, name, path} on success; {copied: False, error, hint} on failure (same
shape as profile_create). Uses the same polkit action as
profile-create — no new polkit file needed.
4. Destination-roots refactor (testability)
The destination paths were hardcoded Path("/etc/archiso/configs")
and Path("/etc/live-build") literals inside profile_copy. Pulled
them out to module-level constants ARCHISO_COPY_DEST and
LIVE_BUILD_COPY_DEST. This mirrors the existing ARCHISO_DIRS /
LIVE_BUILD_DIRS pattern and lets unit tests patch them with
tempdirs instead of touching real /etc/ paths. Small change, big
testability win.
5. 15 new unit tests
A new TestBuilderProfileCopy class in
tests/test_bridge_parsers.py covers:
- Argument validation (5 tests): no args, one arg, slash in
name,
.name,..name. - Source resolution (4 tests): not-found (returns clear error +
hint pointing at the
profilessubcommand), wrong-backend hint filter (passesbackend=archisobut only a live-build profile matches → not-found, no silent cross-copy), mkosi source rejected with "use profile-create" hint, vmdb2 source rejected with same. - Success paths (3 tests): archiso copy (builds a fake
profiledef.sh+airootfs/etc/hostnametree in a tempdir, copies via patchedARCHISO_COPY_DEST, verifies the tree was actually copied byte-for-byte), live-build copy (same pattern withconfig/shared), backend-hint-inferred-when-omitted (third arg optional — first matching profile used regardless of backend). - Failure modes (3 tests): dest-already-exists (pre-create the
dest, expect "already exists" + "use profile-delete" hint),
source-path-not-a-directory (canned entry with a file path instead
of dir → "not a directory"), permission-error (mock
shutil.copytreeto raisePermissionError("denied")→ expect error + polkit hint mentioningorg.sysdeck.builder.modify).
All 15 tests use tempfile.mkdtemp() and
unittest.mock.patch.object(). None touch real /etc/ or
/usr/share/ paths. The tests run in 8 milliseconds.
6. Version sync
Bumped 0.0.47 → 0.0.48 across all 9 release surfaces (Makefile
VERSION + header comment, bridge/__init__.py __version__,
packaging/setup.py VERSION, PKGBUILD pkgver, RPM spec Version
%changelogentry,debian/changelogentry,compat/compat-manifest.jsonversion+_comment+ a new_comment_v0.0.48field on the builder module entry,packaging/sysdeck.metainfo.xml<release>,README.mdVersion line + highlights section).
The existing test_version_sync_all_surfaces_report_047 test was
renamed to ..._048 and the version string updated to "0.0.48".
The test enumerates 8 files and asserts each contains "0.0.48",
catching any surface that misses the bump.
What this release is NOT
- No new polkit file.
profile-copyuses the existingorg.sysdeck.builder.modifyaction — same asprofile-create,profile-delete, andbuild. No new polkit XML, no newpackaging/polkit/*.policyfile, no Makefile install-stanza changes. - No new bridge helper file.
profile_copylives insidebridge/builder.pyalongsideprofile_createandprofile_delete. The bridge helper count stays at 22. - No new plugin. The Copy shipped profile form is part of the
existing
sysdeck-builderpanel. Plugin count stays at 26. - No changes to profile discovery.
_archiso_profiles()and_live_build_profiles()are unchanged.profile_copyreusesprofiles()as-is. - No changes to build invocation.
build()and_backend_build_command()are unchanged. Copied profiles build the same way as shipped ones — the operator runs▶ Buildon the new/etc/entry in the profiles table.
Step-down logic
Three options were on the table for fixing this bug:
-
Make
profile-createaccept archiso/live-build by auto-copying a shipped baseline internally. Rejected: conflates two distinct operations (scaffold-from-template vs. copy-existing-tree), forces a "which baseline?" choice inside the scaffold command, and the operator loses visibility into what got copied. -
Stop offering archiso/live-build in Create, add a separate
profile-copycommand + UI form. Chosen: keeps each operation single-purpose, the UI shows both paths side-by-side, the bridge surface stays self-documenting (profile-createscaffolds,profile-copycopies — the names say what they do). -
Just fix the dropdown filter, no new command. Rejected: fixes the immediate crash but leaves the operator of an archiso-only host with no way to create a profile at all. They'd have to drop to a shell and
cp -r /usr/share/archiso/configs/ baseline /etc/archiso/configs/myarch— defeating the point of the panel.
Option 2 won because it composes best with the rest of the system:
the new profile-copy command is a natural sibling to
profile-create and profile-delete, the UI form is a natural
sibling to the existing Create Profile form, and the operator gets
both paths presented side-by-side under the same panel.
v0.0.47 — 2026-08-18 (logic-flaw fixes: firewall ports, services module, glances default-on webui)
Theme: fix the port topology, lift the service editor out, default-on the glances webui
v0.0.47 is a tight, directive-driven release that closes four logic flaws the operator flagged after running v0.0.46 in production. Per user directive:
we need to fix a few logic flaws i do things a certain way on my servers so ill correct the ports on a firewall script or two. the web server template, and vps template i setup the webserver on 8080 and varnish on 80 for an automatic cache environment. we should move the service/ports editor to its own module entry for ease of access. the glances we should default to enabling the built in webui and embedding that into our module instead it visually looks stunning in comparison to ours.
Four concrete fixes shipped:
1. Firewall: public-webserver.sh port-topology fix
The v0.0.44 public-webserver.sh template had the cache topology
backwards. The header comment described the correct topology
(Varnish on :80, Caddy HTTP backend on :8080, Caddy HTTPS on
:443) but the actual variable defaults and ruleset did the
opposite — CADDY_HTTP_PORT=80 (Caddy exposed on :80) and
VARNISH_PORT=8080 (Varnish exposed on :8080). Worse, the
template shipped a VARNISH_PUBLIC=true default that exposed
:8080 to the internet — meaning clients could bypass Varnish
entirely and hit Caddy's cache-miss backend path directly.
v0.0.47 flips the topology to match the operator's documented cache-environment setup:
| Port | Service | Visibility |
|---|---|---|
:80 |
Varnish cache front (HTTP, redirect to HTTPS) | public |
:443 |
Caddy HTTPS (terminates TLS) | public |
:8080 |
Caddy HTTP backend (Varnish cache-miss target) | loopback only |
:3306 |
MariaDB | loopback only |
:2019 |
Caddy admin API | loopback only |
The VARNISH_PUBLIC toggle is removed entirely — :8080 is now
ALWAYS loopback-only because exposing it defeats the entire point
of running a cache. Defense-in-depth drops were added for :8080
alongside the existing MariaDB + Caddy admin drops, so even a
misconfigured 0.0.0.0:8080 Caddy bind gets dropped at the
firewall with a caddy-http-backend-blocked log prefix.
The detect output now reflects the corrected cache-front-of-origin
topology — Client → :80 (Varnish) → :8080 (Caddy backend, loopback) instead of the misleading Client → :80 (Caddy).
2. Firewall: vps-webserver.sh default topology
The vps-webserver.sh template had a subtler version of the same
flaw. Its detect_varnish() function correctly flipped Caddy HTTP
to :8080 if Varnish was already listening on :80 — but that
required the operator to have manually moved Varnish to :80
first. If Varnish was installed but stopped, or still on the
upstream default :6081, the template would expose Caddy on :80
and leave Varnish unreachable.
v0.0.47 makes the cache-front-of-origin topology the explicit default the moment Varnish is detected at all:
- If Varnish is detected on
:80(the operator already moved it), the behavior is unchanged. - If Varnish is detected on any other port (
:6081upstream default, or a custom port), the template now forcesVARNISH_PORT=80with a log message explaining the override:Varnish detected on :6081 — overriding to :80 (cache-front-of-origin default per v0.0.47). Set VARNISH_PORT env var to keep :6081. - Caddy HTTP is unconditionally moved to
:8080loopback when Varnish is detected, regardless of what port Varnish was on.
This matches the public-webserver.sh behavior and the operator's documented cache-environment setup.
3. NEW PLUGIN: sysdeck-services (sidebar order 45)
The Service/Port Editor card that lived at the bottom of the Firewall panel since v0.0.44 has been lifted out into its own first-class sidebar entry — Service / Ports at order 45 — for ease of access. The operator can now manage service ports without scrolling past the firewall ruleset table.
The new panel (plugins/sysdeck-services/services.js) adds:
- A filter box — search by name, id, port, or process name.
- A show-only-editable toggle — hides services with no detected config file (e.g. services that are registered but not installed on this host).
- A Refresh button — re-mounts the panel and re-runs the listening-socket enumeration.
- The full unmapped-listeners card — ports that didn't match any
SERVICES_REGISTRYentry, so the operator can spot services the editor doesn't yet know about. - An operation output card — shows the result of the last Save / Restart action (success message or stderr).
The bridge surface is unchanged. bridge.firewall.services /
service-info / set-service-port / restart-service remain
the source of truth — the SERVICES_REGISTRY, atomic-write
logic, and CONFIG_BASE_DIRS allowlist stay in
bridge/firewall.py. A new bridge.services proxy (4 methods:
list / info / setPort / restart) was added to
shared/bridge.js so the new panel has a clean API surface
without polluting the firewall namespace.
The firewall panel keeps a renderServicesLinkCard() signpost
pointing operators to the new sidebar entry — preserves the
workflow for operators who used to scroll to the bottom of the
Firewall panel for port edits. The service / port / editor
keywords were removed from the firewall manifest (they belong
to the new services plugin now).
4. Glances: default-on embedded webui
The v0.0.34 Glances integration added the webui but kept it
opt-in — the operator had to click "Start Web UI" every time
they opened the panel, and the legacy SysDeck snapshot cards
were rendered above the iframe. The iframe was also sized to a
fixed height:600px, which left most of the viewport empty on
larger screens.
v0.0.47 flips the default:
- AUTO-START on mount. When the panel loads and glances is
installed but the webserver isn't running, the panel calls
bridge.glances.startWeb()automatically. The operator sees a "Starting Glances web UI …" stub for <1s, then the full Glances web UI loads in the iframe. No click required. - EMBEDDED-FIRST LAYOUT. The iframe is now the primary view,
sized to fill the viewport (
min-height: calc(100vh - 200px)). The legacy snapshot cards (CPU / Memory / Swap / Network / Disk / Processes) are moved into a collapsed<details>at the bottom of the page — they're still there for a quick numeric read, but they don't push the iframe below the fold. - CSP fix. The manifest CSP was updated to
default-src 'self' 'unsafe-inline' 'unsafe-eval'; frame-src 'self' http://127.0.0.1:61208 http://localhost:61208so the embedded Glances web UI loads without a CSP violation. The previous CSP (default-src 'self' 'unsafe-inline' 'unsafe-eval'with noframe-src) would have blocked the iframe once the browser enforced the default-src fallback for frame-src. - Stop on unmount is intentionally NOT done. Keeping the webserver running speeds re-entry — the operator can flip between sidebar entries without paying the ~1s startup cost each time. The Stop button is retained for explicit shutdown.
Version sync catch-up
While doing the version bump, I noticed the v0.0.46 release had
bumped PKGBUILD / spec / debian changelog to 0.0.46 but missed
bridge/__init__.py and packaging/setup.py — both stayed at
0.0.45. v0.0.47 catches these up to 0.0.47 alongside every other
release surface (Makefile VERSION + comment, metainfo,
compat-manifest, README). All 9 release surfaces now report
v0.0.47.
The metainfo <launchable> list also got two missing entries:
sysdeck-modules (added in v0.0.46 but never added to the
launchable list — the AppStream apps page would have shown the
component but not linked it to the plugin) and sysdeck-services
(new in v0.0.47). The list is now 26 entries, matching the 26
plugin directories.
Compatibility
No bridge subcommands were removed. The
bridge.firewall.services / serviceInfo / setServicePort /
restartService methods on the JS bridge surface are kept for
back-compat with any operator-side scripts that may have called
them directly — they proxy to the same firewall subcommands as
the new bridge.services.* methods. The SERVICES_REGISTRY
shape, the atomic-write logic, the CONFIG_BASE_DIRS allowlist,
and the org.sysdeck.firewall.modify polkit action are all
unchanged.
The firewall templates keep their standard
start/stop/restart/detect/status/check interface — no bridge
changes needed to call them. The v0.0.47 port-topology fix is
invisible to the bridge; it just changes which ports the ruleset
opens and which it drops.
v0.0.46 — 2026-08-18 (in-suite 3rd-party module installer)
Theme: kill cockpit-module-pull.sh, ship a real installer panel
The previous flow for installing third-party Cockpit modules (45Drives
Navigator / File Sharing / ZFS Manager, cockpit-pacman, cockpit-
identities, cockpit-sensors, cockpit-benchmark) was a side-channel
shell script — cockpit-module-pull.sh — that surfaced a single
blanket license banner at the top and only logged per-module licenses
after the pull succeeded. Per user directive:
i wanted the in ui module to handle showing license, developer, 3rd party model name and ability to visit homepage and install the plugin 1 click with license agreement inline.
v0.0.46 replaces that flow with a first-class sidebar entry — 3rd-Party Modules at order 44 — backed by a Python bridge helper. Every catalog row renders the module name, license badge, developer/author, source URL, and a clickable homepage link INLINE, right next to a 1-click Install button. No modal. No separate confirmation step. The license is visible in the row; clicking Install IS the acceptance.
Why this is better than the shell script
The shell script's log_mod ran after cp -r / tar -xf / git clone succeeded. Bytes had already hit the network and disk before
the operator was told the module's license and source URL. There was
also no per-module opt-out — once the top-level banner was accepted,
every entry pulled unconditionally.
The new panel surfaces the license, developer, source, and homepage in the row, BEFORE the operator clicks anything. Each Install is a single click; each Uninstall is a single click. The bridge's audit log captures every action with a JSON record.
Bridge: catalog-driven, no per-module code branches
bridge/modules3p.py defines a single CATALOG list of dicts. Each
entry has id, name, blurb, license, author, source,
kind, install_spec, and optional depends / homepage. Four
install kinds are supported:
pacman— native package install (pacman -S --noconfirm --needed)git— depth-1 clone into/usr/share/cockpit/<dest>/deb-tar— download a .deb,bsdtar -xfit, thentar -xf data.tar.xzinto the destinationtarball—curl+tar -x --strip-components=N
Adding a new module to the catalog is a single dict append. No code branches per module, no per-module JS, no per-module polkit actions.
Bridge refuses silent installs
The bridge's install() subcommand checks for --accept-license=1
and returns license-not-accepted otherwise. The JS always passes
that flag because the license is rendered inline next to the Install
button — the click IS the acceptance gesture. The check remains as a
guard against malicious callers (e.g. a different front-end that
tries to bulk-install without operator interaction).
Audit log: shared path, JSON records
Every install / uninstall appends a JSON record to
/etc/cockpit/MODULE_LICENSES.log — the same path the legacy
cockpit-module-pull.sh wrote to in plain text. Legacy plain-text
lines are preserved as {raw: ...} records, so a single audit view
shows both old and new entries. Each new record includes: timestamp
(UTC), module id, name, license, author, source URL, action
(install-ok / install-failed / uninstall-ok / uninstall-failed),
detail string, and bridge version.
Polkit action
New polkit policy packaging/polkit/org.sysdeck.modules3p.policy
defines the action org.sysdeck.modules3p.modify. It authorizes
/usr/bin/python3 /usr/lib/sysdeck/bridge/modules3p.py install| uninstall <id> with auth_admin_keep for active sessions — so the
operator authenticates once and can install/uninstall multiple modules
within the keep window without re-authenticating.
Bridge.js surface
shared/bridge.js gains a bridge.modules3p object with catalog,
status, preflight, install, uninstall, and audit methods.
install and uninstall use { superuser: 'try' } so cockpit
prompts via polkit; the rest are read-only and run as the cockpit
user.
Catalog (v0.0.46, 10 entries)
| Module | License | Author | Source | Kind |
|---|---|---|---|---|
| cockpit-machines | LGPL-2.1 | Cockpit Project | github.com/cockpit-project/cockpit-machines | pacman |
| cockpit-podman | LGPL-2.1 | Cockpit Project | github.com/cockpit-project/cockpit-podman | pacman |
| cockpit-storaged | LGPL-2.1 | Cockpit Project | github.com/cockpit-project/cockpit-storaged | pacman |
| cockpit-identities | LGPL-2.1 | Cockpit Project | github.com/cockpit-project/cockpit-identities | git |
| cockpit-navigator | GPL-3.0 | 45Drives | github.com/45Drives/cockpit-navigator | deb-tar |
| cockpit-file-sharing | GPL-3.0 | 45Drives | github.com/45Drives/cockpit-file-sharing | deb-tar |
| cockpit-zfs-manager | GPL-3.0 | 45Drives | github.com/45Drives/cockpit-zfs-manager | git |
| cockpit-pacman | GPL-3.0 | pfeifferj | github.com/pfeifferj/cockpit-pacman | git |
| cockpit-sensors | MIT | ocristopfer | github.com/ocristopfer/cockpit-sensors | tarball |
| cockpit-benchmark | MIT | ealier | github.com/ealier/cockpit-benchmark | git |
Smoke test
A Python smoke-test script (scripts/test_modules3p.py) exercises the
bridge: catalog shape, status shape, preflight for every entry,
install refuses without --accept-license=1, unknown-id preflight
returns ok=false, audit returns records list. 42 checks, all pass
on a host without pacman and without systemctl (the bridge
degrades gracefully when those binaries are missing).
Files added / changed
- NEW
plugins/sysdeck-modules/{manifest.json, index.html, modules.js} - NEW
bridge/modules3p.py - NEW
packaging/polkit/org.sysdeck.modules3p.policy - EDITED
shared/bridge.js— addedbridge.modules3psurface - EDITED
Makefile— bumped VERSION 0.0.45 → 0.0.46, plugin count 24 → 25, added polkit install + uninstall stanzas - EDITED
THIRD_PARTY.md— appended v0.0.46 section, updated two existing "see cockpit-module-pull.sh" notes - EDITED
packaging/debian/changelog,packaging/sysdeck.spec,packaging/sysdeck.metainfo.xml— v0.0.46 entries
What's next
- Extend the catalog: candidates include cockpit-netplan, cockpit-certificates, and a 45Drives identities-style RH-SSO entry.
- Optional
EXCLUDE_MODULESenv var so operators can hide specific entries from the panel (e.g. hide cockpit-sensors because the suite already ships its own sensors panel). - Optional "verify checksum" field on catalog entries for operators who want to pin to a known-good release artifact.
v0.0.35 — 2026-08-18 (Kata split + Jellyfin, Photos, Remote FS modules)
Theme: restore Kata as its own sidebar entry, add three new modules
v0.0.34 consolidated Kata Containers into the merged Containers & VMs panel, demoting it to a hidden "tools" entry labeled "Kata Containers (hidden helper)" with priority -1. The user directive was clear: "kata containers should be called SysDeck Kata and moved out of the tools area. and dont call it hidden thats akward." v0.0.35 splits Kata back out into a standalone sidebar entry called SysDeck Kata at order 27. The plugin directory moved from plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/, the manifest was converted from a tools section to a menu section, and the awkward "hidden helper" wording was removed everywhere.
The second theme is three new modules added per directive: Jellyfin media server, photo manager, and remote filesystem manager. Each follows the established pattern: a Python bridge helper under bridge/ runs systemctl start/stop/restart via the cockpit superuser channel (polkit prompts the operator), and the JS panel iframes the running service's built-in admin web UI — same shape as the v0.0.34 Glances integration.
Kata restored as standalone sidebar entry
The pre-built cockpit-kata React bundle (index.js + index.css, ~470KB + ~60KB) ships unchanged. The Kata plugin now hosts that bundle directly as its page content — no iframe needed, since the plugin IS the page. The Containers panel was simplified to Podman-only; the merged "Containers & VMs" subtitle was dropped, the Kata tab and its iframe target were removed, and the containers keywords list dropped kata/sandbox (those now live under sysdeck-kata's own keywords list). scripts/generate-plugins.py was extended with a HAND_MAINTAINED_PLUGINS = {"sysdeck-kata"} set — the generator backs up + restores hand-maintained plugins before wiping plugins/, so running make plugins no longer destroys the pre-built React bundle. The Makefile was updated to comment "23 standalone Cockpit plugins" and the install target prints "23 standalone Cockpit plugins" instead of the old "19 visible + 1 hidden helper = 20" wording.
Jellyfin module
Jellyfin ships a single systemd unit (jellyfin.service) on every distro that packages it, and serves a full admin web UI on http://127.0.0.1:8096. The new bridge helper bridge/jellyfin.py exposes seven subcommands: summary (service status + version + port + url in one call), status (service status only), start / stop / restart (systemctl control), web-status (running + url for iframe embedding), and libraries (best-effort GET /Library/VirtualFolders on the local Jellyfin instance). The panel renders: a service summary card (status badge, version, port, uptime, URL with a clickable link), three action buttons (Start / Stop / Restart — each triggers a polkit prompt for org.sysdeck.jellyfin.modify), the running admin UI in an 800px-tall iframe below the service card, and a libraries table. The Content-Security-Policy in the manifest allows frame-src http://127.0.0.1:8096 and connect-src http://127.0.0.1:* so the iframe can load Jellyfin's built-in CSS / JS / image assets. When Jellyfin is not installed, the panel renders an install hint with the per-distro install command and the Jellyfin homepage link.
Photo Manager module
Multi-backend design (same shape as the DB Control module in v0.0.32). Five backends are registered in bridge/photos.py:BACKEND_REGISTRY:
| id | name | family | port | systemd unit | license |
|---|---|---|---|---|---|
| photoprism | PhotoPrism | go-binary | 2342 | photoprism.service | MIT |
| piwigo | Piwigo | php-app | 80 | php-fpm.service | GPL-2.0 |
| lychee | Lychee | php-app | 80 | php-fpm.service | MIT |
| nextcloud-memories | Nextcloud Memories | nextcloud-plugin | 80 | php-fpm.service | AGPL-3.0 |
| librephotos | LibrePhotos | django-react | 3000 | librephotos.service | MIT |
Each backend is auto-detected via shutil.which() (CLI availability), systemctl list-unit-files (unit presence), and os.path.exists() (config dir presence). The bridge runs systemctl start/stop/restart <service> for the chosen backend; the panel renders a per-backend card with status badge, service name, port, URL, license, and — when the service is running — an 800px-tall iframe loading the backend's admin UI. Each card has its own Start / Stop / Restart / Refresh buttons. An install-hint card renders when no backend is detected, listing all five options with their per-distro install commands and license links. The polkit action org.sysdeck.photos.modify authorizes /usr/bin/systemctl and /usr/bin/photoprism.
Remote FS Manager module
Multi-backend design (same shape as the Photos module). Five backends in bridge/remotefs.py:BACKEND_REGISTRY:
| id | name | family | port | systemd unit | cli | license |
|---|---|---|---|---|---|---|
| ceph | Ceph | object-storage | 6789 | ceph.target | ceph | LGPL-2.1 |
| glusterfs | GlusterFS | scale-out-fs | 24007 | glusterd.service | gluster | GPL-2.0 |
| moosefs | MooseFS | distributed-fs | 9420 | moosefs-master.service | moosefs-cli | GPL-2.0 |
| beegfs | BeeGFS | parallel-fs | 8008 | beegfs-meta.service | beegfs-ctl | BeeGFS EULA (free) |
| orangefs | OrangeFS | parallel-fs | 3334 | pvfs2-server.service | pvfs2-server | BSD-3 |
Each backend is auto-detected; the panel renders a card per detected backend with status, service name, CLI tool, port, config path, license, and Start / Stop / Restart / Cluster Info buttons. The cluster-info subcommand dispatches per backend: ceph status --format=json (returns a parsed summary with health, fsid, mon count, osd count, pg count, plus the raw text), gluster pool list, moosefs-cli info, beegfs-ctl --listnodes, pvfs2-server -m. Output renders in a <pre> block at the bottom of the panel.
The polkit action org.sysdeck.remotefs.modify authorizes /usr/bin/systemctl plus /usr/bin/ceph, /usr/bin/gluster, /usr/bin/moosefs-cli, /usr/bin/beegfs-ctl, /usr/bin/pvfs2-server (and their /usr/sbin/ variants, plus /opt/beegfs/sbin/beegfs-ctl).
NFS and Amanda explicitly excluded
Per user directive: "and others but not nfs or amanada fs." The exclusion is documented in three places: the EXCLUDED dict in bridge/remotefs.py ({"nfs": "kernel-builtin; no cluster; no remote-FS-as-data-store semantics. Use cockpit-nfs.", "amanda": "backup system, not a remote/distributed filesystem. Use a dedicated backup solution."}), the _comment_v0.0.35 field in compat/compat-manifest.json, and a dedicated "Intentionally excluded" card in the panel UI itself — the bridge returns the excluded dict in its summary response and the panel renders a table showing each excluded backend and the reason. Amanda is included here even though the directive spelled it "amanada" — the operator's intent is clear: AMANDA (Advanced Maryland Automatic Network Disk Archiver) is a backup system, not a remote FS, and doesn't belong in this module.
Plugin count and version sync
The plugin count went from 20 (19 visible + 1 hidden helper in v0.0.34) to 23 (23 visible, 0 hidden). The hidden helper was promoted to a visible sidebar entry, and three new visible modules were added. tests/check_manifest_consistency.py was updated to expect 23 plugins (still a warn-only check). The Makefile install target now says "23 standalone Cockpit plugins". All release surfaces (Makefile, bridge/__init__.py, packaging/setup.py, packaging/PKGBUILD, packaging/sysdeck.spec, packaging/debian/changelog, compat/compat-manifest.json) were bumped to 0.0.35 — make check-version-sync passes.
Bridge surface expanded
shared/bridge.js got three new module surfaces (bridge.jellyfin, bridge.photos, bridge.remotefs) totaling 19 new bridgeCmd() calls. The build-time guard check-bridge-subcommands.py now verifies 140 calls across 25 bridge modules (was 116 calls across 22 modules in v0.0.34). All calls resolve to a real Python dispatch entry — verified end-to-end.
v0.0.33 — 2026-08-18 (Policy module LSM expansion + MoE QA pass + docs rewrite)
Theme: complete the modern LSM stack, then run a senior-team QA pass
v0.0.32 introduced the Policy & Permissions module with five concerns (ACLs, cgroups v2, VLANs, eBPF, namespaces) plus an optional AppArmor card. v0.0.33 expands the module to cover the rest of the modern Linux LSM stack per user directive: "lets now add smack, tomoyo, yama and others as well to the same policy module. we again dont need selinux its native for cockpit." The bridge now surfaces nine LSMs — AppArmor, Smack, TOMOYO, Yama, LoadPin, Lockdown, BPF-LSM, Landlock, and the capability LSM (file capabilities via setcap/getcap). Each is optional and auto-detected; the panel renders an enable hint when an LSM is absent from the kernel stack.
The second theme is a MoE (Mixture-of-Experts) QA pass that applied a senior QA analyst, senior Linux engineer, senior architect, senior admin, and project-manager-in-devops lens to the codebase. The pass replaced nested ifs with lookup tables (LSM_PROBES, NON_LSM_CONCERNS, SMACK_FILE_MAP, TOMOYO_FILES, YAMA_SCOPE_NAMES), shifted for/while loops toward map/filter/reduce where the data shape allowed it, and kept PEP 868, POSIX, SEI CERT, and MISRA in mind. The QA pass also rewrote project documentation (README, QUICKSTART, BLOG, LICENSE) with decisive language — every design choice is recorded as a decision, not as history.
What's new in the Policy module
lsm-status— reads/sys/kernel/security/lsm(the comma-separated active-stack file the kernel exposes since 5.1) and cross-references each entry against the per-LSM probe. The panel renders a badge row in the header showing the active stack at a glance, and a table showing every supported LSM with itsactive_in_stack/dir_present/pathfields.- Smack —
/sys/kernel/security/smack/directory probe + reads the 12 control files (logging,load,load2,revoke-subject,change-rule,onlycap,cipso2,access2,access,mapped,network-queue-length,ptrace) via theSMACK_FILE_MAPlookup table. Thesmack-labelssubcommand walks/proc/<pid>/attr/currentfor every running PID and returns the deduplicated label set. Thesmack-loadsubcommand writes a rules file to/sys/kernel/security/smack/load. - TOMOYO —
/sys/kernel/security/tomoyo/directory probe + reads the 9 control files (profile,exception_policy,domain_policy,manager,query,grant_log,reject_log,status,version) via theTOMOYO_FILESlookup table. Thetomoyo-profilessubcommand returns theprofilefile's contents;tomoyo-save-policysnapshots the four policy files to an operator-chosen path. - Yama —
/proc/sys/kernel/yama/ptrace_scoperead (the only knob Yama has). TheYAMA_SCOPE_NAMESlookup table maps 0-3 to human-readable names (disabled/restricted (default)/admin-only/no-ptrace). Theyama-set-scopesubcommand writes a new value; the JS panel renders a<select>with the four options. - LoadPin —
/sys/kernel/security/loadpin/directory probe + readsenforceandexcludefiles when present. LoadPin has no userspace management surface beyond the kernel cmdline — the panel documents this. - Lockdown —
/sys/kernel/security/lockdown/directory probe + iterates every file in the directory. Lockdown is enabled by UEFI secure boot; the panel documents that there is no userspace toggle. - BPF-LSM —
/sys/kernel/security/bpf/directory probe + usesbpftool prog show -jto enumerateBPF_PROG_TYPE_LSMprograms. The same programs appear under the eBPF card, but here they're filtered to the LSM hook type. - Landlock —
/sys/kernel/security/landlock/directory probe + walks/proc/<pid>/statuslooking for theLandlock:line on each process. The panel renders a table of sandboxed processes with their ruleset identifier. - File capabilities —
getcap -r /enumeration (capped at 200 entries to avoid huge responses),setcap '<caps>' <path>to grant capabilities,setcap -r <path>to remove. These predate the LSM stack but compose with it for fine-grained privilege delegation.
Each new LSM follows the same shape: if unavailable, the panel renders an enable hint with the kernel cmdline that activates the LSM; if available, the panel renders the live state plus any management controls the LSM supports. Decisive language — no "this is pending" or "to be implemented".
Polkit policy expansion
The org.sysdeck.policy.modify action authorizes 30+ binaries across the policy module's surface:
- ACLs:
setfacl,getfacl - cgroups:
mkdir,mount(for bpffs pinning) - VLANs:
ip,vconfig - eBPF:
bpftool - namespaces:
lsns - AppArmor:
aa-status,aa-enforce,aa-complain - v0.0.33 new:
smackload,smackcipsos,smackcipso,tomoyo-setprofile,tomoyo-set-profile,tomoyo-savepolicy,tomoyo-init,setcap,getcap
SELinux remains intentionally skipped per user directive: it is native to the host distro and SysDeck does not try to manage it.
MoE QA pass — what changed
The QA pass applied five expert lenses:
-
Senior QA analyst — verified that every bridge subcommand named in
shared/bridge.jsexists in the matchingbridge/<module>.pyCOMMANDSdict. Thecheck_bridge_subcommands.pybuild-time guard now verifies 100+ calls across 22 bridge modules. Smoke-tested every new LSM subcommand against the sandbox (where most LSMs are absent — the panel renders install hints, exactly as designed). -
Senior Linux engineer — confirmed the bridge invokes the kernel's standard interfaces, not custom ones:
/sys/kernel/security/lsmfor the stack list,/sys/kernel/security/<lsm>/for each LSM's directory,/proc/sys/kernel/yama/ptrace_scopefor Yama,/proc/<pid>/attr/currentfor Smack labels,/proc/<pid>/statusfor Landlock rulesets. The bridge usessubprocess.runwithcapture_output=Trueandcheck=Falseeverywhere — never raises, always surfaces stderr in the JSON response. -
Senior architect — replaced the v0.0.32 summary command's hand-built per-concern dict with two lookup tables:
LSM_PROBES(9 entries: id, pretty_name, sub_dir) andNON_LSM_CONCERNS(5 entries: id, pretty_name, probe_fn). Adding a new LSM or non-LSM concern is now a one-line table addition, not a new code path. The Smack file-probe loop was refactored to use theSMACK_FILE_MAPtable; the TOMOYO file-probe loop was refactored to use theTOMOYO_FILEStuple. -
Senior admin — verified the cockpit-way pattern is consistent across the entire module: every mutating operation (
acl-set,cgroup-create,cgroup-move,cgroup-set,vlan-create,vlan-delete,ebpf-pin,apparmor-enforce,apparmor-complain,smack-load,yama-set-scope,tomoyo-save-policy,filecaps-set,filecaps-remove) runs throughbridgeCmd("policy", [...], { superuser: "try" }). The cockpit bridge prompts the operator via polkit. Nosudoshell-out from JS anywhere. -
Project-manager-in-devops — confirmed the polkit action authorizes every binary the bridge invokes, so operators do not hit "permission denied" dead-ends during incident response. Production-readiness checklist: every failure mode returns a structured JSON response with
available: false, reason: ..., install: ...rather than crashing.
Documentation rewrite
- README.md — rewritten with the v0.0.33 highlights, the full 22-row module catalog, the architecture file-tree, the bridge-layer table, the cross-cutting contracts, the coding standards (PEP 868 / POSIX / SEI CERT / MISRA + step-down logic + lookup tables), and the install paths. Every design choice is recorded as a decision.
- QUICKSTART.md — rewritten with the 5-minute install path, the 20-sidebar-entry verification table, the first-use walkthrough for the Firewall / Packages / Policy / Databases panels, the build-from-source instructions, and the uninstall path.
- LICENSE — kept MIT (the right license for a Cockpit plugin that integrates with both GPL-licensed cockpit and permissive-licensed third-party helpers). Added a third-party attributions block listing the independently-licensed programs the bridge invokes as separate subprocesses (nft, pacman, mkosi, bpftool, setcap, aa-, smackload, tomoyo-, systemctl, getfacl, ip, lsns, mount, mkdir).
- BLOG.md — this entry. Every prior release narrative is preserved as-is; the v0.0.32 narrative is rewritten to use decisive decision language (the v0.0.15-era
sudo systemctlshell-out fix, the DB module panel addition, the polkit actions, etc. are all stated as decisions, not as "restored" or "brought back").
Decision: step-down logic for the SELinux question
When the user directive said "we again dont need selinux its native for cockpit," the team faced a fork of choices:
- Implement SELinux management anyway — surface
semanage,setsebool,audit2allow,restoreconas bridge subcommands. - Skip SELinux entirely — declare it out-of-scope and document the decision.
- Detect SELinux state only — read
/sys/fs/selinux/enforceand report it without management controls.
Applying step-down logic: option 1 violates the user directive and duplicates what setroubleshoot and cockpit-selinux already do. Option 3 is half-measure — reading enforcement state without management controls gives the operator a useless read-only view. Option 2 wins: the summary subcommand returns {selinux: {skipped: true, reason: "SELinux is native to the host distro — not managed by SysDeck."}}, the panel's capability matrix shows a "skipped" badge for SELinux, and the decision is documented in bridge/policy.py and plugins/sysdeck-policy/policy.js. Decisive, composes with the rest of the system, documented.
Guards
make check passes all 7 build-time guards: manifest consistency (20 plugin manifests + 1 shared manifest = 21 total), metainfo consistency, Makefile recipe indentation (tabs not spaces), no broken import cockpit from pattern, no broken python3 -m sysdeck.bridge pattern, bridge.js subcommand cross-check (100+ calls verified across 22 bridge modules — up from 84 in v0.0.32 thanks to the 16 new policy subcommands), version sync (all release surfaces report v0.0.33). All 9 unit tests pass. JS files pass node --check. Shell scripts pass bash -n. Manifest JSON files all parse.
make dist builds sysdeck-0.0.33.tar.bz2 (~220KB). make distcheck confirms the tarball is self-sufficient — it extracts into sysdeck-0.0.33/ and make check passes inside the extracted tree.
v0.0.32 — 2026-08-18 (Policy & Permissions + DB Control — plugin count 18 → 20)
Theme: cover the modern Linux policy stack and the database layer in one release
v0.0.32 ships two new modules. The first is Policy & Permissions (cockpit-policy) — a modern policy management and permissions manager for groups. Per user directive: "modern policy management and permissions manager for groups. such as acl, cgroups, vlans, ebpf namespace separation and related policies. we can skip selinux its native. we can implement apparmor but its not default on my machine so make it optional for sure." The module surfaces five concerns: POSIX ACLs (getfacl/setfacl), cgroups v2 unified hierarchy (mkdir / move PID / write control files), VLANs (ip link add/del type vlan), eBPF programs and maps (bpftool, plus pin-to-bpffs), and namespaces (lsns). AppArmor is optional — the bridge auto-detects whether it is compiled into the kernel; if absent, the panel renders an install hint instead of an empty table. SELinux is intentionally skipped (native to the host distro).
The second is DB Control (cockpit-db). The bridge helper bridge/db.py surfaces 32+ engines across SQL/NoSQL/Vector/TimeSeries/Graph/Embedded/Cloud/AI families with summary/status/start/stop/restart/connections/query subcommands. 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 the v0.0.31 packages-module pattern) is to run systemctl directly via subprocess and let the JS panel pass { superuser: 'try' } so the cockpit bridge prompts the operator via polkit for the new org.sysdeck.db.modify action.
Two new polkit actions: org.sysdeck.policy.modify (authorizes setfacl, getfacl, mkdir, mount, ip, bpftool, lsns, aa-enforce, aa-complain, aa-status) and org.sysdeck.db.modify (authorizes /usr/bin/systemctl).
make check passes: 84 bridge.js calls cross-verified against Python COMMANDS dicts across 22 bridge modules (up from 59 in v0.0.31 thanks to the 25 new policy methods + 7 new db methods). make distcheck confirms the tarball is self-sufficient.
v0.0.31 — 2026-08-18 (Firewall manager + Packages sudo fix + Builder full-featured + Fester rename)
Theme: turn the monitor-only modules into full managers, the cockpit way
v0.0.31 fixes four operator-reported issues:
-
Firewall — monitor → manager. v0.0.30's panel could only list nftables rules. v0.0.31 makes it a full manager: template selector, Apply/Stop/Restart, ban/unban IP, clear bans, live service detection. Two templates ship under
/usr/share/sysdeck/firewall/templates/(vps-webserver.sh,no-services.sh); operators can drop more in. The bridge gainstemplates,template-info,detect,apply,stop,restart,status,ban,unban,banned,clear-bans,checksubcommands. -
Packages — sudo → cockpit way. v0.0.30's
Update Allbutton calledbridge.packages.updateAll()which returned only the command string. The panel showedalert("Run this command with superuser privileges.")and the operator had to copy / sudo / paste / run. v0.0.31 makes install/remove/update/update-all actually execute via subprocess; the JS panel passes{ superuser: 'try' }to cockpit.spawn so the operator authenticates via polkit (org.sysdeck.packages.modify). Live stdout/stderr renders in an in-panel<pre>log. -
Builder — viewer → full-featured. v0.0.30's builder could list installed backends and walk their config dirs. v0.0.31 adds
build(profile, backend, options)— runs the backend in the profile's directory via subprocess under theorg.sysdeck.builder.modifypolkit action; streams stdout+stderr to/var/lib/sysdeck/builder/logs/<build-id>.log; tracks state in/var/lib/sysdeck/builder/state/<build-id>.json. Also:profile-create,profile-delete,artifacts,build-status,build-log. The panel renders a Build button per profile, a Builds table, a per-build log viewer, and a Create Profile form. -
Fester rename. The directory was already
plugins/sysdeck-fester/but the menu label and panel title said "SysDeck Build Orchestration". v0.0.31 changes both to "SysDeck Fester" per user directive. Updatedmanifest.json,fester.js,scripts/generate-plugins.pyMODULES table,README.md,compat-manifest.json,BLOG.mdreferences.
All mutating operations use the cockpit superuser channel — no sudo shell-out from JS anywhere in the suite. make check passes: 59 bridge.js calls cross-verified across 21 bridge modules. make distcheck confirms the tarball is self-sufficient.
v0.0.30 — 2026-08-17 (Image Builder rewritten — Arch + Debian backends)
Theme: stop targeting Fedora-only tooling
v0.0.29's builder was a thin systemctl is-active osbuild-composer.service shim. osbuild-composer is Fedora/RHEL-only and is not packaged for Arch or Debian, so the Builder panel was permanently 'inactive' on every distro this suite ships to. v0.0.30 replaces osbuild-composer with a multi-backend detection layer: mkosi (systemd's image builder, Arch primary) + archiso (Arch Live ISO builder) on Arch; vmdb2 (Debian project's image builder) + live-build (Debian Live ISO builder) on Debian. New bridge subcommands: status, profiles, summary, backends, install-hint. Rewrote plugins/sysdeck-builder/builder.js to render per-backend profile cards + a distro-specific install hint. Updated polkit/org.sysdeck.policy org.sysdeck.builder.modify to authorize /usr/bin/mkosi, /usr/bin/mkarchiso, /usr/bin/vmdb2, /usr/bin/lb (osbuild + livemedia-creator annotations removed). Updated compat-manifest.json builder entry: Arch and Debian distro_support upgraded from 'none' to 'full'.
v0.0.19 — 2026-08-17 (manifest rewritten to match a real working plugin — first release that should actually appear in the Cockpit sidebar)
Theme: stop guessing, copy a working plugin's manifest
v0.0.9 through v0.0.18 all shipped with a manifest schema that Cockpit silently rejected on every install. The user reported: "nothing has worked yet. we have zero entries anywhere. like zero existence to the visible eye." That is the accurate summary of every release before this one. The previous three "fixes" (v0.0.16's path-alignment, v0.0.17's AppStream registration, v0.0.18's path-to-file resolution) were each solving a problem one layer deeper than the actual problem. The actual problem was that the manifest schema itself was wrong, and Cockpit was rejecting the whole plugin at the discovery layer.
This release fixes that by copying the manifest pattern from cockpit-podman, a real, working Cockpit plugin that ships in this very tarball under standalone-plugins/.
What every previous release got wrong
Compare the v0.0.18 sysdeck manifest to cockpit-podman's actual working manifest:
v0.0.18 sysdeck (broken — silently rejected by Cockpit):
{
"version": 1,
"name": "sysdeck",
"title": "SysDeck",
"priority": 0,
"requires": { "cockpit": ">=239" },
"content": {
"suite": { "label": "Suite Dashboard", "path": "/index.html" }
},
"menu": {
"suite": { "label": "SysDeck", "order": 20, "path": "/index.html" }
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}
cockpit-podman (working — appears in Cockpit sidebar):
{
"version": 0,
"name": "cockpit-podman",
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/podman" }],
"menu": {
"index": { "label": "Podman Containers", "order": 46, ... }
},
"content-security-policy": "default-src 'self'; ..."
}
The differences are not stylistic — every one of them is a contract violation Cockpit silently enforces:
| Field | v0.0.18 sysdeck | cockpit-podman (works) | Effect of the deviation |
|---|---|---|---|
version |
1 |
0 |
Schema version 1 is only supported by recent Cockpit; older versions silently reject the plugin. Version 0 is universally supported. |
top-level title |
"SysDeck" |
(absent) | Not a recognized field; Cockpit's strict manifest parser may reject the whole manifest. |
top-level priority |
0 |
(absent) | Same as above. |
content section |
present, keyed suite |
(absent) | Real plugins don't declare content — the menu.index magic key serves index.html implicitly. Having a content section with a non-index key (like suite) requires Cockpit to resolve suite to a file, which historically fails silently. |
menu.<key> |
menu.suite (with path) |
menu.index (no path) |
The index magic key is special: Cockpit serves /index.html implicitly when the menu key is index. Any other key (like suite) requires Cockpit to resolve the key to a content entry, which requires a content section, which... see above. This is the trap every previous release fell into. |
path inside menu entry |
present ("/index.html") |
absent | Not a recognized menu field. |
Why this bug shipped for ten releases
The previous author wrote a manifest-consistency test that codified the author's own mental model of the manifest contract: "every menu.<item>.path must match some content.<page>.path". That contract was invented — it does not appear in Cockpit's documentation or in any real plugin's manifest. The test passed on every release because every release satisfied the invented contract, while the actual Cockpit manifest parser was silently rejecting the plugin because none of them matched the real contract.
This is the same failure mode as v0.0.14 (tarball-wrapping-directory) and v0.0.16 (path mismatch): a build-time guard that codifies a wrong invariant is worse than no guard, because it gives false confidence. The test passed; the plugin didn't appear in the sidebar; the user saw "zero entries anywhere"; the release shipped anyway.
The fix
Rewrote manifest.json to match the cockpit-podman reference manifest as closely as possible:
{
"version": 0,
"name": "sysdeck",
"requires": { "cockpit": ">=239" },
"menu": {
"index": {
"label": "SysDeck",
"order": 20,
"keywords": [{ "matches": ["sysdeck", "operations", "monitoring", "firewall", "containers", "firmware", "fleet"] }],
"docs": [{ "label": "SysDeck Quickstart", "url": "https://dcos.net/sysdeck/quickstart" }]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}
Changes from v0.0.18:
version: 1→version: 0(universally supported schema version)- Removed
title(not in any working plugin's manifest) - Removed
priority(not in any working plugin's manifest) - Removed
contentsection entirely (cockpit-podman doesn't have one) menu.suite→menu.index(the magic key Cockpit uses to serveindex.htmlimplicitly)- Removed
pathfrom menu entry (not a real menu field — theindexmagic key handles path resolution)
Hardened guard: test against the actual reference, not a made-up contract
tests/check_manifest_consistency.py has been rewritten. It no longer validates against an invented "menu path must match content path" contract. It now validates against the actual cockpit-podman reference manifest shipped in this tarball:
- Rejects
version: 1— onlyversion: 0matches the working pattern. - Rejects any top-level field not present in cockpit-podman (
title,priority, etc.). - Rejects a
contentsection — real working plugins don't declare one. - Rejects any menu key other than
index— non-indexkeys have historically caused silent drops. - Rejects a
pathfield inside a menu entry — not a real menu field.
Regression-tested: deliberately reverting sysdeck's manifest to the v0.0.18 state fails the new check with a clear message naming every deviation:
FAIL: manifest.json does not match the working Cockpit plugin pattern:
Reference (standalone-plugins/cockpit-podman/manifest.json):
{
"version": 0,
"name": "cockpit-podman",
"requires": { "cockpit": ">=239" },
"menu": {
"index": { "label": "Podman Containers", "order": 46 }
}
}
- menu section has keys ['suite'] but is missing the magic 'index' key. ...
- manifest has a 'content' section (['suite']) — real working Cockpit plugins do NOT declare 'content'. ...
- version=1 — should be 0. ...
- manifest has a top-level 'title' field ('SysDeck') — real working Cockpit plugins do not declare 'title' at the top level. ...
- manifest has a top-level 'priority' field (0) — real working Cockpit plugins do not declare 'priority'. ...
- manifest has top-level keys not present in the cockpit-podman reference: ['content', 'priority', 'title']. ...
New: sysdeck-diagnose.sh — see exactly what Cockpit sees
A diagnostic script is now installed at /usr/share/sysdeck/sysdeck-diagnose.sh. Run it after sudo make install && sudo systemctl restart cockpit.socket to see, in one command's output:
- Cockpit service status and version
- What's installed under
/usr/share/cockpit/ - The contents of
/usr/share/cockpit/sysdeck/manifest.json - File permissions on the manifest
- Whether the
cockpituser can read the manifest - AppStream metainfo validation (
appstreamcli validate) - AppStream cache search for
sysdeck - Cockpit journal lines mentioning
sysdeck,error,warning,fail,cannot, ordenied - Cockpit config (
/etc/cockpit/cockpit.conf) - The reference manifests from
standalone-plugins/for side-by-side comparison
If SysDeck still doesn't appear in the sidebar after this release, run this script and paste the output — it will pinpoint which layer is broken.
New: cockpit-smoke-test.sh — verify Cockpit discovery independently of sysdeck
A second script is installed at /usr/share/sysdeck/cockpit-smoke-test.sh. It installs a 5-line hello-world Cockpit plugin to /usr/share/cockpit/hellotest/:
sudo bash /usr/share/sysdeck/cockpit-smoke-test.sh install
The hello-world plugin uses the same manifest pattern as cockpit-podman (version=0, menu.index, no content section). After running the script:
- If "Hello Test" appears in the Cockpit sidebar → Cockpit discovery works → if SysDeck still doesn't appear, the issue is sysdeck's manifest (run
sysdeck-diagnose.sh). - If "Hello Test" does NOT appear → the issue is Cockpit itself, not sysdeck. Check Cockpit version,
/etc/cockpit/cockpit.conf, SELinux/AppArmor, andjournalctl -u cockpit.
Remove the hello-world plugin when done:
sudo bash /usr/share/sysdeck/cockpit-smoke-test.sh remove
This script decouples "is Cockpit working?" from "is sysdeck's manifest correct?" — so we can finally tell which side of the install is broken without guessing.
Install and verify
# 1. Nuke any previous install (including pacman-tracked ones)
sudo pacman -R sysdeck 2>/dev/null || true
sudo rm -rf /usr/share/cockpit/sysdeck /usr/lib/sysdeck /usr/share/sysdeck /usr/share/metainfo/sysdeck.metainfo.xml /usr/share/polkit-1/actions/org.sysdeck.policy
sudo systemctl restart cockpit.socket
# 2. Install v0.0.19
tar xjf sysdeck-0.0.19.tar.bz2
cd sysdeck-0.0.19
sudo make install
sudo systemctl restart cockpit.socket
sudo appstreamcli refresh-cache --force 2>/dev/null || true
# 3. Hard-refresh the browser (Ctrl+Shift+R) — cockpit-ws caches manifests aggressively
# 4. Open https://localhost:9090 → look for "SysDeck" in the left sidebar
# 5. If it doesn't appear, run:
bash /usr/share/sysdeck/cockpit-smoke-test.sh install
# Refresh the browser. If "Hello Test" doesn't appear either, the issue is Cockpit.
# 6. Either way, run:
sudo bash /usr/share/sysdeck/sysdeck-diagnose.sh
# Paste the output back so we can pinpoint the remaining issue.
Honest summary
- v0.0.9 introduced the broken manifest schema (
version: 1,content.suite,menu.suite, top-leveltitleandpriority). - v0.0.10 through v0.0.15 added features on top of a broken foundation — the plugin never appeared in Cockpit, but the features were validated against the mock cockpit environment in dev.
- v0.0.16 "fixed" a fake path-mismatch bug that was a symptom of the broken schema, not the root cause.
- v0.0.17 added AppStream metainfo and PolKit policy on top of the broken manifest — necessary infrastructure, but didn't address the discovery failure.
- v0.0.18 "fixed" another fake path-resolution bug that was still a symptom of the broken schema.
- v0.0.19 (this release) is the first release whose manifest actually matches the contract Cockpit enforces. The previous nine patch releases were attempts to fix symptoms while leaving the root cause untouched.
If this release still doesn't make SysDeck appear in the sidebar, the diagnostic scripts will tell us which layer is actually broken — and we can stop guessing.
v0.0.18 — 2026-08-17 (manifest path actually resolves to a file — plugin loads)
Theme: fix the silent empty-page bug from v0.0.16's manifest "fix"
v0.0.18 fixes a critical empty-page bug introduced by the v0.0.16 "manifest fix". After v0.0.16, the SysDeck menu entry appeared in the Cockpit sidebar, but clicking it loaded a blank page. The user reported: "we might have added a pacman entry but clicking it was an empty page." This release makes the menu entry actually load the dashboard.
Root cause: the v0.0.16 fix was based on a wrong model of how Cockpit serves plugin files
The v0.0.16 release notes claimed:
"The
content.<page>.pathfield is a URL path, not a filename. Cockpit automatically servesindex.htmlfrom the plugin directory at whatever URL path the content entry declares."
That claim is false. Cockpit does NOT automatically serve index.html at arbitrary URL paths. Cockpit's web service serves files from the plugin directory based on the URL path the browser requests. The URL-to-file mapping is approximately:
| URL requested | File Cockpit tries to serve |
|---|---|
<plugin>/index.html |
<plugin_dir>/index.html |
<plugin>/suite |
<plugin_dir>/suite.html or <plugin_dir>/suite/index.html |
<plugin>/suite/ |
<plugin_dir>/suite/index.html |
So when the v0.0.16 manifest declared content.suite.path = "/suite", Cockpit registered a content page at URL <plugin>/suite. When the user clicked the menu entry, the browser requested <plugin>/suite, and Cockpit tried to serve <plugin_dir>/suite.html — which doesn't exist. No file matched, so Cockpit returned an empty page. Cockpit does not log this as an error; the plugin loads "successfully" but the content URL is a 404.
The v0.0.16 release passed its own manifest-consistency test, because that test only verified that menu.<item>.path matched some content.<page>.path. It did not verify that the path resolved to a real file. Both fields said /suite, so they matched — but neither one resolved to a file, so the menu entry silently 404'd.
The fix
Two-line change to manifest.json:
"content": {
"suite": {
"label": "Suite Dashboard",
- "path": "/suite"
+ "path": "/index.html"
}
},
"menu": {
"suite": {
"label": "SysDeck",
"order": 20,
- "path": "/suite"
+ "path": "/index.html"
}
},
Now both fields point at /index.html, which resolves to the actual index.html file at the plugin root. Clicking the menu entry loads the suite dashboard at URL <plugin>/index.html, and Cockpit serves the file.
This matches the convention used by every real-world Cockpit plugin (cockpit-podman, cockpit-machines, cockpit-ostree — see their manifests under standalone-plugins/). They all use path: "/index.html" (or implicitly use the index menu key, which resolves to /index.html).
New guard: check-manifest-consistency now verifies file resolution
The existing tests/check_manifest_consistency.py has been extended. It now also verifies that every content.<page>.path and menu.<item>.path resolves to an actual file in the plugin directory. The resolver mirrors Cockpit's URL-to-file mapping:
/index.html→./index.html/suite→./suite.htmlor./suite/index.html/suite/→./suite/index.html/foo/bar.html→./foo/bar.html
If a path does not resolve to a file, the test fails with:
FAIL: manifest.json has structural problems:
- content.suite.path='/suite' does not resolve to a file in the plugin
directory (looked for ./suite.html and ./suite/index.html). Cockpit
will return 404 / empty page when this URL is requested.
The guard was validated by deliberately reverting the manifest to the v0.0.16 broken state (path: "/suite") and confirming make check fails with that exact message. The manifest was then restored to /index.html and make check passes.
How to verify the fix after install
sudo make install
sudo systemctl restart cockpit.socket
Then open https://localhost:9090 in a browser. Click SysDeck in the sidebar — the suite dashboard should load (header with brand mark + version badge, left sidebar with module list, content area with the default module panel).
If the menu entry still shows an empty page after this release:
- Confirm the installed manifest has the new path:
grep -A1 '"path"' /usr/share/cockpit/sysdeck/manifest.json # Should show: "path": "/index.html" (twice) - Hard-reload the browser (Ctrl+Shift+R) to bust the cockpit-ws cache.
- Check the cockpit journal for 404s on
/sysdeck/suite:journalctl -u cockpit -n 200 --no-pager | grep -E 'sysdeck|404'
Why this bug shipped
The v0.0.16 release was authored by an agent that reasoned about Cockpit's manifest contract from the test's perspective (menu path must match content path) rather than from Cockpit's actual serving behavior (URL path must resolve to a file). The agent wrote a test that codified its wrong model, the test passed, the release shipped, and the bug was only discovered when a real user clicked the menu and got an empty page.
This is the same lesson as v0.0.14 (tarball-wrapping-directory) and v0.0.16 (manifest path mismatch): a build-time guard that codifies a wrong invariant is worse than no guard, because it gives false confidence. The v0.0.18 guard verifies the actual contract Cockpit enforces at runtime (path → file resolution), not the simplified invariant the previous agent imagined.
v0.0.15 — 2026-08-17
Theme: observability stack + compatibility manifest + standalone plugin sidebar links
v0.0.15 introduces the Prometheus and Grafana modules, a DB Control module, a Prometheus log pipeline in the event bus, a hwalert bridge helper, a compatibility manifest (compat/compat-manifest.json) that provides a complete distro support matrix for all 21 modules and 3 standalone plugins, and standalone plugin sidebar links that register external Cockpit plugins (cockpit-ostree, cockpit-machines, cockpit-podman) in the suite's sidebar when they are installed.
The compatibility manifest enables operators to pre-flight a deployment: for each module, it declares the runtime condition (path-exists check), per-distro dependency package names and install commands, default-path messages for missing deps, minimum Cockpit version, tested Cockpit versions, and a distro support classification (full / partial / none).
The standalone plugin manifests follow the standard Cockpit manifest.json contract with menu entries and conditions — they appear automatically in the sidebar when their backing tools are present, without the suite needing to bundle or fork any of their code.
What changed
Compatibility manifest. compat/compat-manifest.json contains a per-module entry for all 21 modules plus 3 standalone plugins. Each entry includes:
requires— minimum Cockpit versionconditions— path-exists runtime checks (module hidden when deps absent)config— per-distro (Arch, Debian, Fedora) dependency package name and install commandfallback— human-readable message and install docs URL for missing depsmin_cockpit— minimum Cockpit version as integertested_cockpit_versions— list of Cockpit versions tested againstdistro_support— classification: full, partial, or none per distro
Prometheus module (P1). cockpit-prometheus — monitoring, alerting, and centralized log pipeline. All event-bus emissions are forwarded to the Prometheus pushgateway via a fire-and-forget hook in event-bus.ts. The pipeline classifies events by log level (error/warn/info) and pushes structured metadata. Bridge helper: bridge/prometheus.py. License: Apache-2.0.
Grafana module (P1). cockpit-grafana — dashboards, visualization, and alerting UI for the observability stack. Bridge helper: bridge/grafana.py. License: AGPL-3.0 (Grafana Labs).
DB Control module (P1). cockpit-db — unified control for all database engines (SQL/NoSQL/vector/AI). Bridge helper: bridge/db.py.
Prometheus log pipeline. event-bus.ts contains a fire-and-forget pushLogToPrometheus() hook. On every emit(), the event is forwarded to /api/bridge/prometheus with the module source, classified log level, event type, and payload metadata. Pipeline failures are swallowed and never propagate to callers. The pipeline is enabled via PROMETHEUS_LOG_ENABLED = true.
hwalert bridge. bridge/hwalert.py — hardware alert aggregation (bridge helper; panel integration pending).
Standalone plugin sidebar links. Three new manifests under standalone-plugins/:
- cockpit-ostree (order 35) — OSTree/rpm-ostree updates for Fedora Silverblue. Condition:
/usr/bin/rpm-ostreeexists. - cockpit-machines (order 45) — Libvirt/KVM virtual machine management. Condition:
/usr/bin/virshexists. - cockpit-podman (order 46) — Podman container management. Condition:
/usr/bin/podmanexists.
Each standalone manifest includes menu, conditions, keywords, docs, and content-security-policy — the standard Cockpit contract. Deploying them to /usr/share/cockpit/<name>/ makes them appear in the sidebar automatically.
Enhanced root manifest.json. The suite's own manifest.json now includes priority: 0 and uses >=239 requires syntax instead of bare "239".
Design: benchmark.js. src/modules/benchmark.js line 89 previously called bare spawn() (undefined reference) in the per-test Run button handler. Integrated with bridge.benchmark.runTest(test).
Version bump. All version references updated to 0.0.13: bridge/__init__.py, packaging/setup.py, Makefile, manifest.json, nextjs-dashboard/package.json, index.html.
Distro support summary
| Module | Arch | Debian | Fedora | Condition |
|---|---|---|---|---|
| Containers | ✅ | ✅ | ✅ | /usr/bin/podman |
| Firewall | ✅ | ✅ | ✅ | /usr/sbin/nft |
| Integrity | ✅ | ✅ | ✅ | /usr/sbin/lynis |
| Netsec | ✅ | ✅ | ✅ | /usr/sbin/ss |
| Mesh | ✅ | ✅ | ✅ | /usr/bin/kubectl |
| Vault | ✅ | ✅ | ✅ | /usr/sbin/cryptsetup |
| Fleet | ✅ | ✅ | ✅ | (always) |
| Kata | ⚠️ | ⚠️ | ⚠️ | /usr/bin/kata-runtime |
| Fester | ✅ | ✅ | ✅ | (always) |
| Firmware | ✅ | ✅ | ✅ | /usr/bin/fwupdmgr |
| Builder | ✅ | ✅ | ✅ | /usr/bin/mkosi (Arch) · /usr/bin/vmdb2 (Debian) |
| Mining | ✅ | ⚠️ | ⚠️ | /usr/bin/xmrig |
| Themes | ✅ | ✅ | ✅ | (always) |
| Auth | ✅ | ✅ | ✅ | (always) |
| Glances | ✅ | ✅ | ✅ | /usr/bin/glances |
| Sensors | ✅ | ✅ | ✅ | /usr/bin/sensors |
| Benchmark | ✅ | ✅ | ✅ | /usr/bin/sysbench |
| Packages | ✅ | ✅ | ✅ | pacman/dnf/apt |
| DB Control | ✅ | ✅ | ✅ | (always) |
| Prometheus | ✅ | ✅ | ✅ | /usr/bin/prometheus |
| Grafana | ✅ | ✅ | ✅ | /usr/sbin/grafana-server |
v0.0.15 — 2026-08-17 (production-readiness re-release)
Theme: restore dropped code, ship the real source tree
v0.0.15 is a corrective release. The v0.0.13 and v0.0.14 tarballs shipped with three bridge modules and two source directories silently dropped from the tree. The v0.0.14 tarball was 80 KB; the correct source tree is 280 KB. This release restores the dropped code and adds Makefile infrastructure to install it.
What was dropped
Three bridge helpers and two source directories were present in the v0.0.13-full development snapshot but absent from the v0.0.13 release tarball and the v0.0.14 corrective release:
bridge/grafana.py(413 lines) — Grafana HTTP API helper. Manages dashboards, datasources, alerting, plugins, org, users. Subcommands:summary,dashboards,datasources,alerts,health,org,users,plugins,search,restart,reload.bridge/hwalert.py(628 lines) — Hardware alert aggregation. Detects foreign/unauthorized USB, DMA-capable Thunderbolt/FireWire, rogue Bluetooth, new PCI devices, RFID/NFC skimmers, firmware tampering. Subcommands:summary,devices,alerts,acknowledge,dismiss,block,unblock,whitelist,unwhitelist,policy.bridge/prometheus.py(448 lines) — Prometheus HTTP API helper. Manages monitoring, alerting, and the centralized log pipeline. All SysDeck module logs can be pushed to the Prometheus pushgateway. Subcommands:summary,targets,alerts,rules,config,push-log,log-summary,restart,reload.nextjs-dashboard/— the standalone Next.js variant dashboard (1.4 MB on disk, ~200 KB compressed). Operators who want a non-cockpit web UI can run this independently.prometheus/— Prometheus + Grafana YAML config files (alerts, scrape targets, Grafana dashboards, Grafana datasources). 4 files, ~16 KB.
Why it was dropped
The v0.0.13 release tarball was generated from a working copy that had been pruned of "dev-only" directories under the mistaken assumption that nextjs-dashboard/ and prometheus/ were drift artifacts. They were not — they are legitimate parts of the source tree. The three bridge modules were dropped at the same time, likely as a side effect of regenerating the tarball from a stale working copy.
The v0.0.14 corrective release inherited the same dropped code because it was built from the v0.0.13 release tarball as its base, not from the v0.0.13-full development snapshot.
What is restored
bridge/grafana.py,bridge/hwalert.py,bridge/prometheus.py— all three bridge helpers, 1489 lines total, byte-identical to the v0.0.13-full versions.nextjs-dashboard/— the full Next.js variant dashboard source.prometheus/— all four YAML config files (alerts, scrape, dashboards, datasources).THIRD_PARTY.md— restored the Prometheus, Grafana, DB Engines, and hwalert attribution sections.README.md,BLOG.md,QA.md,QUICKSTART.md,docs/INSTALL.md— restored the 21-module narrative (was incorrectly describing 18 modules).
Makefile changes
disttarget now includesprometheus/andnextjs-dashboard/in the tarball. New exclusions:nextjs-dashboard/node_modules,nextjs-dashboard/.next,nextjs-dashboard/.git.installtarget now installs:- Prometheus + Grafana configs to
/etc/sysdeck/prometheus/ - Next.js dashboard source to
/usr/share/sysdeck/nextjs-dashboard/
- Prometheus + Grafana configs to
uninstalltarget now removes/etc/sysdeck/and/usr/share/sysdeck/.- The
check-version-syncguard continues to enforce version consistency acrossbridge/__init__.py,packaging/setup.py,packaging/PKGBUILD,index.html, andcompat/compat-manifest.json.
Tarball size
The v0.0.15 tarball is ~280 KB, matching the v0.0.13-full development snapshot. The v0.0.14 tarball was 80 KB — definitively wrong, as the user observed. The 200 KB delta is the three restored bridge modules, the nextjs-dashboard/ source, and the prometheus/ configs.
Lesson
The v0.0.14 "Hard-won packaging rules" section in README included a rule: "Dev artifacts never ship in the production tarball." That rule was wrong as written. The correct rule is: the source tarball ships the full source tree, including optional variants and config files; the install target decides what gets installed system-wide. The nextjs-dashboard/ and prometheus/ directories are source, not dev artifacts. The rule has been corrected in this release.
v0.0.17 — 2026-08-17 (first-class Cockpit application registration)
Theme: stop fighting Cockpit's app model — register properly
v0.0.17 restructures how SysDeck identifies itself to Cockpit. The previous releases treated the install as "drop files in /usr/share/cockpit/sysdeck/ and hope Cockpit picks them up." That approach made the plugin visible (after the v0.0.16 manifest fix) but did not make it a first-class Cockpit application — the kind that appears in the Applications install menu, gets proper auth context from cockpit-ws, and can request privileged operations through PolKit instead of failing with permission errors.
This release ships the two files that change the registration model: an AppStream metainfo file and a PolKit policy file. Together they make SysDeck a real Cockpit application rather than a directory of static assets.
What "first-class Cockpit application" means
Cockpit's Applications menu (the metapackage-style install page that lists 389 Directory Server, Docker, Files, Machines, Networking, Podman, SELinux, Storage, etc.) is not just a list of installed directories. It is the AppStream registry — the same mechanism that GNOME Software and KDE Discover use to show installable applications. Cockpit reads AppStream metadata from /usr/share/metainfo/*.metainfo.xml and looks for entries that declare <provides><cockpit-manifest>NAME</cockpit-manifest></provides>. Each such entry is a registered Cockpit application.
Without an AppStream metainfo file:
- SysDeck does not appear in the Applications install menu.
- Cockpit-ws does not grant the plugin proper auth context.
- PolKit does not know which actions the plugin's bridge helpers may request.
- Privileged bridge operations (systemctl, nft, package managers, fwupdmgr, tpm2, cryptsetup) fail with permission errors because there is no policy authorizing them.
The v0.0.16 manifest fix made the plugin visible in the sidebar. v0.0.17 makes it first-class in the application registry.
AppStream metainfo
New file: packaging/sysdeck.metainfo.xml, installed to /usr/share/metainfo/sysdeck.metainfo.xml. The key element is:
<component type="addon">
<id>sysdeck</id>
<name>SysDeck</name>
<summary>Unified operations surface for Linux infrastructure</summary>
<provides>
<cockpit-manifest>sysdeck</cockpit-manifest>
</provides>
<!-- ... -->
</component>
The <cockpit-manifest>sysdeck</cockpit-manifest> element tells Cockpit that this AppStream component provides a cockpit plugin named sysdeck. When Cockpit's Applications page enumerates installed applications, it sees this entry and lists SysDeck alongside cockpit-podman, cockpit-machines, etc.
The <id> must match the directory name (/usr/share/cockpit/sysdeck/) and the name field in manifest.json. The new check-metainfo-consistency guard enforces this invariant at build time.
PolKit policy
New file: packaging/polkit/org.sysdeck.policy, installed to /usr/share/polkit-1/actions/org.sysdeck.policy. Defines six privilege domains, one per coarse-grained class of privileged operation the bridge helpers invoke:
| Action ID | Covers | Bridge modules |
|---|---|---|
org.sysdeck.system.manage |
systemctl, hostnamectl, timedatectl, localectl, loginctl, machinectl | auth, db, hwalert |
org.sysdeck.firewall.modify |
nft, iptables, ip6tables | firewall, netsec |
org.sysdeck.packages.modify |
pacman, apt, dnf, yum | packages |
org.sysdeck.firmware.modify |
fwupdmgr, tpm2 | firmware |
org.sysdeck.vault.modify |
cryptsetup | vault (pending) |
org.sysdeck.builder.modify |
mkosi, mkarchiso, vmdb2, lb (live-build) | builder |
Each action uses auth_admin_keep for active sessions, which means the user authenticates once and the auth is kept for the session — the same pattern cockpit-podman and cockpit-machines use. Read-only operations (nft list, pacman -Q, fwupdmgr get-devices) do not need elevation; only mutations go through polkit.
New guard: check-metainfo-consistency
The v0.0.14 release added check-makefile-recipes and check-version-sync. The v0.0.16 release added check-manifest-consistency. This release adds check-metainfo-consistency, the fourth build-time guard. The new target runs tests/check_metainfo_consistency.py, which validates:
- The metainfo file is well-formed XML.
- The root element is
<component>. - Required fields are present and non-empty:
<id>,<name>,<summary>. - The
<provides>element contains a non-empty<cockpit-manifest>. - The
<cockpit-manifest>text matches the<id>text.
The guard was validated by deliberately removing the <cockpit-manifest> element and confirming make check fails with: FAIL: missing or empty <cockpit-manifest> in <provides>.
How the auth model works after this release
- User logs into Cockpit with their system credentials. Cockpit-ws authenticates the user and starts a cockpit-bridge process running as that user.
- User navigates to SysDeck in the sidebar. The suite dashboard loads at
/suite. Read-only operations (list containers, list firewall rules, show sensor readings) run directly as the user — no elevation needed. - User triggers a privileged operation (e.g., "Add firewall rule", "Install package", "Flash firmware"). The bridge helper invokes the privileged binary via
pkexec, which routes through polkit. - Polkit checks the policy file at
/usr/share/polkit-1/actions/org.sysdeck.policy. The matching action (e.g.,org.sysdeck.firewall.modify) authorizesauth_admin_keepfor active sessions. - Cockpit's polkit agent prompts the user for authentication. The user enters their password (or uses a smartcard / biometric if configured).
- The privileged operation runs. Subsequent operations in the same session do not re-prompt, because
auth_admin_keepcaches the auth.
Without the PolKit policy file, step 4 fails — polkit has no rule that authorizes the bridge helper to invoke nft or pacman, so it denies the request. The bridge helper receives a permission-denied error and the operation fails. This is the "auth reasons" the user observed.
Install and verify
tar xjf sysdeck-0.0.17.tar.bz2
cd sysdeck-0.0.17
sudo make install
sudo systemctl restart cockpit.socket
Then open https://localhost:9090:
- Sidebar — SysDeck appears under "System" (unchanged from v0.0.16).
- Applications menu — SysDeck appears as an installed application, alongside 389 Directory Server, Docker, Machines, Podman, etc.
- Privileged operations — when you click "Add firewall rule" or "Install package", Cockpit's polkit agent prompts for authentication. After auth, the operation succeeds.
To verify the registration from the command line:
# Metainfo installed?
ls -l /usr/share/metainfo/sysdeck.metainfo.xml
# PolKit policy installed?
ls -l /usr/share/polkit-1/actions/org.sysdeck.policy
# AppStream sees the registration?
appstreamcli search sysdeck
# Polkit knows the actions?
pkaction --action-id org.sysdeck.firewall.modify
v0.0.16 — 2026-08-17 (manifest fix — plugin now appears in Cockpit menu)
Theme: fix the manifest path mismatch that made SysDeck invisible after install
v0.0.16 fixes a critical visibility bug. After installing SysDeck and restarting cockpit.socket, the plugin did not appear in the Cockpit sidebar menu. The Applications, System, and Tools menus all listed the standard Cockpit plugins (389 Directory Server, Docker, Files, Machines, Networking, Podman, SELinux, Storage, etc.) but SysDeck was absent. The plugin was installed correctly on disk — manifest.json and index.html were present at /usr/share/cockpit/sysdeck/ — but Cockpit silently dropped it from the menu.
Root cause: manifest content/menu path mismatch
The manifest.json had two path fields that were supposed to refer to the same URL but did not:
"content": {
"suite": {
"label": "Suite Dashboard",
"path": "/index.html" ← wrong: this is a filename, not a URL path
}
},
"menu": {
"suite": {
"label": "SysDeck",
"order": 20,
"path": "/suite" ← correct format, but no content entry serves this URL
}
}
Cockpit's manifest contract requires every menu.<item>.path to match some content.<page>.path. The menu entry pointed at /suite, but the only content path was /index.html. Cockpit could not resolve the menu entry to a servable page, so it silently omitted SysDeck from the sidebar. No error was logged; the plugin simply did not appear.
The content.<page>.path field is a URL path, not a filename. Cockpit automatically serves index.html from the plugin directory at whatever URL path the content entry declares. The correct value is /suite (the URL), not /index.html (the file).
The fix
One-line change to manifest.json:
"content": {
"suite": {
"label": "Suite Dashboard",
- "path": "/index.html"
+ "path": "/suite"
}
},
Now content.suite.path and menu.suite.path both equal /suite. Cockpit serves index.html at the /suite URL, and the menu entry resolves correctly. After sudo systemctl restart cockpit.socket, SysDeck appears in the sidebar under the "System" section at order 20.
New guard: check-manifest-consistency
The v0.0.14 release added check-makefile-recipes and check-version-sync as make check prerequisites. This release adds a third guard: check-manifest-consistency. The new target runs tests/check_manifest_consistency.py, which validates:
manifest.jsonis valid JSON (already checked, but now with a clearer error message).- Required fields are present:
name,title,content(non-empty),menu(non-empty). - Every
menu.<item>.pathmatches somecontent.<page>.path.
If any check fails, make check exits non-zero with a message that names the exact problem. For the original bug, the failure output would have been:
FAIL: manifest.json has structural problems:
- menu.suite.path=/suite does not match any content path (['/index.html'])
The guard was validated by deliberately reverting the manifest to the broken state and confirming make check fails with that exact message.
Why this bug shipped
The manifest was authored in v0.0.9 and never tested against a real Cockpit install. The make check target validated that manifest.json was syntactically valid JSON, but did not validate the semantic contract between content and menu. Cockpit's behavior on a mismatched path is to silently drop the plugin — no error, no log entry — so the bug was invisible until someone actually installed the plugin and looked at the menu.
This is the same class of bug as the v0.0.14 tarball-wrapping-directory bug: a structural contract that the build system did not enforce, and that the runtime failed loudly enough to be invisible. The lesson is the same: every cross-field invariant in a packaging artifact needs a build-time guard, because silent runtime failures are worse than loud build failures.
How to verify the fix after install
sudo make install
sudo systemctl restart cockpit.socket
Then open https://localhost:9090 in a browser. SysDeck should appear in the sidebar under the "System" section, between the standard Cockpit entries. Clicking it loads the suite dashboard at the /suite URL.
If the plugin still does not appear, check:
ls /usr/share/cockpit/sysdeck/manifest.json— confirms the manifest is installed.python3 -c "import json; m=json.load(open('/usr/share/cockpit/sysdeck/manifest.json')); print(m['content']); print(m['menu'])"— confirms the paths match.journalctl -u cockpit -n 50— Cockpit sometimes logs manifest parse errors here.
v0.0.12 — 2026-08-17
Theme: package management + auth identities
v0.0.12 adds a packages module that wraps the system package manager (pacman on Arch Linux, dnf on Fedora/RHEL, apt on Debian/Ubuntu) into a unified cockpit panel, and extends the auth module with first-class identity objects that enumerate PKCS#11 tokens, SSH keys, and Kerberos principals. The suite now has 21 registered modules.
Both additions follow the established subprocess model — the package manager and identity tools are invoked as separate processes via cockpit.spawn, preserving license independence between the MIT suite and the GPL-2.0+ / LGPL-2.1 / BSD / MIT tools it calls.
A license audit accompanies the release. THIRD_PARTY.md documents every new external tool's copyright holder, license, and invocation model. The setup.py now includes extras_require for optional dependencies (glances, sysbench) and a license audit logging pattern.
What changed
Packages module (P1). src/modules/packages.js renders a package management dashboard: installed count, pending updates, searchable package list, and update-all action. The bridge helper bridge/packages.py auto-detects the system package manager (pacman, dnf, or apt) at import time and dispatches to the appropriate backend. Install, remove, and update operations return the command that would be run, requiring superuser elevation via spawnPrivileged. Icon: 📦.
Auth identities extension. The bridge/auth.py bridge helper is extended with three new subcommands: identities, ssh-keys, and kerberos. The identities subcommand aggregates all identity objects — PKCS#11 tokens, SSH keys (from ssh-add -L and ~/.ssh/), and Kerberos principals (from klist) — into a single JSON document with typed identity objects. The auth panel description now includes "identities". The standalone cockpit-identities plugin (LGPL-2.1, cockpit-project) is referenced but not bundled.
Bridge helper. bridge/packages.py is a new Python bridge helper that:
- Auto-detects the system package manager (pacman / dnf / apt) at import time.
- Dispatches to the appropriate backend for
list-installed,list-updates,search,info,install,remove,update,update-all, andsummarysubcommands. - Uses a step-down detection order: pacman (Arch) → dnf (Fedora) → apt (Debian).
- Returns structured JSON for every operation; install/remove/update return the command to be run (requires elevation).
License audit. Every new external tool invocation is documented in THIRD_PARTY.md with: tool name, copyright holder, SPDX license identifier, upstream URL, and invocation model. New entries:
- pacman (GPL-2.0+, Pacman Development Team)
- dnf (GPL-2.0+, RPM project)
- apt (GPL-2.0+, Debian project)
- cockpit-identities (LGPL-2.1, cockpit-project) — referenced, not bundled
- OpenSSH / ssh-add (BSD-2-Clause, OpenBSD project)
- MIT Kerberos / klist (MIT, MIT Kerberos Consortium)
Mock data. src/mock-cockpit.js extended with canned responses for packages (pacman summary, installed list, search results, update commands) and identities (PKCS#11 token, 3 SSH keys, 1 Kerberos principal). Panels render in any browser without the tools installed.
Module registry. src/modules/registry.js now has 21 entries. Sidebar groups: P0 (3), P1 (10), P2 (8). The packages module is P1. The auth module description updated to include "identities".
Setup.py. extras_require added for optional dependencies (pip install sysdeck[glances] installs glances, pip install sysdeck[benchmark] installs sysbench). Description updated to "eighteen domain modules".
Design: MODULE_LOADERS gap. The MODULE_LOADERS gap in suite.js is resolved: glances, sensors, and benchmark now have proper loader entries (these were registered in the registry but missing from the dynamic import map in v0.0.11).
Architecture decisions
Why auto-detect the package manager instead of requiring configuration. The target audience is Linux administrators who already have a package manager installed. Detecting pacman / dnf / apt at bridge-helper import time eliminates a configuration step. The step-down order (pacman → dnf → apt) reflects the suite's Arch-first heritage while supporting the two other major families.
Why install/remove/update return the command rather than executing it. Package operations modify system state and require superuser privileges. Returning the command string lets the panel call spawnPrivileged through the bridge client's permission gating layer, which prompts for elevation and surfaces the result. The bridge helper does not execute the command itself — it only formulates the correct command for the detected package manager.
Why identity objects unify PKCS#11, SSH, and Kerberos. An operator authenticating to a system may use any combination of hardware tokens, SSH keys, and Kerberos tickets. Presenting them as typed identity objects in a single view — rather than scattered across separate panels — gives the operator a unified picture of their authentication surface. The type discriminator (pkcs11-token | ssh-key | kerberos-principal) lets the panel render type-specific detail cards while the summary counts give an at-a-glance overview.
Why the MODULE_LOADERS gap was resolved. In v0.0.11, glances, sensors, and benchmark were registered in registry.js but had no entries in the MODULE_LOADERS map in suite.js. Clicking those sidebar items would throw "No loader registered for module X" errors. Adding the three missing entries is a correctness integration that should have been in v0.0.11.
Code quality pass
- All
cockpit.spawncalls use the array form (SEI CERT — no shell injection). - All panels use functional iterators (
map,filter,reduce) — nofororwhileloops. - Module registry is a single declarative array — adding a module means appending one entry.
- Bridge client is the only path to the system; panels never call cockpit APIs directly.
- Lookup tables over if-ladders (STATUS_STYLES, DEVICE_ICONS, BACKENDS dispatch table).
- Author attribution (Jeremy Anderson / dcos.net) in every source file header.
- Unit tests cover the pure-function parsers; MakefileA
checkruns them. - License audit complete; THIRD_PARTY.md attributions match upstream metadata.
setup.py
extras_requirefor optional deps (glances, sysbench).
Documentation
README.md— architecture, bridge-layers table, module catalog (21 entries), packaging paths, coding conventions.QUICKSTART.md— five-minute path from tarball to running dashboard.BLOG.md— this release narrative.THIRD_PARTY.md— third-party attributions and license audit (now includes pacman/dnf/apt, cockpit-identities, OpenSSH, MIT Kerberos).docs/INSTALL.md— detailed packaging paths (RPM / DEB / pip / manual).tests/test_bridge_parsers.py— unittest coverage for the parsers.LICENSE— MIT, attributed to Jeremy Anderson (https://dcos.net).
Looking forward
The next milestone (v0.0.15) targets compatibility manifest entries + sidebar links to standalone plugins — adding cockpit manifest entries that expose deep links to standalone cockpit plugins (cockpit-pacman, cockpit-identities, cockpit-sensors, cockpit-benchmark) alongside the suite's built-in panels, so operators can choose the built-in panel or navigate to the standalone plugin.
v0.0.11 — 2026-08-16
Theme: external module integrations
v0.0.11 adds three new external-module panels — glances (system monitor), sensors (hardware sensors), and benchmark (system benchmark) — bringing the suite to 17 registered modules. Each module wraps an established open-source tool invoked as a separate process via cockpit.spawn, preserving license independence between the MIT suite and the GPL / MIT / GPL-2.0 tools it calls.
A full license audit accompanies the release. THIRD_PARTY.md documents every external tool's copyright holder, license, and invocation model. Because the suite never bundles external code — it only spawns external processes — the suite itself remains MIT, and each tool's license governs only the tool.
What changed
Glances module (P1). src/modules/glances.js renders a live system-monitor dashboard: CPU per-core bars, memory/swap gauges, disk I/O rates, network throughput, and process top-N. The panel calls cockpit.spawn(["glances", "--time", "2", "--quiet", "-f", "json"]) and streams the JSON output line-by-line. Icon: ◎.
Sensors module (P1). src/modules/sensors.js renders hardware sensor readings: temperature, fan speed, voltage, and current values from lm_sensors. The panel calls cockpit.spawn(["sensors", "-j"]) and parses the per-chip JSON output. Icon: 🌡.
Benchmark module (P2). src/modules/benchmark.js renders a system benchmark workflow: CPU, memory, file I/O, and thread benchmarks via sysbench. The panel calls cockpit.spawn(["sysbench", "<test>", "--time=30", "--threads=4", "run"]) for each test and renders results with score bars and comparison baselines. Icon: ⚡.
Bridge helpers. Three new Python bridge helpers:
bridge/glances.py— wrapsglanceswith structured JSON output and optional per-metric filtering.bridge/sensors.py— wrapssensors -jwith per-chip normalization and alert thresholds.bridge/benchmark.py— wrapssysbenchwith result parsing and baseline comparison.
License audit. Every external tool invocation is documented in THIRD_PARTY.md with: tool name, copyright holder, SPDX license identifier, upstream URL, and invocation model (subprocess via cockpit.spawn). The audit confirms:
- No external code is bundled — the suite only spawns processes.
- The MIT license of the suite is independent of each tool's license.
- GPL-3.0 (glances) and GPL-2.0 (sysbench) apply only to the tools, not to the suite.
Mock data. src/mock-cockpit.js extended with canned responses for glances (CPU/memory/disk/net samples), sensors (coretemp + fan + voltage readings), and benchmark (sysbench CPU/memory/fileio results). Panels render in any browser without the tools installed.
Module registry. src/modules/registry.js now has 21 entries. Sidebar groups: P0 (3), P1 (9), P2 (8).
Architecture decisions
Why subprocess invocation preserves license compatibility. The suite calls glances, lm_sensors, and sysbench as separate OS processes via cockpit.spawn. No code from these tools is linked, imported, or bundled into the suite. Under FOSS license theory, a program that spawns another program as a subprocess does not create a combined work — the process boundary is a clear arm's-length interface. The suite's MIT license governs the suite; each tool's license (GPL-3.0, MIT, GPL-2.0) governs only the tool. THIRD_PARTY.md documents this boundary for every external invocation.
Why the pull pattern (panel requests data on mount) over the push pattern (tool pushes data to panel). External tools have no awareness of the cockpit bridge. The pull pattern — panel mounts, calls cockpit.spawn, renders the response — is the only model that works without modifying the tool. The bridge client's retry/backoff and channel pool layers handle the resilience concerns that a push model would otherwise solve at the tool side.
Why bridge helpers for all three new modules. glances, sensors, and benchmark each produce output that benefits from normalization (consistent field names, alert thresholds, baseline comparison). The Python bridge helpers parse, normalize, and enrich the raw CLI output into a single structured JSON document. Panels receive clean data and don't duplicate parsing logic.
Code quality pass
- All
cockpit.spawncalls use the array form (SEI CERT — no shell injection). - All panels use functional iterators (
map,filter,reduce) — nofororwhileloops. - Module registry is a single declarative array — adding a module means appending one entry.
- Bridge client is the only path to the system; panels never call cockpit APIs directly.
- Lookup tables over if-ladders (scoreColor uses a
SCORE_COLORS.find()table). - Author attribution (Jeremy Anderson / dcos.net) in every source file header.
- Unit tests cover the pure-function parsers; Makefile
checkruns them. - License audit complete; THIRD_PARTY.md attributions match upstream metadata.
Documentation
README.md— architecture, bridge-layers table, module catalog (21 entries), packaging paths, coding conventions.QUICKSTART.md— five-minute path from tarball to running dashboard.BLOG.md— this release narrative.THIRD_PARTY.md— third-party attributions and license audit.docs/INSTALL.md— detailed packaging paths (RPM / DEB / pip / manual).tests/test_bridge_parsers.py— unittest coverage for the parsers.LICENSE— MIT, attributed to Jeremy Anderson (https://dcos.net).
Looking forward
The next milestone (v0.0.12) targets packages module + auth identities — a package-management panel (listing, installing, updating RPM/DEB packages via cockpit.spawn) and an auth identities panel (enumerating PKCS#11 tokens, SSH keys, and Kerberos principals as first-class identity objects). Both will follow the same subprocess model established in v0.0.11.
v0.0.10 — 2026-08-16
Theme: cockpit-bridge channel integration
v0.0.10 hardens the bridge client so panels stop polling and start streaming. Where v0.0.9 introduced the cockpit-native plugin shape, v0.0.10 fills in the transport contract: every spawn is pooled, every privileged call is gated, every metric stream is a long-lived channel, and every system service subscription is a DBus signal instead of a refresh button.
The shell now surfaces the bridge state directly: a live CPU/memory counter in the header (sourced from a cockpit.metrics channel), a superuser elevation badge, and a footer indicator showing whether the DBus proxies came up. Operators can see at a glance whether the dashboard is talking to the system the way it should be.
What changed
cockpit.metrics tap. The bridge client exposes a metricsTap(options) helper that opens a streaming channel and returns a subscribe/unsubscribe pair. The fleet panel subscribes for live CPU + memory samples; the netsec panel subscribes for live socket-rate. No setInterval polling anywhere in the suite.
DBus proxies. initDbusProxies() opens long-lived clients for org.freedesktop.systemd1 and org.freedesktop.NetworkManager. The systemd proxy exposes subscribeToUnit(name, onChange) that listens for .changed signals on the unit's properties interface. Containers, firewall, and integrity panels use this so they re-fetch only when the underlying service state transitions — no more 5-second refresh storms.
Retry with exponential backoff. withRetry(fn) wraps spawn calls with up to three retries on transient bridge failures (cancelled, channel-closed, timeout, internal-error). The backoff is 200ms / 400ms / 800ms. Permanent failures (non-zero exit, command-not-found) propagate immediately so panels fail closed with the real error.
Permission gating. spawnPrivileged(argv) checks cockpit.permission({ superuser: 'try' }).allowed before reaching cockpit.spawn. If the user has not elevated, the call rejects with a typed error and the panel renders an "elevation required" affordance. The header has a live elevation badge and an elevate button that triggers the cockpit prompt via a no-op privileged call.
Channel pool. Identical spawn calls within a 250ms window collapse into a single bridge round-trip. When the dashboard boots and multiple panels mount simultaneously, the header stat counter, the containers panel, and the overview cards all wanting podman ps share one call.
Dev mock. src/mock-cockpit.js shims window.cockpit with realistic canned responses (3 containers, 4 listening sockets, 2 LUKS volumes, 3 fwupd devices, TPM PCR 0 sample, etc.). Uncomment one <script> tag in index.html and the dashboard renders in any browser without the cockpit-bridge — useful for development, screenshots, and design review.
Type declarations. src/cockpit-types.d.ts declares the subset of the cockpit API the suite uses (spawn, file, dbus, channel, metrics, permission, transport, user). Editors and tsc can now type-check the bridge client.
Extended bridge helpers. Three new Python aggregations:
bridge/netsec.py—ss -tulpn+ss -tnp state established+nft -j list countersinto a single JSON document.bridge/fleet.py— local host info (hostname, uptime, load, addresses) +/etc/cockpit/machines.d/*.jsonpeer list.bridge/auth.py—pkcs11-tool --list-token-slots+lsusbreader filter +systemctl is-active pcscd.
Unit tests. tests/test_bridge_parsers.py covers the pure-function parsers in firewall.py, netsec.py, and integrity.py. The Makefile check target runs them via PYTHONPATH=bridge python3 -m unittest.
Architecture decisions
Why metricsTap instead of cockpit.metrics directly? Panels should not know about channel lifecycle. The tap abstraction handles channel creation, subscriber fan-out, and cleanup; panels just call subscribe(cb) and get back an unsubscribe function. The same channel serves multiple subscribers, so the fleet panel and the header stat counter share one bridge round-trip per second.
Why DBus proxies for systemd instead of polling systemctl is-active? Polling wastes bridge round-trips and misses fast transitions. The systemd Manager interface emits UnitNew and UnitRemoved signals plus per-unit property changes; subscribing means the panel wakes only when something actually changed. This matters for the firewall and integrity panels where the service state is usually steady and a refresh-on-event model is correct.
Why retry only transient failures? Permanent failures (command-not-found, non-zero exit) are real signals that the panel needs to surface — the backend tool is absent or the operation was rejected. Retrying those would just delay the failure and hide the real error. The retryable set is closed (cancelled, channel-closed, timeout, internal-error) and documented in a constant at the top of the bridge client.
Why a 250ms pool window? Long enough to catch the boot burst (when multiple panels mount in the same tick), short enough that a deliberate refresh-after-edit still hits the bridge. The window is a single Map lookup keyed by JSON.stringify({ argv, options }) — constant time, no lock contention.
Why gate on cockpit.permission('try') instead of cockpit.permission('require')? The 'try' flavor gives a long-lived object whose .allowed flips when the user elevates, without forcing the elevation prompt at boot. Panels can render their normal UI and surface the elevation affordance only when the user actually attempts a privileged action.
Code quality pass
- All
cockpit.spawncalls use the array form (SEI CERT — no shell injection). - All panels use functional iterators (
map,filter,reduce) — nofororwhileloops. - Module registry is a single declarative array — adding a module means appending one entry.
- Bridge client is the only path to the system; panels never call cockpit APIs directly.
- Lookup tables over if-ladders (scoreColor uses a
SCORE_COLORS.find()table). - Author attribution (Jeremy Anderson / dcos.net) in every source file header.
- Unit tests cover the pure-function parsers; Makefile
checkruns them.
Documentation
README.md— architecture, bridge-layers table, module catalog, packaging paths, coding conventions.QUICKSTART.md— five-minute path from tarball to running dashboard.BLOG.md— this release narrative.docs/INSTALL.md— detailed packaging paths (RPM / DEB / pip / manual).tests/test_bridge_parsers.py— unittest coverage for the parsers.LICENSE— MIT, attributed to Jeremy Anderson (https://dcos.net).
Looking forward
The next milestone (v0.0.11) targets external module integrations — glances, sensors, and benchmark modules invoked as separate processes via cockpit.spawn, with a full license audit and third-party attributions. (Delivered in v0.0.11 — see above.)
v0.0.9 — 2026-08-16
Theme: cockpit-native drop-in plugin
v0.0.9 restructures the suite as a cockpit-native plugin that drops into an existing Cockpit install. The package now ships a manifest.json at the root, static HTML+JS+CSS files that the cockpit web service serves directly, and a Python bridge helper package for aggregations that span multiple CLI tools.
The previous Next.js dashboard is preserved under nextjs-dashboard/ for operators who want a standalone variant. The cockpit plugin is the primary deliverable.
What changed
Cockpit-native plugin. The top-level package now contains:
manifest.json— cockpit v1 manifest registering thesuitecontent key and a top-level menu entry.index.html— entry HTML that loads../base1/cockpit.js(the cockpit-bridge runtime) and thesuite.jsbundle.suite.js— ES module dashboard shell with sidebar / content / footer layout, panel router, and event-bus tail.suite.css— PatternFly-inspired dark theme scoped to.suite-*classes.src/bridge-client.js— typed facade wrappingcockpit.spawn,cockpit.file,cockpit.dbus. Every spawn call uses the array form; no shell strings reach the bridge.src/event-bus.js— singleton pub/sub with a 200-event ring buffer.src/modules/registry.js— single source of truth for the 14-module sidebar.src/modules/<name>.js— one panel per module, each calling the bridge client for real backend data.
Python bridge helpers. The bridge/ package contains standalone CLI scripts for operations too complex for a single CLI call:
containers.py— aggregates podman container state with systemd unit names.firewall.py— parsesnft list rulesetinto structured JSON.integrity.py— runs lynis audit and parses the hardening index.firmware.py— aggregates fwupd device list with TPM 2.0 PCR registers.
Each helper is invoked via cockpit.spawn(["python3", "-m", "sysdeck.bridge.<module>", "<subcommand>"]).
Real backend calls. Every panel now calls the real backend tool through the bridge client:
- Containers →
podman ps - Firewall →
nft list ruleset - Integrity →
lynis audit system - Netsec →
ss -tulpn - Mesh →
kubectl get svc -A - Vault →
lsblk -J - Fleet →
uptime - Kata →
kata-runtime list - Fester →
systemctl list-units - Firmware →
fwupdmgr get-devices+tpm2_pcrread - Builder →
mkosi(Arch) /vmdb2(Debian) — installed backends + profile list - Mining → XMRig REST API
- Themes →
/etc/cockpit/cockpit.conf - Auth →
pkcs11-tool --list-token-slots
Every panel fails closed when its backend tool is absent — the panel shows an install hint instead of crashing.
Packaging. Three install paths are supported:
make install— copies to/usr/share/cockpit/sysdeck/and/usr/lib/sysdeck/bridge/.pip3 install packaging/— usessetup.pywithdata_filesfor the cockpit plugin root.- RPM spec at
packaging/sysdeck.spec—rpmbuild -bbproduces a noarch RPM thatRecommends:the backend tools.
Next.js dashboard preserved. The v0.0.8 Next.js dashboard is preserved under nextjs-dashboard/ with its package name updated to sysdeck-dashboard to avoid conflict with the cockpit plugin. The Next.js variant uses mock data; the cockpit plugin uses real backend calls.
Architecture decisions
Why vanilla JS instead of bundling the React app? Cockpit plugins run inside the cockpit web service and load cockpit.js from the bridge. The bridge provides the cockpit.spawn / cockpit.file / cockpit.dbus API. A vanilla JS bundle loads faster, has no build step, and aligns with how real cockpit plugins (cockpit-podman, cockpit-machines) are written. The React dashboard remains available as a standalone variant for development.
Why a Python bridge alongside cockpit.spawn? The bridge helpers exist for aggregations that span multiple tools — e.g. cross-referencing podman containers with their systemd scope units, or aggregating fwupd devices with TPM PCR registers into a single JSON document. Single-tool calls go directly through cockpit.spawn from the JS bridge client; aggregations go through the Python helpers.
Why fail closed? A production dashboard must never crash when a backend tool is absent. Each panel catches bridge errors and renders an install hint card. The operator sees actionable guidance ("Install opensc and pcsc-lite, then start pcscd.service") instead of a blank screen.
Code quality pass
- All
cockpit.spawncalls use the array form (SEI CERT — no shell injection). - All panels use functional iterators (
map,filter,reduce) — nofororwhileloops. - Module registry is a single declarative array — adding a module means appending one entry.
- Bridge client is the only path to the system; panels never call cockpit APIs directly.
- Author attribution (Jeremy Anderson / dcos.net) in every source file header.
Documentation
README.md— architecture, module catalog, packaging paths, coding conventions.QUICKSTART.md— five-minute path from tarball to running dashboard.BLOG.md— this release narrative.docs/INSTALL.md— detailed packaging paths (RPM / DEB / pip / manual).LICENSE— MIT, attributed to Jeremy Anderson (https://dcos.net).
Looking forward
The next milestone (v0.0.10) targets cockpit-bridge channel integration — replacing the cockpit.spawn polling pattern with persistent cockpit.dbus proxies for live metric streaming. The bridge client facade is already structured to make that swap a per-module decision. (Delivered in v0.0.10 — see above.)
v0.0.8 — 2026-08-16
Theme: production-readiness pass
v0.0.8 hardens the Next.js dashboard variant for production evaluation. Documentation, code quality, and the introduction of the Kata Containers and Fester Build Orchestration modules round out the infrastructure and operations stories. See the v0.0.8 entry in nextjs-dashboard/BLOG.md for the full narrative.
v0.0.7 — 2026-08-16
Theme: reconciliation release
v0.0.7 consolidates the full module surface and shared infrastructure into a single coherent tree. The release unifies the 14-module target architecture (12 core modules plus Kata Containers and Fester Build Orchestration), the 48 shadcn/ui primitives, the Prisma schema and client singleton, the use-mobile and use-toast hooks, and the cn class-merge utility under one package. See the v0.0.7 entry in nextjs-dashboard/worklog.md for the full narrative.
v0.0.6 — 2026-08-16
Theme: build orchestration and sandboxed containers
v0.0.6 introduces the Fester Build Orchestration module and the Kata Containers module, plus the supporting type definitions, mock generators, and event-bus registry entries that wire them into the dashboard.
v0.0.5 — 2026-08-16
Theme: integration and dashboard shell
v0.0.5 delivers the AuthPanel and the main dashboard page that integrates all twelve original modules. The dashboard shell provides the three-zone layout, module routing, and responsive Sheet sidebar for mobile.
v0.0.4 — 2026-08-16
Theme: operations and platform modules
v0.0.4 ships the five operations and platform modules: Fleet, Firmware, Builder, Mining, and Themes.
v0.0.3 — 2026-08-16
Theme: security modules
v0.0.3 ships the three P1 security modules: Netsec, Mesh, and Vault.
v0.0.2 — 2026-08-16
Theme: P0 core infrastructure
v0.0.2 ships the three P0 modules: Containers, Firewall, and Integrity.
v0.0.1 — 2026-08-16
Theme: shared infrastructure
v0.0.1 establishes the shared infrastructure that all subsequent modules build on: types, event bus, mock data generators.
v0.0.28 — 2026-08-17 (finishing touches: 2 broken subcommands + missing CSS classes + a guard that would have caught them)
Theme: the v0.0.27 "subcommand alignment" pass missed two — and the build had no guard to catch it
v0.0.27's release narrative claimed: "FIXED 4 SUBCOMMAND MISMATCHES… each subcommand now verified against the actual COMMANDS dict in the Python helper before being called." That was wrong on two counts:
- The verification was done by hand at authoring time, not enforced at build time — so once the author moved on, nothing prevented the next edit from re-introducing a mismatch.
- The hand-verification itself missed two cases:
firmware.devices(firmware.py only hadsummary) andbenchmark.runTest(benchmark.py had norun-testsubcommand at all). Both shipped throughmake checkbecause no guard looked for them.
This release fixes the two broken modules, fills in a pile of missing CSS that the same hand-verification era let slip, and adds a build-time guard that would have caught both bugs.
What was actually broken in v0.0.27
| # | Plugin | Symptom | Root cause |
|---|---|---|---|
| 1 | sysdeck-firmware | Page crashes with "Unknown subcommand: devices" on every visit | bridge.js calls bridgeCmd("firmware", ["devices"]) but bridge/firmware.py's main() only handled summary. The bridge.js comment even claimed "firmware.py COMMANDS: devices" — the comment was a lie. |
| 2 | sysdeck-benchmark | "Run" button in Available Tests table does nothing useful | bridge.js calls bridgeCmd("benchmark", ["run-test", name]) but bridge/benchmark.py's COMMANDS dict had no run-test entry. |
| 3 | sysdeck-glances, sysdeck-fleet, sysdeck-netsec | Progress bars invisible (0-height divs) | shared/sysdeck.css had no .suite-progress / .suite-progress-bar / .suite-progress-fill rules |
| 4 | sysdeck-fleet, sysdeck-integrity, sysdeck-mining, sysdeck-packages | Multi-column layouts collapsed to single column | shared/sysdeck.css had no .suite-grid / .cols-2 / .cols-3 rules |
| 5 | sysdeck-benchmark, sysdeck-integrity, sysdeck-packages | Primary CTA buttons looked like ghost buttons | shared/sysdeck.css had no .suite-btn-primary rule |
| 6 | sysdeck-benchmark, sysdeck-firewall, sysdeck-kata, sysdeck-mesh, sysdeck-netsec, sysdeck-integrity | Info badges unstyled (no background) | shared/sysdeck.css had no .suite-badge.info rule |
| 7 | sysdeck-packages | Search box unstyled, no border/padding | shared/sysdeck.css had no .suite-input rule |
| 8 | sysdeck-packages | "Updates pending" warning count not colored | shared/sysdeck.css had no .suite-warn rule |
| 9 | cockpit-smoke-test.sh | Smoke test produces false "Cockpit is broken" diagnostic | Embedded manifest used "cockpit": ">=239" — the exact broken pattern Cockpit silently rejects at the discovery layer |
| 10 | sysdeck-diagnose.sh | Cannot detect missing Python bridge helpers | No section verifying /usr/lib/sysdeck/bridge/*.py exists or is executable |
| 11 | bridge/init.py docstring | Misleading invocation example | Still showed broken python3 -m sysdeck.bridge.X pattern from v0.0.25 |
Fixes
bridge/firmware.py — added devices subcommand. Refactored to a proper COMMANDS dict and added a devices subcommand that returns fwupdmgr's native {Devices: [...]} shape (capital D — matches the panel's result.value?.Devices access pattern). Kept summary as an alias for backwards compatibility. Added defensive normalization so the helper always returns a renderable shape even when fwupdmgr is absent, fails, or emits invalid JSON.
bridge/benchmark.py — added run-test subcommand. New run_test(args) function takes a test name from argv, runs sysbench <name> run, and returns the parsed result dict {raw, events_per_sec, latency_ms, error?} — same shape as the existing run-cpu/run-memory/run-io helpers so the panel can render it uniformly. Surfaces sysbench failures via the error field instead of crashing.
shared/sysdeck.css — added 16 missing CSS classes. All 16 classes that the v0.0.19-era plugin JS referenced but the shared CSS never declared:
.suite-row, .suite-row-between /* horizontal flex layouts */
.suite-grid, .cols-2, .cols-3 /* grid layouts */
.suite-col-2, .suite-col-3 /* legacy flex column widths */
.suite-btn-primary /* primary CTA button */
.suite-badge.info /* informational badge */
.suite-stat-value, .suite-stat-label /* stat block hierarchy */
.suite-progress, .suite-progress-bar,
.suite-progress-fill /* progress bar track + fills */
.suite-input /* form inputs */
.suite-warn /* warning modifier */
Each class has a comment explaining which plugin(s) use it and what breaks without it.
cockpit-smoke-test.sh — fixed the embedded manifest. Changed "requires": { "cockpit": ">=239" } to "requires": { "cockpit": "239" } to match the pattern used by every real working plugin in this tarball. Added a long comment explaining why ">=239" is broken (Cockpit's sortify_version() turns ">=" into a string that sorts GREATER than any real cockpit version because > is ASCII 62 > 0 ASCII 48, so packages.py raises JsonError and silently rejects the manifest).
sysdeck-diagnose.sh — added section 5a. New section verifies that:
/usr/lib/sysdeck/bridge/*.pyexists- Each helper is executable
glances.py --helpruns end-to-end (smoke test)firmware.py devicesreturns{Devices: [...]}benchmark.py run-testreturns a sysbench result dict
bridge/init.py — fixed docstring. Updated the invocation example from the broken python3 -m sysdeck.bridge.containers pattern to python3 /usr/lib/sysdeck/bridge/containers.py, with a note explaining why the -m pattern was broken (requires a nested Python package layout the Makefile never produced).
The new guard: check-bridge-subcommands
The headline addition. A new Python static analyzer at tests/check_bridge_subcommands.py:
- Regex-parses
shared/bridge.jsto find everybridgeCmd("<module>", ["<subcommand>", ...])call. - For each
<module>, ast-parsesbridge/<module>.pyand extracts the keys of itsCOMMANDSdict. For helpers without aCOMMANDSdict (e.g.db.py,hwalert.py), falls back to scanningmain()'sargv[0] == "X"checks. - Verifies every subcommand the JS expects actually exists in the Python helper's dispatch table.
On failure, prints a clear message naming the bad file, line number, the JS-side call, and the Python-side COMMANDS dict contents. Example failure output (when I temporarily reverted firmware.py to its v0.0.27 state during regression testing):
FAIL: bridge.js calls Python subcommands that don't exist:
shared/bridge.js:173: bridgeCmd("firmware", ["devices", ...]) —
bridge/firmware.py does not expose a "devices" subcommand.
Its COMMANDS dict has: ['summary'].
To fix: either add the missing subcommand to the Python helper's
COMMANDS dict, or change the bridge.js call to use a subcommand
that exists.
Wired into the Makefile as the check-bridge-subcommands target, added to the check aggregate target. The full guard list is now:
check: check-metainfo-consistency
check-manifest-consistency
check-makefile-recipes
check-no-broken-cockpit-import
check-no-broken-python-module
check-bridge-subcommands <- NEW
check-version-sync
Verification
make checkend-to-end: all 7 guards pass; 19 JS sources passnode --check; 19 manifest.json files are valid JSON; both shell scripts passbash -n; 9 bridge parser unit tests pass.- Smoke-tested the two fixed Python helpers:
python3 bridge/firmware.py devicesreturns{"Devices": []}(correct shape; empty because no fwupdmgr in this container)python3 bridge/benchmark.py run-testreturns{"raw": "", "events_per_sec": null, "latency_ms": null, "error": "no test name provided"}(graceful error)
- Regression-tested the new guard: temporarily reverted
bridge/firmware.pyto its v0.0.27 state (onlysummarysubcommand). Confirmedmake checkfails with the exact message above. Restored the fix; confirmed the guard passes. make distcheckpasses — tarball extracts intosysdeck-0.0.28/wrapping directory;make checkruns inside the extracted tree and all 7 guards pass.
Lesson
The v0.0.27 release notes claimed "each subcommand now verified against the actual COMMANDS dict." That verification was done by hand at authoring time — and hand-verification rots the moment someone touches either side without re-running the verification.
The new check-bridge-subcommands guard makes the verification automatic and continuous. Every make check from now on will catch any future bridge.js ↔ Python helper drift, with a message that names the exact file, line, and missing subcommand.
v0.3.1 — 2026-09-13 (the login gate)
The cockpit-style login (0.3.1 follow-up)
"sysdeck is intended for lan side use not wan facing, so this is a decision point for lack of crypto or auth in depth. a simple cockpit-style login for our standalone is fine by me, lets get it coded if it hasnt been added to the standalone nextjs side" — the operator made the posture call, and this entry records how it landed.
The 0.3.0 audit ended with the web edition guarded (loopback binds, rate limits, body caps) but still unauthenticated. That was honest for a LAN-side console — and it left the door literally open to anyone who could reach the port. 0.3.1 adds the one boundary that matches the actual threat model (the roommate, the accidental port-forward): a shared password, exactly like the Cockpit login the console's plugin pages already live behind.
Mechanically: SYSDECK_WEB_PASSWORD in web/.env (default
sysdeck, with an amber nag on the login screen until you change
it — the nag is intentional, so a default install advertises its own
default). The password compares in constant time; failures rate
limit per IP at 5/minute and both login and failure land in the
AuditLog with the source IP. A success mints an HttpOnly
SameSite=Lax cookie carrying an HMAC-SHA256-signed token with a 12h
expiry — and the signing key is generated per install and persisted
in the same SQLite store everything else uses, which buys a
property the project needed anyway: the fester mini-service reads
that key straight out of the DB file and verifies the identical
token on its REST and WebSocket surface. The browser's live DAG
event stream rides the same cookie, so the direct
?XTransformPort=3010 path is gated, not just the proxied routes.
Until the web console has booted once, fester keeps its documented
standalone behavior (loopback, unauthenticated) — the gate arms
itself the first time the console renders.
Everything visible is gated: the page server-renders a login screen
until the cookie verifies, every /api/* route answers 401 until
signed in, and a session expiring mid-flight reloads to the login
instead of plastering panels with error cards. What it is not is
also on the record (QUICKSTART §10.4): no user accounts, no MFA, no
online-attacker crypto — the loopback bind remains the outer
boundary, and SYSDECK_SESSION_SECURE=1 arms the Secure cookie flag
when an operator fronts the console with TLS.
Verified: bun run lint clean; curl smoke of the full auth
lifecycle (wrong password 401 + audited, login → cookie → bridge 200,
logout → 401 again, forged token rejected, 429 after the failure
cap, fester REST+WS gated, standalone-mode fallback);
make check 254/254 across the bumped release surfaces; the master
tarball rebuilt with new guards (session lib, gated routes, page
gate, fester gate, QUICKSTART §10.4).
v0.4.0 — 2026-09-12 (the Unix login)
"lets change from shared login to unix acc based login same way cockpit does it" — the operator's directive, and the one this whole project was always pointed at: the console is named for the deck it replaces, so its login should work the way that deck's login works. You sign in with a Unix account, and the host's PAM stack decides.
The mechanics are deliberately boring, because PAM already solved
this in 1997: web/scripts/pam-auth.py is a stdlib-only ctypes
client of libpam that runs the same sequence every login surface on
the box runs — pam_start → pam_authenticate → pam_acct_mgmt —
under the sysdeck service when /etc/pam.d/sysdeck exists, else
the stock login stack. The credentials arrive over stdin (argv is
world-readable in /proc, so it never touches argv), the
conversation callback answers only password/username prompts, and
the reply buffers come from the C allocator because Linux-PAM frees
them itself — the classic ctypes-PAM heap-corruption trap, found and
fixed the honest way (the sandbox crash first, then the malloc).
The honest constraint surfaced early: pam_unix needs root to verify
arbitrary users (a non-root process only gets its own uid through
unix_chkpwd — a pam_unix guarantee, not ours). Cockpit answers that
by running cockpit-ws as root; SysDeck ships the same call as a
mode: SYSDECK_AUTH_MODE=pam (default) for the root systemd unit,
pam+local for unprivileged installs (PAM first, then a SdUser
scrypt table managed by bun scripts/manage-users.mjs), local for
console-accounts-only. A wedged PAM helper fails CLOSED — a health
incident, never a silent fallback. Failures rate limit per-IP and
per-username, wrong-user and wrong-password look identical, and the
AuditLog now records the unix username as the actor on both ends.
The session token grew a spine: v2.<exp>.<userB64>.<hmac> — the
cookie is bound to the account that earned it. The shell wears the
identity cockpit-style: an account menu with avatar, user@host,
PAM/local provenance, the wheel "Administrative access" badge, and a
session-expiry countdown with a draining life bar; the status bar
carries user@host beside the vitals. The fester service verifies
the identical v2 token on REST and WebSocket. And v1 tokens still
verify — the 0.3.1 README promised restart-stable sessions, so the
0.4.0 upgrade keeps that promise: old cookies age out as legacy
"operator" sessions instead of logging anyone out.
The login screen itself finally got the fester treatment: host identity banner (hostname + OS pretty name — exactly what cockpit leads its own login with), an aurora/grid backdrop that re-skins under every console theme, caps-lock detection, a one-shot error shake, Enter-to-advance from username to password, and the amber default-password nag for the seeded local account until it's rotated. Reduced-motion users get the same scene, still.
Verified: PAM helper exercised against the live stack (wrong password → PAM_AUTH_ERR, clean exits, no heap events); curl smoke of the full lifecycle (401 pre-auth, login → v2 cookie → bridge 200, logout → 401, forged v2 rejected by console AND fester, 429 after the failure cap, legacy v1 acceptance); browser-driven pass over the login scene, account menu, panel transitions; the cockpit bridge guards re-run unchanged (218 calls / 28 modules, manifests clean) — the plugin side is untouched by design.
v0.4.1 — 2026-09-12 (cockpit module detection)
"lets make sure any installed cockpit modules detected are also loaded in the nextjs only side as well, such as the distro modules like cockpit-machines and cockpit-podman for example. this will be 100% compatible at that point." — the operator's compatibility directive, and the honest last gap: 0.4.0 made the LOGIN cockpit-equal, but a host with cockpit-machines or cockpit-podman installed still showed those modules nowhere in the Next.js console.
0.4.1 makes the console perform the same discovery the cockpit shell
does: scan /usr/share/cockpit/<pkg>/manifest.json and treat every
package with a menu entry as a module. That single rule carries the
whole feature — distro modules (machines, podman, networking, storage,
accounts, updates, SELinux, PCP metrics, kdump, tuned), 45Drives-style
addons, anything a packager ships with a manifest. sysdeck-* modules
are skipped because native panels already exist for them; base1 and
shell never appear because they have no menu — the shell's own rules,
reused verbatim. Detection is pure filesystem, so it works with cockpit
stopped, absent, or merely staged (SYSDECK_COCKPIT_SCAN points at
extra roots, colon-separated, for DESTDIR installs).
Every detected module loads into the console's navigation: a
"Cockpit" sidebar group with a LIVE/DEMO provenance badge, a ⌘K palette
group, and a per-module detail view — manifest identity, shipped files
with sizes, live backend presence probes (real which() checks for
virsh/podman/nmcli/pkcon/getenforce/pmrep..., on-demand
version probes), and a jump to the native panel that already covers the
domain: machines and podman → Containers & VMs, packagekit → Packages,
networkmanager → Network Security, metrics → Monitoring, storage →
Overview. A Cockpit Modules hub panel summarizes the scan: counts,
backend availability, native coverage. With no cockpit tree on the
host, the surface shows a clearly-badged typical-distro set so it stays
explorable — the same demo/live honesty the rest of the console
practices.
The same directive retired the codenames: no more "web edition", no edition subtitles. The console's identity is SysDeck, and its one subtitle line is a single clean URL — dcos.net — on the login banner, under the sidebar wordmark, and in the status bar. The page title is just "SysDeck".
Verified: live detection exercised end-to-end against a staged
cockpit tree (machines + podman detected LIVE with labels, orders, API
levels and file counts from their manifests; menu-less chrome and
sysdeck-* correctly excluded); demo fallback returns the 11-module
typical distro set badged DEMO; detail views, native-panel jumps, hub
table, and palette entries driven through a real browser; make check
still ALL PASS (218 calls / 28 modules, 254/254 tests, version sync at
0.4.1); lint and tsc clean on every touched file.