diff --git a/BLOG.md b/BLOG.md index f2c96e2..f7fa27e 100755 --- a/BLOG.md +++ b/BLOG.md @@ -1,3288 +1,72 @@ -# SysDeck — Release Notes +# SysDeck and the Cockpit Inheritance: One Module Catalog, Two Frontends, and the Case for Real Host State -Author: **Jeremy Anderson** · · +*A technical walkthrough of how SysDeck 0.4.4 — a twenty-six-module Linux server console that ships simultaneously as a drop-in Cockpit plugin and a standalone Next.js edition — authenticates against the host's own Unix accounts through PAM exactly the way Cockpit does, keeps every module working in both frontends, holds itself to a compiler-enforced no-demo contract where every panel reads real host state or fails honestly, and resolves every fork in the road with a step-down through the system's real tools: ten package managers from pacman to sorcery, nftables before iptables, lm-sensors before raw sysfs. Grounded in the source code, not in marketing claims.* + +There is a particular category of Linux server console that treats the operator as an implementation detail. The shared-password panels — one login for whoever holds the string, no per-user audit trail, no privilege boundary between reading a sensor list and rewriting the firewall — go back to the Webmin era, and the pattern keeps getting reinvented because it is easy to build. A second category is softer but just as corrosive: the console that demos beautifully and operates poorly, because half its panels are wired to fixture data that was never meant to see a production host. Both categories share a root cause: the console stopped being a view onto the system and became a self-contained application with opinions of its own. + +Cockpit solved the first problem a decade ago and has kept solving it since: the login is the host's own PAM stack, the session belongs to a real Unix account, privileged operations ride a polkit-authenticated superuser channel, and every page is a thin view onto systemd, the firewall, and the package manager as they actually are. SysDeck is built inside that inheritance. It ships as a cockpit-native plugin — static HTML+JS+CSS plus a Python bridge package installed under `/usr/share/cockpit/sysdeck-*/`, discovered automatically by the cockpit-bridge, no separate web server, no Node.js runtime — and as a standalone Next.js edition (`web/` in the master tarball) that runs with no Cockpit installed at all. The two frontends share one module catalog of twenty-six domain modules — containers, firewall, network security, integrity auditing, service mesh, encryption vaults, fleet compute, Kata Containers, firmware, image building, mining, theme engine, hardware authentication, build orchestration, monitoring, sensors, benchmarking, packages, policy and permissions, database control, media servers, photo managers, remote filesystems, the service/port editor, plus a third-party Cockpit-module installer — and one contract: real host state or an honest empty. The web edition's TypeScript bridge layer (`web/src/lib/sysdeck/bridge/*.ts`) mirrors the cockpit-side Python bridges (`bridge/*.py`) command-for-command; the packages bridge runs the same ten-manager step-down on both sides, and the sensors bridge runs the same `sensors -j` → sysfs chain. The inheritance also runs both directions: the web console scans `/usr/share/cockpit` and `/usr/local/share/cockpit` (extra roots via `SYSDECK_COCKPIT_SCAN`) and loads every installed third-party Cockpit module into its own sidebar, so cockpit-machines or a 45Drives plugin appears in the Next.js console the same way SysDeck's own modules appear inside Cockpit. + +This post is a technical walkthrough of how those pieces fit together, grounded in the source code rather than marketing claims. It is organized around the four decisions that most heavily shape SysDeck's identity: the decision to authenticate with the host's own Unix accounts through PAM rather than a private user database, the decision to keep one module catalog behind two frontends with real parity between them, the decision to make "real host state" a compiler-enforced contract instead of a reviewer's good intentions, and the decision to resolve every fork in the road — package manager, firewall backend, sensor source, privilege path — with an explicit step-down through the system's real tools rather than an abstraction layer that hides them. --- -## v0.3.0 — 2026-09-11 (AI Gateway Edition: klanker-gate integrated) +## The Authentication Model: Unix Accounts via PAM, the Way Cockpit Does It -*(latest release: v0.4.3 — the MoE QA pass; see the last entry below)* +The load-bearing decision in the web edition is that it owns no account system. `web/scripts/pam-auth.py` is a stdlib-only ctypes shim that loads `libpam` directly and hands the username and password to the host's PAM stack, the same mechanism a Cockpit login uses. The password arrives on stdin as one JSON document — never argv, which is world-readable through `/proc//cmdline` — and the PAM conversation callback answers only `PAM_PROMPT_ECHO_OFF` and `PAM_PROMPT_ECHO_ON` messages, acknowledging anything else with an empty reply so an exotic stack cannot fish for extra data. `pam_acct_mgmt()` runs after authentication, because an account that is expired, locked, or outside its allowed login hours must not pass just because its password was right. -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 service name steps down the same way the rest of the system does: `SYSDECK_PAM_SERVICE` or the request's `service` field (default `sysdeck`) first, so an operator can ship a tailored `/etc/pam.d/sysdeck` stack; when that stack does not exist the helper falls back to `login` — the stack the console TTY uses, which is what "log in like at the console" means on a stock distro. Sessions are user-bound HMAC tokens, `v2...` in `web/src/lib/sysdeck/session.ts`, minted from a per-install secret and bound to one account name; the login route rate-limits attempts per IP *and* per username, and audits the actor it actually authenticated. `SYSDECK_AUTH_MODE` selects the deployment posture as a step-down — `pam` (the Cockpit default; run the service as root so any Unix account can sign in), `pam+local` (PAM first, locally-stored scrypt-hashed console accounts as fallback, which works unprivileged because `unix_chkpwd` serves the invoking uid), or `local` (console accounts only). This is the same honesty Cockpit applies to its own root posture: the docs state plainly that arbitrary-user PAM verification requires the service to run as root, and the operator picks the rung that matches the deployment. -### The verdict: zero source changes (it was never a Windows codebase) +## One Module Catalog, Two Frontends -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: +The catalog is a single declarative source: `scripts/generate-plugins.py` enumerates the modules, and adding one means appending an entry and dropping a plugin directory — no other wiring. On the Cockpit side each plugin is a directory under `plugins/` with a `manifest.json` registered under the `index` menu key, and its panels reach the system exclusively through the Python bridges, invoked as `python3 /usr/lib/sysdeck/bridge/.py [args]`. Privilege rides the cockpit superuser channel — the JS passes `{ superuser: 'try' }` to `cockpit.spawn`, the operator authenticates once through polkit against the shipped `org.sysdeck.policy` action domains (`packages.modify`, `firewall.modify`, `builder.modify`, and so on), and the bridge then runs as root. There is no sudo shell-out from JavaScript anywhere in the suite. -- `apps/gateway/cluster.ts` — `reusePortSupported()` returns true **only for `linux` and `darwin`**. 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/sh` script. -- `scripts/` — `.sh` and Deno `.ts` tasks; not a single `.bat`/`.ps1` in the tree. -- `deno.lock` win32 entries — ordinary cross-platform lockfile records that every project using npm-native dependencies carries on every OS. +The web edition is a full Next.js console over the same catalog. Its bridge layer replaces the Python helpers with TypeScript modules that exec the same system commands — `ss`, `nft`, `podman`, `systemctl`, the package managers — and speak the same JSON envelope. Mutations there run only with real privilege (root or passwordless `sudo -n`) and, since 0.4.3, only from an admin session (wheel/sudo/adm or uid 0), with `SYSDECK_MUTATIONS=any` documented for single-operator consoles where every login *is* the operator. Module-level parity is treated as a release property, not an aspiration: the 0.4.3 QA pass explicitly hardened the Python side to match the web side (the sensors chain, the `dnf check-update` exit-100-is-data rule, timeouts on every spawn), and the 0.4.4 pass did the same for the packages bridge — the ten-manager step-down, the detection probes, and the parser fixtures now live in both trees. The third-party module installer completes the loop in both directions: it pulls 45Drives Navigator, cockpit-pacman, cockpit-identities, and friends on demand with license, developer, source URL, and homepage shown inline next to a one-click install button, and the web console's cockpit-module detection (`bridge/cockpitmodules.ts`) reports the scanned roots honestly in its envelope when no Cockpit tree exists — `unavailable`, with a note saying exactly what was scanned and how to extend it. -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). +## Real Host State or an Honest Empty: The Zero-Demo Contract -### What ships in sysdeck-0.3.0-master.tar.bz2 +The most important type in the web edition is three strings wide: -- `/` — 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 its `ATTRIBUTION.md`), including the new `arch/` packaging directory. +```ts +export type DataSource = 'live' | 'hybrid' | 'unavailable' +``` -### Run without Cockpit — the complete runbook (0.3.0) +Every bridge envelope carries a `source` field, and `'demo'` is not a member of the union. A panel cannot fabricate rows without lying in a field the type system refuses to produce — the contract is checked by the compiler, not by reviewer vigilance. `'live'` means the data was read from this host; `'hybrid'` means live data merged with an operator-managed registry (the service/port editor cross-references `ss -H -tlnp` output against its `SERVICES_REGISTRY` of known services); `'unavailable'` means the backend is absent and the panel says so, with install guidance, instead of inventing an inventory. -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 concrete behavior this forces is worth spelling out, because it is the difference between a console and a demo. Sensors reads the canonical source — `sensors -j` from lm-sensors — with the raw sysfs collectors (`/sys/class/hwmon`, thermal zones) as the dependency-free fallback, and a host with no sensors gets an empty list, never a made-up chip set. Network-security bans are enforced for real: banning an address loads an atomic nftables batch (`table inet sysdeck`, a `blacklist` set with 30-day timeouts) or an iptables `INPUT DROP` rule when only iptables exists, the ban list merges the live fail2ban state when fail2ban runs, and unbanning a fail2ban row executes the real `fail2ban-client set unbanip` and reports its actual exit code. `dnf check-update` exiting 100 (updates exist) is parsed as data, because treating it as failure would fabricate an empty update list on every RPM host. A firewall template apply marks the ruleset `ACTIVE` only after the apply exits zero. A database backup is a real `dump | gzip > file` binary-safe pipeline, not a text buffer with a `.sql.gz` name. Every spawn from every bridge runs under a scrubbed `LC_ALL=C` environment with a hard timeout and an output cap, so parsed output stays locale-stable and a runaway command cannot exhaust the console. -- **the "Run without Cockpit" panel** in the web console (system group, right under Overview): prerequisites, the one-command `make web-dev` quickstart, the granular commands, the standalone production build (bun + node-only paths), both systemd units (web + fester), the `.env` reference table, the reverse-proxy/WebSocket-gateway configs (Caddy + nginx), and a troubleshooting matrix — every command block copy-to-clipboard. -- **`web/README.md`** in the master tarball — the same runbook as plain markdown. -- **QUICKSTART §11** — the condensed version, plus §12 for the skin below. +## Ten Package Managers, One Step-Down -### The web-edition skin for Cockpit (0.3.0) +The packages module is where the step-down philosophy is most visible, because the fork it faces has ten tines. The alternative — a cross-distro abstraction like PackageKit — was rejected for the same reason the SCP browser in a certain Rust SSH client shells out to `ssh`: the abstraction would carry its own daemon, its own policy layer, and its own divergence from what the operator actually types at a root shell. SysDeck wraps the distro's own manager instead: pacman on Arch, emerge on Gentoo, lunar on Lunar, sorcery on SourceMage, xbps on Void, apk on Alpine, zypper on openSUSE, dnf and yum on RPM hosts, apt on Debian. -*"…or completely theme cockpit to look like the web edition 0.3.0 as demoed"* — done, without touching a single module: +Detection is a `shutil.which` step-down in a fixed order, most specific first, with two corroboration rules that matter: `emerge` only claims the host when `/var/db/pkg` also exists (a Gentoo box always carries the vdb), and Void is probed through `xbps-query` because Void ships no bare `xbps` binary. Each backend then reads the manager's real state — pacman `-Q`, the rpm database for zypper, a direct `/var/db/pkg//-` directory scan for emerge (no emerge invocation needed), `lvu installed` with the `/var/state/lunar/packages` file as the dependency-free fallback, `gaze installed` with `/var/state/sorcery/packages` likewise. The parsers are built to survive the tools' actual output formats: zypper tables are parsed by locating `Name`/`Current`/`Available` columns from the header row, because zypper prefixes its tables with status and repository columns whose count varies by subcommand and release; the emerge update preview anchors its capture *after* the class bracket (`[ebuild U ] cat/pkg-1.2.3 [1.2.2]`), because portage pads the class field with spaces and a looser regex captures the bracket itself and silently drops every row. That failure mode is the exact bug the zero-demo contract exists to prevent — a parser that returns nothing looks identical to "no updates available" unless someone makes the distinction explicit. -- `shared/sysdeck-web.css` — the skin: every plugin's `index.html` links 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 distro `branding.css` first, `make uninstall-branding` restores it. +Where a manager genuinely lacks an operation, the module reports that instead of approximating it: `lvu` has no update-preview subcommand, so the lunar backend returns an honest empty and the summary carries a note — "lunar has no update-preview subcommand — run lunar update to fetch + rebuild" — rather than a count of zero that would read as "all current." Single-module update has no lunar equivalent, so the mutation refuses with the real instruction; every other manager maps to its exact argv (`pacman -S --noconfirm`, `emerge --unmerge`, `cast`, `dispel`, `xbps-remove -y`, `zypper --non-interactive install`, …) from one `MUTATION_CMDS` table shared by install, remove, update, update-all, and the dry-run preview. Package names pass an argument-injection guard first — no leading dash (a name like `--config=` becomes a manager *option*), no URL scheme (dnf would fetch a remote RPM), no whitespace, bounded length — because the name travels to the system package manager as one argv element, which makes this argument injection, not shell injection. -### The Arch packaging (`klanker-gate/arch/`) +## The Firewall: Seven Topologies, the Live Ruleset, and Privilege on stdin -- `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` (pacman `backup=()`), unit + sysusers + tmpfiles in their canonical locations. `depends=('deno>=2.9')`; postgres is an `optdepends` (remote `FROSTY_PG_URL` is equally supported). -- `klanker-gate.service` — hardened systemd unit: `StateDirectory=klanker-gate`, `WorkingDirectory=/var/lib/klanker-gate` (so the permission contract's `--allow-write=data` resolves to `/var/lib/klanker-gate/data`), `ProtectSystem=full`, `PrivateTmp`, empty `CapabilityBoundingSet`. Deliberately no `MemoryDenyWriteExecute` (V8's JIT needs W^X pages). -- `run.sh` — the ExecStart wrapper: one-time `deno cache --frozen` warmup into the service user's DENO_DIR, then exec with the upstream permission flags, plus `--allow-run` scoped to the Deno binary **only** when `FROSTY_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 firewall module treats the host's kernel firewall as the only source of truth and the panel as a view. A live-ruleset tab reads the actual active ruleset — `nft -j list ruleset`, or `iptables-save` when only iptables exists — rendered straight from the binary and refreshed every 15 seconds. The template catalog ships seven full topologies (public-webserver, vps-webserver, ai-llm, remote-admin, no-services, cilium, and the unified zone firewall sysdeck-fw, which takes its zone model from Smoothwall Express and IPFire), each a real script the operator can read before running. -### The klanker module (both editions) +The apply path is where the privilege discipline lives. Rulesets ride stdin: the bridge pipes the synthesized nft or iptables script to `nft -f -` / `iptables-restore` directly, so no predictable `/tmp` file exists to hijack — the same hardening the shipped templates carry via `mktemp` staging. A dry-run renders the exact script the apply would execute, verbatim, so preview and execution cannot diverge. Rule comments are injection-guarded and escaped at render time, because a comment containing a quote would otherwise escape its string literal inside the generated script. The ruleset is marked `ACTIVE` only after the apply exits zero. And the network-security module's ban/unban operations share this exact privilege chain on the web side — root or `sudo -n`, honest refusal otherwise — while the cockpit side gates the same operations through the polkit `firewall.modify` domain. Secrets follow the same stdin rule everywhere: the smartcard PIN in the hardware-auth module never touches argv, so `/proc//cmdline` cannot leak it to other local users. -- `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 ` (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/models` on this host, 0.4s each in parallel threads, returns wiring recipes + env/admin-API examples). `KLANKER_URL` (default `http://127.0.0.1:8080`) and `KLANKER_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, Bearer `KLANKER_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 to `source: live` untouched. -- `shared/bridge.js` — the klanker surface: 11 methods. `check-bridge-subcommands` now verifies **218 calls across 28 bridge modules** (was 216/27 — the audit pass added auth readers+certs). +## Performance Without Fabrication -### The local stack is first-class (0.3.0 follow-up) +A console that polls the host every ten seconds is a console that can DDoS itself, so the web edition's shared bridge layer (`web/src/lib/sysdeck/bridge/shared.ts`) runs a TTL plus single-flight cache: identical probes inside one window collapse into a single subprocess sweep whose settled result serves every panel, an explicit `invalidateCache()` sweeps it after mutations, and errors are never cached — a failed probe is retried, not enshrined. Binary presence probes (`which`) carry their own short TTL because a missing binary is a slow-moving fact. Every child runs with a scrubbed environment and an 8 MB output cap, and supports stdin for the privileged-write paths above. -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. +The step-down rule applies to performance too. The services module rewrote its listener enumeration as an async `ss -H -tlnp` parse with a `/proc` step-down for hosts without ss, replacing a synchronous per-process-per-file walk that blocked the event loop on every poll. Fleet reachability probes fan out in parallel. The Fester journal viewer folds its replay in O(delta) over a WeakMap-keyed running fold instead of re-reducing the whole log on every render. None of this fabricates anything — it changes how often the truth is sampled, never what the truth is. -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). +## Putting It All Together -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 canonical workflow ties the pieces together: +1. **Install** drops the cockpit tree under `/usr/share/cockpit/` (plugin panels, Python bridges, polkit policies) and optionally unpacks the web edition with its two systemd units; `make check` gates the build on manifest consistency, bridge subcommand coverage, parser unit tests, and version sync across every release surface. +2. **Sign in** with a real Unix account — PAM answers, the session mints a user-bound `v2` token, and the login route's per-IP and per-username rate limits stand guard. +3. **The catalog loads**: twenty-six SysDeck modules in whichever frontend you opened, plus every installed third-party Cockpit module the scanner found under the scan roots. +4. **Every panel probes its real backend** — one cached subprocess sweep per window serves them all; absent backends render honest empties with install hints. +5. **A mutation gates on privilege**: polkit on the cockpit side, an admin session plus root or `sudo -n` on the web side, with rulesets and secrets riding stdin and the executed command reported back verbatim. +6. **The result is auditable**: the envelope's `source` field says where every row came from, dry-runs show exactly what will run, and the firewall panel reads back the kernel's own ruleset rather than its own intentions. -### 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 `/` -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`/`journal` all return graceful structured JSON in the sandbox (no Deno, no systemd units here — exactly the degradation path designed). -- Web edition: `bun run lint` clean; bridge smoke — all 7 klanker commands return `ok:true` with `source:'demo'`; panel render + mobile verified with zero page/console errors. -- Master tarball rebuilt by `scripts/make-master-tarball.sh` with the vendored `klanker-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.md` is the 5-minute path to the LIVE badge. +Each piece is independently simple — `pam` is `libpam`, the firewall is `nft`, the package managers are the distro's own tools, and the bridges are thin parsers over their output. The value SysDeck adds is not in reimplementing any of them; it is in the catalog that organizes them, the parity that makes every module work identically in Cockpit and in the standalone console, the type-level contract that keeps every panel honest, and the step-down discipline that keeps every fork resolved in favor of the system's real tools. --- -## 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` + `/sys` collectors 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_URL` override, default `http://127.0.0.1:3010`): `status`, `metrics`, `builds`, `build `, `nodes`, `targets`, `timeline `, `sessions`, `start-build --project --targets [--no-cache] [--retries] [--fail-action]`, `cancel `, `replay `. Every subcommand degrades to actionable JSON when the service is down ("start it with `make 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-subcommands` now 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?)`), so `make install` / `make dist` could not run from the released tarball. v0.2.0 restores tab indentation (the fix the v0.0.28 guard itself recommended) and `make -n` passes 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.py` clean; `node --check` clean on `fester.js` and `bridge.js`; `tests/check_bridge_subcommands.py` → 206/206 across 27 modules; `check-makefile-recipes` green (tabs restored); `make -n` parses for install / plugins / dist / master / web-dev / fester-start. -- Live integration smoke against the running fester service: `start-build --project gentoo-stage3 --targets mipsel` → build `gentoo-stage3-mtw730qo` (7/7 actions, 7 CAS cache hits, 1050 ms critical path); a second build listed `running` then `cancel` → `{"ok": true}` (final state cancelled, 11 timeline events); `replay` → session `sess-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: - -```python -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: - -```python -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 ` 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.conf` there — doesn't find one -- uses empty defaults (2 base packages) -- the `--include arch-workstation.conf` file 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: - -```python -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 `` 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 -`. 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 `. Removes the entire -`/var/lib/sysdeck/builder/artifacts//` 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: - -1. "Delete build record?" — OK = delete state + log only -2. If Cancel: "Also delete ALL artifacts for profile?" — OK = delete - state + log + the profile's entire artifacts dir - -The Python `build-delete [--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_file` - - `test_artifact_delete_refuses_path_traversal` - - `test_artifacts_clear_removes_all` - - `test_build_delete_removes_state_and_log` - - `test_build_delete_with_artifacts_flag_clears_artifacts_dir` - - `test_build_delete_nonexistent_returns_error` - - `test_new_subcommands_registered_in_commands` - -- `TestBuilderMkosiTempWorkDir` (3 tests): - - `test_prepare_creates_temp_dir_with_mkosi_conf_symlink` - - `test_prepare_returns_none_for_missing_profile_path` - - `test_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: - -```python -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.conf` in `/etc/mkosi/mkosi.conf.d/` -- The file is named `arch-workstation.conf`, NOT `mkosi.conf` -- mkosi finds no config → uses empty defaults -- Zero packages installed - -Even for v0.1.0+ profiles at `/etc/mkosi/profiles//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 ` on every mkosi build - -```python -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: - -```ini -[Packages] -Packages= - linux - linux-firmware - systemd - openssh -``` - -mkosi v22+ (Arch ships 25.x) only understands single-line: - -```ini -[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: -1. Reads the profile file -2. Detects the old indented syntax via regex -3. Extracts package names from the indented block -4. Rewrites the `Packages=` line to single-line space-separated form -5. 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** (`warnings` array): `"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 old - `Packages=\n linux\n vim\n` is rewritten to `Packages=linux vim` -- `test_migrate_noop_on_modern_syntax` — verifies already-modern files - are left unchanged -- `test_migrate_noop_on_no_packages_section` — verifies files without - `[Packages]` are left unchanged -- `test_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: - -1. **Old v0.0.x profiles have no `OutputDirectory=`.** The operator's - `arch-workstation` profile was created by v0.0.50 — it lives at - `/etc/mkosi/mkosi.conf.d/arch-workstation.conf` and has no output - directory setting. mkosi defaulted to writing `image.raw` into - the cwd (`/etc/mkosi/mkosi.conf.d/`), a system config directory - owned by root. -2. **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: - -```python -cmd = ["mkosi", "build"] -``` - -It is now: - -```python -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//.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// 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//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//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 - rejects `output_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 - -1. `sudo make uninstall && sudo make install && sudo systemctl restart cockpit.socket` -2. Delete the old image.raw that mkosi created in the wrong place: - `sudo rm /etc/mkosi/mkosi.conf.d/image.raw` -3. 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` -4. 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: -1. The file is `image.raw`, not `myimage.raw` (the template said - `Output=myimage.raw`) -2. 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/.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//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: - -```ini -[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=append` write 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//` 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/` 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 to `pacman -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=` — 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_packages` dispatch (pacman path + dedup) -- `--packages` override end-to-end (writes the file) -- `--dry-run` doesn'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/.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: -1. Move their profiles from `/etc/mkosi/mkosi.conf.d/.conf` to - `/etc/mkosi/profiles//mkosi.conf`, or -2. 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()`: - -```python -safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile) -``` - -`re` wasn't imported at module level. The module-level imports were: - -```python -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: - -1. **`python3 -m py_compile`** (the `make check` syntax check) only - catches *syntax* errors. A `NameError` at 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. - -2. **Unit tests.** The existing unit tests covered `profile_create`, - `profile_copy`, `profile_delete`, and the v0.0.49 package-writing - helpers. None of them call `build()`, and `build()` is the only - caller of `_new_build_id`. So the function that held the bug was - never exercised by any test. - -3. **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: - -```python -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_id` format**: asserts the build_id matches - `^-(\d{14})$`. -- **`_new_build_id` sanitizes unsafe chars**: profile name - `myarch.v2` → `myarch_v2-` (dot replaced with `_`). -- **`_new_build_id` preserves safe chars**: profile name - `my-arch_profile` → `my-arch_profile-` (hyphens + underscores - kept). -- **`_new_build_id_re_imported_at_module_level`**: explicit - `assertIn("re", dir(builder))`. This is the regression guard — if - anyone ever removes the `import re` line 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 mocked `subprocess.run` - (rc=0). Patches `BUILDER_STATE_DIR` / `BUILDER_LOGS_DIR` / - `BUILDER_ARTIFACTS_DIR` to tempdirs, patches `BACKENDS` to fake a - mkosi install, patches `profiles()` 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.run` was 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**: mocked `subprocess.run` returns - 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 by `for ... in` - loops. -- **Except-clause targets** (`exc`) — bound by `except ... 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.** `build` is 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 `