A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.

This commit is contained in:
Jeremy Anderson 2026-09-10 20:41:25 -04:00
commit 490bb6fc36
314 changed files with 82893 additions and 0 deletions

2755
BLOG.md Executable file

File diff suppressed because it is too large Load Diff

51
LICENSE Executable file
View File

@ -0,0 +1,51 @@
MIT License
Copyright (c) 2026 Jeremy Anderson <info@dcos.net> · https://dcos.net
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
────────────────────────────────────────────────────────────────────────────
SysDeck is a drop-in plugin for the Cockpit web UI. It is distributed
under the MIT license so that it remains compatible with the widest
range of host distributions and integration points. The MIT license
is permissive: it permits commercial use, modification, and
re-distribution, and it imposes no copyleft obligation on
distributors who bundle SysDeck with their own software.
Third-party attributions and license summaries are documented in
[THIRD_PARTY.md](./THIRD_PARTY.md). The SysDeck codebase invokes
the following independently-licensed programs as separate processes
via cockpit.spawn / subprocess — these programs retain their own
licenses and are not bundled with SysDeck:
- cockpit-bridge (LGPL-2.1+, https://cockpit-project.org/)
- nftables / nft (GPL-2.0+, https://www.nftables.org/)
- pacman / apt / dnf (GPL-2.0+)
- mkosi / vmdb2 / archiso / live-build (LGPL-2.1+ / GPL-2.0+)
- bpftool (GPL-2.0+)
- setcap / getcap (libcap, GPL-2.0+ / BSD-dual)
- aa-status / aa-enforce / aa-complain (AppArmor userspace, GPL-2.0+)
- smackload / tomoyo-* (Smack / TOMOYO userspace, GPL-2.0+)
- systemctl / journalctl (systemd, LGPL-2.1+)
- getfacl / setfacl (acl, GPL-2.0+)
- ip / lsns / mount / mkdir (iproute2 / util-linux / util-linux)
Author: Jeremy Anderson · <info@dcos.net> · https://dcos.net
Project home: https://dcos.net

423
Makefile Executable file
View File

@ -0,0 +1,423 @@
# SysDeck - Makefile
# Author: Jeremy Anderson (https://dcos.net)
#
# v0.2.0 MASTER EDITION: two distributions in one tree —
# / the cockpit edition: 26 standalone Cockpit plugins + shared bridge
# /web the SysDeck Web Edition (Next.js console, 28 bridge modules)
# /web/mini-services/fester — Fester, vendored + pre-integrated (own version 0.2.1)
# Each plugin ships to /usr/share/cockpit/sysdeck-<name>/ and appears as
# its own sidebar entry in Cockpit. The Python bridge helpers stay at
# /usr/lib/sysdeck/bridge/ (called via cockpit.spawn).
#
# Targets:
# make install - install all 26 plugins + bridge + scripts + firewall templates
# make uninstall - remove all 26 plugins + bridge + scripts
# make check - validate all manifests against cockpit-podman pattern,
# syntax-check JS/Python/shell sources, run unit tests
# make plugins - regenerate plugins/ and shared/ from scripts/generate-plugins.py
# make clean - remove build artifacts
# make dist - build the source tarball (runs check first)
# make distcheck - extract the tarball into a clean dir and run check inside
# make fester-start - run the vendored fester service on :3010 (bun)
# make web-install - install + migrate the web edition (bun + prisma)
# make web-dev - start fester (background) + the web console on :3000
# make master - build the master tarball (cockpit + web + fester)
#
# Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
PACKAGE := sysdeck
VERSION := 0.2.0
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
PYTHON_DIR := $(LIB_DIR)/bridge
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
# v0.0.31: firewall templates (pre-built nftables rulesets the operator
# selects from the panel — applied via the org.sysdeck.firewall.modify
# polkit action). Two are shipped; operators can drop more in.
FIREWALL_TEMPLATES_DIR := $(SHARE_DIR)/firewall/templates
# Python bridge helpers (called via `python3 -m sysdeck.bridge.<module>`).
PY_FILES := $(wildcard bridge/*.py bridge/modules/*.py)
# Unit tests.
TEST_FILES := $(wildcard tests/*.py)
# Documentation.
DOC_FILES := $(wildcard docs/*.md)
# AppStream metainfo + PolKit policy.
METAINFO_FILE := packaging/sysdeck.metainfo.xml
POLKIT_FILE := packaging/polkit/org.sysdeck.policy
# Diagnostic + smoke-test scripts.
DIAGNOSE_SCRIPT := sysdeck-diagnose.sh
SMOKE_TEST_SCRIPT := cockpit-smoke-test.sh
# Generator script (regenerates plugins/ and shared/).
GENERATOR := scripts/generate-plugins.py
.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master
# ─── plugins: regenerate from generator ──────────────────────────────
plugins:
@echo ">>> Regenerating plugins/ and shared/ from $(GENERATOR)"
python3 $(GENERATOR)
# ─── install: 26 visible plugins + shared/ + bridge + scripts + metainfo ────
install:
@echo ">>> Installing $(PACKAGE) $(VERSION): 26 standalone Cockpit plugins"
# Each plugin: /usr/share/cockpit/sysdeck-<name>/{manifest.json,index.html,<module>.js}
@for plugin in plugins/sysdeck-*; do \
[ -d "$$plugin" ] || continue; \
name=$$(basename "$$plugin"); \
destdir=$(DESTDIR)/usr/share/cockpit/$$name; \
install -d "$$destdir"; \
for f in $$plugin/manifest.json $$plugin/index.html $$plugin/*.js; do \
[ -f "$$f" ] || continue; \
install -m 0644 "$$f" "$$destdir/$$(basename $$f)"; \
done; \
echo " installed $$name"; \
done
# Shared bridge + CSS + manifest: /usr/share/cockpit/sysdeck-common/
# The manifest.json is REQUIRED — without it, cockpit doesn't register
# sysdeck-common as a package, and every URL like
# /cockpit/@localhost/sysdeck-common/bridge.js returns 404.
# Verified from cockpit's pkg/static/manifest.json (just `{}`).
install -d $(DESTDIR)/usr/share/cockpit/sysdeck-common
install -m 0644 shared/manifest.json $(DESTDIR)/usr/share/cockpit/sysdeck-common/manifest.json
install -m 0644 shared/bridge.js $(DESTDIR)/usr/share/cockpit/sysdeck-common/bridge.js
install -m 0644 shared/sysdeck.css $(DESTDIR)/usr/share/cockpit/sysdeck-common/sysdeck.css
# Python bridge helpers: /usr/lib/sysdeck/bridge/
# v0.0.27: install each helper as an executable script (0755, not 0644)
# so they can be invoked by absolute path:
# python3 /usr/lib/sysdeck/bridge/glances.py snapshot
# No PYTHONPATH or symlink needed — the JS calls them directly.
# Each helper has a `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` guard.
install -d $(PYTHON_DIR)
@for f in $(PY_FILES); do \
rel=$$(echo $$f | sed 's|^bridge/||'); \
install -d $$(dirname $(PYTHON_DIR)/$$rel); \
install -m 0755 $$f $(PYTHON_DIR)/$$rel; \
done
# Unit tests: /usr/lib/sysdeck/tests/
install -d $(LIB_DIR)/tests
@for f in $(TEST_FILES); do \
rel=$$(echo $$f | sed 's|^tests/||'); \
install -m 0644 $$f $(LIB_DIR)/tests/$$rel; \
done
# Documentation: /usr/share/doc/sysdeck/
install -d $(DESTDIR)/usr/share/doc/$(PACKAGE)
@for f in $(DOC_FILES); do \
install -m 0644 $$f $(DESTDIR)/usr/share/doc/$(PACKAGE)/$$(basename $$f); \
done
install -m 0644 README.md $(DESTDIR)/usr/share/doc/$(PACKAGE)/README.md
install -m 0644 QUICKSTART.md $(DESTDIR)/usr/share/doc/$(PACKAGE)/QUICKSTART.md
install -m 0644 LICENSE $(DESTDIR)/usr/share/doc/$(PACKAGE)/LICENSE
# Diagnostic + smoke-test scripts: /usr/share/sysdeck/
install -d $(DESTDIR)/usr/share/$(PACKAGE)
install -m 0755 $(DIAGNOSE_SCRIPT) $(DESTDIR)/usr/share/$(PACKAGE)/$(DIAGNOSE_SCRIPT)
install -m 0755 $(SMOKE_TEST_SCRIPT) $(DESTDIR)/usr/share/$(PACKAGE)/$(SMOKE_TEST_SCRIPT)
# v0.0.31: firewall templates — pre-built nftables rulesets the
# operator selects from the Firewall panel. The bridge firewall.py
# `templates` and `apply-template` subcommands read this directory.
# Templates are executable (0755) because they are invoked by the
# bridge via `bash <template>.sh start|stop|detect|...` under the
# org.sysdeck.firewall.modify polkit action.
# v0.0.36: also install firewall/policies/*.yaml — the Cilium
# default policy file (cilium-default.yaml) lives here and is
# applied by cilium.sh `start`. Operator overrides go in
# /etc/sysdeck/firewall/ (not shipped by the package).
install -d $(FIREWALL_TEMPLATES_DIR)
@for f in firewall/templates/*.sh; do \
[ -f "$$f" ] || continue; \
install -m 0755 $$f $(FIREWALL_TEMPLATES_DIR)/$$(basename $$f); \
echo " installed firewall template: $$(basename $$f)"; \
done
install -d $(SHARE_DIR)/firewall/policies
@for f in firewall/policies/*.yaml; do \
[ -f "$$f" ] || continue; \
install -m 0644 $$f $(SHARE_DIR)/firewall/policies/$$(basename $$f); \
echo " installed firewall policy: $$(basename $$f)"; \
done
# v0.0.43: Prometheus + Grafana config files — scrape configs,
# alert rules, datasource + dashboard provisioning. Operators
# include these in their prometheus.yml / grafana provisioning
# dirs. Shipped read-only at /usr/share/sysdeck/prometheus/.
install -d $(SHARE_DIR)/prometheus
@for f in prometheus/*.yml; do \
[ -f "$$f" ] || continue; \
install -m 0644 $$f $(SHARE_DIR)/prometheus/$$(basename $$f); \
echo " installed monitoring config: $$(basename $$f)"; \
done
# AppStream metainfo: /usr/share/metainfo/
install -d $(DESTDIR)/usr/share/metainfo
install -m 0644 $(METAINFO_FILE) $(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml
# PolKit policy: /usr/share/polkit-1/actions/
install -d $(DESTDIR)/usr/share/polkit-1/actions
install -m 0644 $(POLKIT_FILE) $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy
# v0.0.46: 3rd-party-modules polkit action (org.sysdeck.modules3p.modify)
install -m 0644 packaging/polkit/org.sysdeck.modules3p.policy $(DESTDIR)/usr/share/polkit-1/actions/
# Reload polkit + refresh AppStream cache.
-@if command -v systemctl >/dev/null 2>&1; then \
systemctl reload polkit 2>/dev/null || true; \
fi
-@if command -v appstreamcli >/dev/null 2>&1; then \
appstreamcli refresh-cache 2>/dev/null || true; \
fi
@echo ">>> Done. Restart cockpit.socket to pick up the new plugins:"
@echo " sudo systemctl restart cockpit.socket"
@echo ">>> 26 sidebar entries should appear under 'SysDeck <Name>' in Cockpit."
# ─── uninstall: remove EVERY trace of EVERY prior version ──────────
# This target is deliberately over-aggressive. It removes:
# - /usr/share/cockpit/sysdeck/ (v0.0.9-v0.0.19 single-plugin layout)
# - /usr/share/cockpit/sysdeck-*/ (v0.0.20+ multi-plugin layout, including sysdeck-common)
# - /usr/lib/sysdeck/ (Python bridge helpers, all versions)
# - /usr/share/sysdeck/ (diagnostic scripts, v0.0.19+)
# - /usr/share/doc/sysdeck/ (docs, v0.0.20+)
# - /usr/share/metainfo/sysdeck.metainfo.xml (AppStream metainfo, v0.0.17+)
# - /usr/share/polkit-1/actions/org.sysdeck.policy (PolKit policy, v0.0.17+)
# - python site-packages sysdeck symlink (all versions)
# - the pacman-installed sysdeck package (if installed via PKGBUILD)
# Without this thorough cleanup, cockpit would discover stale manifests from
# prior versions and serve old broken code instead of the freshly-installed
# new code.
uninstall:
@echo ">>> Removing EVERY trace of $(PACKAGE) (all prior versions)"
# Try to remove pacman-tracked package first (if installed that way)
-@if command -v pacman >/dev/null 2>&1; then \
if pacman -Q sysdeck >/dev/null 2>&1; then \
echo " removing pacman package 'sysdeck'"; \
pacman -R --noconfirm sysdeck 2>/dev/null || true; \
fi; \
fi
# Old single-plugin layout (v0.0.9-v0.0.19): /usr/share/cockpit/sysdeck/
@if [ -d "$(DESTDIR)/usr/share/cockpit/sysdeck" ]; then \
echo " removing old single-plugin /usr/share/cockpit/sysdeck/ (v0.0.9-v0.0.19 layout)"; \
rm -rf "$(DESTDIR)/usr/share/cockpit/sysdeck"; \
fi
# New multi-plugin layout (v0.0.20+): /usr/share/cockpit/sysdeck-*
@for dir in $(DESTDIR)/usr/share/cockpit/sysdeck-*; do \
[ -d "$$dir" ] || continue; \
echo " removing $$(basename $$dir)"; \
rm -rf "$$dir"; \
done
# Python bridge helpers (all versions)
rm -rf $(LIB_DIR)
# Diagnostic + smoke-test scripts + firewall templates (v0.0.19+)
# v0.0.31: firewall/templates/*.sh live under here too.
rm -rf $(DESTDIR)/usr/share/$(PACKAGE)
# Documentation (v0.0.20+)
rm -rf $(DESTDIR)/usr/share/doc/$(PACKAGE)
# AppStream metainfo (v0.0.17+)
rm -f $(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml
# PolKit policy (v0.0.17+)
rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy
rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy
# Python site-packages symlink (all versions)
@SITE_PACKAGES=$$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null); \
if [ -n "$$SITE_PACKAGES" ] && [ -L "$(DESTDIR)$$SITE_PACKAGES/$(PACKAGE)" ]; then \
echo " removing python site-packages symlink"; \
rm -f "$(DESTDIR)$$SITE_PACKAGES/$(PACKAGE)"; \
fi
# Reload polkit + refresh AppStream cache
-@if command -v systemctl >/dev/null 2>&1; then \
systemctl reload polkit 2>/dev/null || true; \
fi
-@if command -v appstreamcli >/dev/null 2>&1; then \
appstreamcli refresh-cache 2>/dev/null || true; \
fi
@echo ">>> Done. Verify with: ls /usr/share/cockpit/ | grep sysdeck"
@echo ">>> (should print nothing)"
@echo ">>> Then restart cockpit.socket to flush the sidebar:"
@echo " sudo systemctl restart cockpit.socket"
# ─── check: build-time guards ────────────────────────────────────────
check-metainfo-consistency:
@echo ">>> Checking packaging/sysdeck.metainfo.xml structural consistency"
@python3 tests/check_metainfo_consistency.py
# check-manifest-consistency: validate EVERY plugin's manifest against
# the working cockpit-podman reference pattern. One bad manifest = the
# plugin silently disappears from the sidebar. Catches:
# - non-zero `version` field
# - any top-level field not in cockpit-podman's manifest
# - a `content` section (real plugins don't declare one)
# - menu keys other than `index` (the magic key)
# - `path` field inside menu entries
check-manifest-consistency:
@echo ">>> Checking all 25 plugin manifests against cockpit-podman reference"
@python3 tests/check_manifest_consistency.py
check-makefile-recipes:
@echo ">>> Checking Makefile recipe indentation (must be tabs, not spaces)"
@if awk '/^[ \t]+[@a-zA-Z#]/{if ($$0 !~ /^\t/) exit 1}' Makefile; then \
echo " OK: all recipe lines use tabs"; \
else \
echo "FAIL: Makefile has recipe lines indented with spaces instead of tabs."; \
echo " Run: perl -i -pe 's{^( {8})+}{ \"\\t\" x (length(\$$&)/8) }e' Makefile"; \
exit 1; \
fi
# check-no-broken-cockpit-import: scan every JS file shipped to plugins/ and
# shared/ for `import cockpit from "..."` — that pattern is broken because
# pkg/base1/cockpit.js is a UMD/IIFE that sets window.cockpit as a global,
# NOT an ES module. The broken import returns undefined, cockpit.spawn()
# throws when mount() runs, and the plugin page stays on "Loading…".
# This was the v0.0.27 root cause of "every plugin stuck on Loading".
# Pattern verified from cockpit's build.js:71-83 (esbuild plugin rewrites
# `import cockpit from "cockpit"` to `module.exports = cockpit`).
check-no-broken-cockpit-import:
@echo ">>> Checking no JS file uses broken \`import cockpit from\` pattern"
@hits=$$(grep -rEn '^[[:space:]]*import\s+cockpit\s+from' shared/ plugins/ 2>/dev/null); \
if [ -n "$$hits" ]; then \
echo "FAIL: found 'import cockpit from' in JS files — cockpit.js is NOT an ES module;"; \
echo " use 'const cockpit = window.cockpit' instead. Affected files:"; \
echo "$$hits" | sed 's/^/ /'; \
exit 1; \
fi
echo " OK: no JS file uses the broken import-cockpit pattern"
# check-bridge-subcommands: cross-check every `bridgeCmd("<module>", ["<sub>"] )`
# call in shared/bridge.js against the COMMANDS dict declared in
# bridge/<module>.py. Catches the v0.0.27 bug class where the JS
# bridge surface references a subcommand the Python helper doesn't
# implement — every call to that bridge method returns
# "Unknown subcommand: X" and the plugin page crashes.
# Examples this guard would have caught in v0.0.27:
# - bridge.firmware.devices() calling `python3 firmware.py devices`
# (firmware.py only had `summary`)
# - bridge.benchmark.runTest(name) calling `python3 benchmark.py run-test`
# (benchmark.py had no `run-test` subcommand)
check-bridge-subcommands:
@echo ">>> Cross-checking bridge.js calls vs Python COMMANDS dicts"
@python3 tests/check_bridge_subcommands.py
# check-no-broken-python-module: verify bridge.js does NOT call
# `python3 -m sysdeck.bridge.X` — that pattern requires a Python package
# layout (sysdeck/bridge/X.py) that doesn't exist in our install.
# v0.0.27 bug: every bridge helper call returned ModuleNotFoundError
# "No module named 'sysdeck.bridge'" because the actual layout is
# /usr/lib/sysdeck/bridge/X.py (flat files, not a nested package).
# v0.0.27 fix: call helpers by absolute path:
# python3 /usr/lib/sysdeck/bridge/X.py <args>
check-no-broken-python-module:
@echo ">>> Checking no JS file uses broken \`python3 -m sysdeck.bridge\` pattern"
@hits=$$(grep -rEn '"python3".*"-m".*"sysdeck\.bridge' shared/ plugins/ 2>/dev/null); \
if [ -n "$$hits" ]; then \
echo "FAIL: found 'python3 -m sysdeck.bridge' in JS files — this pattern"; \
echo " requires a Python package layout (sysdeck/bridge/X.py) that doesn't"; \
echo " exist. Use 'python3 /usr/lib/sysdeck/bridge/X.py' (absolute path)."; \
echo " Affected files:"; \
echo "$$hits" | sed 's/^/ /'; \
exit 1; \
fi
echo " OK: no JS file uses the broken python3 -m sysdeck.bridge pattern"
check-version-sync:
@echo ">>> Checking version consistency across release surfaces"
@v=$(VERSION); \
for f in \
bridge/__init__.py \
packaging/setup.py \
packaging/PKGBUILD \
packaging/sysdeck.spec \
packaging/debian/changelog \
compat/compat-manifest.json \
; do \
if ! grep -q "$$v" "$$f" 2>/dev/null; then \
echo "FAIL: $$f does not reference version $$v"; \
exit 1; \
fi; \
done; \
echo " OK: all release surfaces report v$$v"
check: check-metainfo-consistency check-manifest-consistency check-makefile-recipes check-no-broken-cockpit-import check-no-broken-python-module check-bridge-subcommands check-version-sync
@echo ">>> Syntax-checking Python sources"
@python3 -m py_compile bridge/*.py bridge/modules/*.py
@echo ">>> Syntax-checking JS sources (node --check)"
@for f in shared/bridge.js plugins/*/*.js; do \
node --check $$f || exit 1; \
done
@echo ">>> Validating all manifest.json files"
@for f in plugins/*/manifest.json; do \
python3 -c "import json; json.load(open('$$f'))" || { echo "FAIL: $$f is not valid JSON"; exit 1; }; \
done
@echo ">>> Syntax-checking shell scripts"
@for f in $(DIAGNOSE_SCRIPT) $(SMOKE_TEST_SCRIPT); do \
bash -n $$f || { echo "FAIL: $$f has syntax errors"; exit 1; }; \
done
@echo ">>> Running bridge parser unit tests"
@PYTHONPATH=bridge python3 -m unittest tests/test_bridge_parsers.py -v
@echo ">>> All checks passed."
clean:
rm -rf build dist *.tar.bz2
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
dist: check
@echo ">>> Building $(PACKAGE)-$(VERSION).tar.bz2"
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \
--exclude='__pycache__' \
--exclude='*.pyc' \
--exclude='*.tar.bz2' \
--transform 's,^,$(PACKAGE)-$(VERSION)/,' \
plugins shared bridge tests packaging compat standalone-plugins \
prometheus scripts firewall \
Makefile README.md QUICKSTART.md BLOG.md LICENSE QA.md worklog.md THIRD_PARTY.md docs \
sysdeck-diagnose.sh cockpit-smoke-test.sh
@echo ">>> $(PACKAGE)-$(VERSION).tar.bz2 ready"
# distcheck: verify the tarball extracts into <package>-<version>/ and
# passes `make check` from inside the extracted tree.
distcheck: dist
@echo ">>> Distcheck: extracting $(PACKAGE)-$(VERSION).tar.bz2"
rm -rf /tmp/sysdeck-distcheck-$$
mkdir -p /tmp/sysdeck-distcheck-$$
tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C /tmp/sysdeck-distcheck-$$
@if [ ! -d /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) ]; then \
echo "FAIL: tarball did not extract into $(PACKAGE)-$(VERSION)/"; \
rm -rf /tmp/sysdeck-distcheck-$$; \
exit 1; \
fi
@echo ">>> Distcheck: running make check inside extracted tree"
@(cd /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) && make check)
@rm -rf /tmp/sysdeck-distcheck-$$
@echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct."
# ─── v0.2.0 master edition: web + fester ─────────────────────────────
# Run these from an extracted master tarball (where web/ sits alongside
# this Makefile) or the canonical dev tree with web/ present.
FESTER_DIR := web/mini-services/fester
FESTER_URL ?= http://127.0.0.1:3010
export FESTER_URL
fester-start:
@echo ">>> Starting the vendored fester service on :3010 (Ctrl+C stops it)"
cd $(FESTER_DIR) && bun install && bun run dev
web-install:
@echo ">>> Installing the SysDeck Web Edition (bun + prisma)"
cd web && bun install && bun run db:push
cd $(FESTER_DIR) && bun install
web-dev: web-install
@echo ">>> Starting fester in the background (log: /tmp/fester.log)"
cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 &
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
cd web && bun run dev
# master: rebuild the master tarball from this tree (cockpit + web + fester)
master:
@echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester)"
tar cjf $(PACKAGE)-$(VERSION)-master.tar.bz2 \
--exclude='__pycache__' --exclude='*.pyc' --exclude='*.tar.bz2' \
--exclude='*node_modules*' --exclude='*.next' \
--exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \
--transform 's,^,$(PACKAGE)-$(VERSION)-,' \
bridge plugins shared tests packaging compat standalone-plugins \
prometheus scripts firewall docs web \
Makefile README.md QUICKSTART.md BLOG.md LICENSE QA.md THIRD_PARTY.md \
sysdeck-diagnose.sh cockpit-smoke-test.sh
@echo ">>> $(PACKAGE)-$(VERSION)-master.tar.bz2 ready"

1074
QA.md Executable file

File diff suppressed because it is too large Load Diff

161
QUICKSTART.md Executable file
View File

@ -0,0 +1,161 @@
# SysDeck — Quick Start
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
Version: **0.2.0** (Master Edition)
Five-minute path from tarball to 26 sidebar entries in your Cockpit — plus the Web Edition with Fester pre-integrated (section 9).
---
## 1. Prerequisites
| Component | Why | Install |
|-----------|-----|---------|
| `cockpit-bridge` ≥ 239 | The plugin runtime | `pacman -S cockpit` / `apt install cockpit` / `dnf install cockpit` |
| `python3` ≥ 3.9 | Bridge helpers | Universal on modern Linux |
| `polkit` | Privilege escalation (the cockpit way) | `pacman -S polkit` / `apt install policykit-1` / `dnf install polkit` |
| `appstream` (optional) | Cockpit Applications menu | `pacman -S appstream` / `apt install appstream` |
Cockpit itself ships its own `cockpit-bridge` package — that is the only hard dependency. SysDeck degrades gracefully when optional backends (podman, nftables, mkosi, bpftool, apparmor, …) are absent — each panel renders an install hint instead of crashing.
## 2. Install
```bash
# Get the tarball
ls sysdeck-0.0.35.tar.bz2 # download from your release source
# Extract and install
tar xjf sysdeck-0.0.35.tar.bz2
cd sysdeck-0.0.35
sudo make install
# Restart cockpit so it re-scans the plugin directory
sudo systemctl restart cockpit.socket
```
`make install` does:
- Copies each `plugins/sysdeck-*/{manifest.json,index.html,*.js}` to `/usr/share/cockpit/sysdeck-*/`
- Copies `shared/{manifest.json,bridge.js,sysdeck.css}` to `/usr/share/cockpit/sysdeck-common/`
- Copies each `bridge/*.py` (executable, 0755) to `/usr/lib/sysdeck/bridge/`
- Copies `firewall/templates/*.sh` (executable, 0755) to `/usr/share/sysdeck/firewall/templates/`
- Installs the AppStream metainfo at `/usr/share/metainfo/sysdeck.metainfo.xml`
- Installs the polkit policy at `/usr/share/polkit-1/actions/org.sysdeck.policy`
- Reloads polkit and refreshes the AppStream cache
## 3. Verify the install
Open `https://<host>:9090` in your browser and authenticate as a wheel/sudo user. The Cockpit sidebar should now list **23** entries under the `SysDeck <Name>` prefix:
| # | Module | # | Module |
|---|--------|---|--------|
| 1 | SysDeck Containers | 13 | SysDeck Themes |
| 2 | SysDeck Firewall | 14 | SysDeck Hardware Auth |
| 3 | SysDeck Integrity | 15 | SysDeck Glances |
| 4 | SysDeck Network Security | 16 | SysDeck Sensors |
| 5 | SysDeck Service Mesh | 17 | SysDeck Benchmark |
| 6 | SysDeck Vault | 18 | SysDeck Packages |
| 7 | SysDeck Fleet | 19 | SysDeck Policy |
| 8 | SysDeck Kata | 20 | SysDeck Databases |
| 9 | SysDeck Fester | 21 | SysDeck Jellyfin |
| 10 | SysDeck Firmware | 22 | SysDeck Photos |
| 11 | SysDeck Image Builder | 23 | SysDeck Remote FS |
| 12 | SysDeck Mining | | |
If any are missing, run the diagnostic:
```bash
sudo /usr/share/sysdeck/sysdeck-diagnose.sh
```
It prints exactly what cockpit sees on your system — installed manifests, bridge helpers present, polkit actions loaded, and the cockpit-bridge version.
## 4. First-use walkthrough
### 4a. Firewall (the cockpit way)
Open **SysDeck Firewall**. The panel renders:
1. **Capability matrix** — confirms nftables is installed.
2. **Template selector** — pick `vps-webserver` (service-aware firewall that auto-detects SSH/Caddy/Varnish/Forgejo) or `no-services` (locked-down host with no public services except SSH).
3. **Detect Services** — runs the template's `detect` action and shows the OS, interface, IPv4/IPv6, and which services the template found.
4. **Apply Template** — cockpit prompts for the superuser password via polkit. The bridge runs the template's `start` action under the `org.sysdeck.firewall.modify` action.
5. **Banned IPs** — live `ssh_abuse` / `port_scanners` / `connlimit_abuse` ban sets, with per-IP **Unban** buttons and a **Clear All** button.
6. **Active Ruleset** — the live nftables rules table, refreshed after each operation.
To drop in your own template, copy a `*.sh` file into `/usr/share/sysdeck/firewall/templates/` — the panel's `templates` subcommand discovers it automatically. The script must implement `start / stop / restart / detect / status` subcommands (see the shipped templates for reference).
### 4b. Packages (the cockpit way)
Open **SysDeck Packages**. Click **⬆ Update All**. Cockpit prompts for the superuser password via polkit. The bridge runs `pacman -Syu` / `apt upgrade -y` / `dnf upgrade -y` directly via subprocess — no `sudo` shell-out from JS. Live stdout/stderr stream into the in-panel `<pre>` log. The **👁 Preview Command** button shows the exact command that will be run before you confirm.
### 4c. Policy & Permissions
Open **SysDeck Policy**. The panel renders:
1. **LSM Stack** — a badge row in the header showing which LSMs the kernel has stacked (`/sys/kernel/security/lsm`), and a table of all 9 supported LSMs with their securityfs paths and active/inactive status.
2. **Capability Matrix** — confirms availability of ACLs, cgroups v2, VLANs, eBPF, namespaces, file caps, and each LSM. Absent concerns show a red "no" badge plus the install command.
3. **ACL Manager** — pick a path, type an entry like `group:www-data:rwx`, click `setfacl -m` (or `setfacl -x` to remove, `set default ACL` for directory inheritance).
4. **cgroups v2** — the unified hierarchy tree under `/sys/fs/cgroup/` with per-cgroup process counts and controllers. Use **Show** to inspect one cgroup's processes and control files; **mkdir** to create a new one; **move** to migrate a PID; **write** to set `memory.max`, `cpu.weight`, etc.
5. **VLANs** — list and create/delete 802.1Q VLANs via `ip link add ... type vlan id <vid>`.
6. **eBPF programs** — list loaded BPF programs via `bpftool prog show -j`, list maps, pin a program to `/sys/fs/bpf/...`.
7. **Namespaces** — `lsns -J` output as a table.
8. **File Capabilities** — `getcap -r /` enumeration with `setcap` / `getcap` / `setcap -r` controls.
9. **AppArmor** (optional) — if the kernel compiled AppArmor in, shows the enforcement mode and lets you switch profiles between `enforce` and `complain` modes. If absent, renders an install hint.
10. **Smack / TOMOYO / Yama / LoadPin / Lockdown / BPF-LSM / Landlock** — each has its own card with the live state and any management controls the LSM supports. Each card follows the same shape: if the LSM is not active, the card shows the kernel cmdline that enables it; if active, it shows the live state.
### 4d. Databases
Open **SysDeck Databases**. The panel auto-detects 32+ engines across SQL (PostgreSQL/MySQL/MariaDB/SQLite/CockroachDB/TiDB), NoSQL (MongoDB/CouchDB/RethinkDB/DynamoDB-local), Vector (Milvus/Qdrant/Weaviate/Chroma/pgvector), TimeSeries (InfluxDB/TimescaleDB/QuestDB/ClickHouse), Graph (Neo4j/ArangoDB/OrientDB), Embedded (Redis/KeyDB/ValKey/RocksDB/LMDB/BadgerDB), Cloud (Firestore-emulator/Supabase-local), and AI (LanceDB/DuckDB/Tile38). Each row has **▶ Start / ■ Stop / ↻ Restart / 🔍 Status** buttons. The **Run SQL Query** card lets you execute arbitrary SQL against SQL-family engines via the engine's CLI client (`psql -tAc`, `mysql -e`, etc.).
## 5. Build from source
```bash
cd sysdeck-0.0.35
make check # 7 build-time guards: manifests, metainfo, tabs, no-broken-import, no-broken-module, bridge-subcommands cross-check, version sync
make dist # builds sysdeck-0.0.35.tar.bz2
make distcheck # extracts + runs make check inside the tarball tree
```
`make check` is a hard pre-flight: it cross-checks every `bridgeCmd("<module>", ["<sub>", ...])` call in `shared/bridge.js` against the `COMMANDS` dict declared in each `bridge/<module>.py`. If the JS calls a subcommand the Python helper doesn't implement, `make check` fails with a clear message naming the file, line, and missing subcommand.
## 6. Uninstall
```bash
sudo make uninstall
sudo systemctl restart cockpit.socket
```
`make uninstall` removes every trace of every prior version (the v0.0.9-v0.0.19 single-plugin `/usr/share/cockpit/sysdeck/` directory, the v0.0.20+ multi-plugin `/usr/share/cockpit/sysdeck-*/` directories, the Python bridge helpers, the diagnostic scripts, the firewall templates, the AppStream metainfo, the polkit policy, and any pacman-installed `sysdeck` package).
## 7. Where to go next
- [README.md](./README.md) — full module catalog, architecture, coding standards.
- [BLOG.md](./BLOG.md) — release narrative for v0.0.33 and prior versions.
- [docs/INSTALL.md](./docs/INSTALL.md) — RPM, DEB, pip, and manual install paths.
- [QA.md](./QA.md) — QA notes per release.
- [worklog.md](./worklog.md) — per-task development log.
## 8. Reporting issues
Open the in-panel error view: every SysDeck plugin's `index.html` installs `window.addEventListener('error')` and `'unhandledrejection'` handlers that replace the "Loading…" placeholder with the actual error message on the page — no devtools required. The same page tells you whether `cockpit.js` itself loaded, whether `bridge.js` imported cleanly, and whether the panel's `mount()` threw.
## 9. The Web Edition (master tarball, v0.2.0)
The master tarball also ships the **SysDeck Web Edition** at `web/` — a standalone browser console (no cockpit required) with 28 bridge modules, real `/proc` / `/sys` collectors, and **Fester pre-integrated** (vendored at `web/mini-services/fester`, independent version 0.2.1):
```bash
make web-dev # fester service in the background (:3010) + web console (:3000)
```
Manual equivalent:
```bash
make fester-start # terminal 1: fester on :3010
cd web && bun install && bun run db:push # terminal 2: web edition setup
bun run dev # web console on :3000
```
Open `http://localhost:3000`. The master tarball can be rebuilt any time with `make master`.
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>

439
README.md Executable file
View File

@ -0,0 +1,439 @@
# SysDeck
**A drop-in plugin for an existing Cockpit install — twenty-six domain modules behind one dashboard.**
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net>
Version: **0.2.0** (Master Edition) · License: **MIT**
---
## What this is
SysDeck is a cockpit-native plugin that consolidates the day-to-day work of a Linux operations team — containers, firewall, integrity auditing, network security, service mesh, encryption vaults, fleet compute, Kata Containers, firmware, image building, mining, theme engine, hardware authentication, DAG-driven build orchestration, system monitoring, hardware sensors, system benchmarking, package management, policy & permissions, database control, Jellyfin media server, photo manager (PhotoPrism/Piwigo/Lychee/Nextcloud-Memories/LibrePhotos), remote filesystem manager (Ceph/GlusterFS/MooseFS/BeeGFS/OrangeFS), a 3rd-party Cockpit module installer (45Drives Navigator/File-Sharing/ZFS-Manager, cockpit-pacman, cockpit-identities, cockpit-sensors, cockpit-benchmark — each pulled on demand with the license, developer, source URL, and homepage shown inline next to a 1-click Install button), and a service/port editor (a first-class sidebar entry that enumerates every listening TCP socket, cross-references against a SERVICES_REGISTRY of 9 known services — ssh, cockpit, caddy, varnish, mariadb, ollama, openwebui, hermes, odysseus — and lets the operator edit the port in each service's config file with an atomic write + systemctl restart) — into a single dashboard accessible from the cockpit web UI.
The plugin ships as static HTML+JS+CSS plus a Python bridge helper package. It installs under `/usr/share/cockpit/sysdeck-*/` and is discovered automatically by the cockpit-bridge. No separate web server, no Node.js runtime, no database — the plugin runs inside the cockpit web service.
### v0.2.0 highlights (Master Edition)
v0.2.0 ships as a **master tarball — `sysdeck-0.2.0-master.tar.bz2`** — bundling the cockpit edition (this tree), the new **SysDeck Web Edition** (`web/` — a standalone Next.js console with 28 bridge modules, an Overview landing view, and the previously-orphaned Hardware Alerts panel), and **Fester pre-integrated**.
**Fester** remains its own project upstream (independent repository, independent version line — currently 0.2.1). The master tarball vendors a pinned snapshot at `web/mini-services/fester` so nothing else needs cloning:
- **cockpit side** — `bridge/fester.py` is now a real REST client of the fester service (`FESTER_URL`, default `http://127.0.0.1:3010`; 11 subcommands: status, metrics, builds, build, nodes, targets, timeline, sessions, start-build, cancel, replay) and `plugins/sysdeck-fester/` is a full panel — the v0.0.31 systemd-listing stub is gone.
- **web side** — a dedicated Fester sub-app (live DAG, replay sessions, timeline, failure autopsy, cause graph, interactive debugger, metrics) wired via the `/api/fester` proxy and a WebSocket event stream.
- **fixed** — the shipped 0.1.3 Makefile had space-indented recipes (GNU make rejected it with `missing separator`); v0.2.0 restores tabs, and every target parses.
Quick start (web edition, from an extracted master tarball):
make web-dev # fester service (background, :3010) + web console (:3000)
Or step by step: `make fester-start`, then `cd web && bun install && bun run db:push && bun run dev`. Rebuild the master tarball with `make master`. See `web/README.md` for details.
### v0.1.3 highlights
v0.1.3 fixes two critical bugs and adds the download/manage UI for builds. The operator reported: *"profile workstation still doesnt import current system pkgs. it trys to build only 2."* Two root causes were identified and fixed.
- **Import bug — `from __init__ import` failed silently.** `_detect_host_packages()` relied on `from __init__ import PKG_MANAGER` which silently failed in the cockpit superuser channel context (different Python path). `PKG_MANAGER` defaulted to `"unknown"`, the host query returned an EMPTY list, and the import wrote nothing. The operator saw "tries to build only 2" because the build used the profile's original template packages.
- **Import fix — `shutil.which()`.** `_detect_host_packages()` now uses `shutil.which()` to find `pacman`/`apt-mark`/`dnf` directly — no import dependency, works in any execution context.
- **Build bug — `--include` doesn't load the config.** v0.1.2's `--include` flag includes a drop-in fragment ON TOP OF the base `mkosi.conf` — it does NOT replace the base config. If there's no `mkosi.conf` in the cwd, mkosi uses defaults and ignores the `--include` file entirely. This is why v0.1.2 still produced builds with only 2 packages.
- **Build fix — temp work dir with symlink.** `build()` now creates a temp directory, symlinks the profile file into it as `mkosi.conf`, and sets `work_dir` to that temp dir. mkosi finds `mkosi.conf` (the symlink), follows it, reads the actual profile. Works for ANY profile path regardless of filename or location. Temp dir is cleaned up after the build finishes. New helper: `_prepare_mkosi_work_dir()`.
- **New: artifact download.** Each artifact in the Artifacts panel now has a ⬇ Download button. Reads the file via `cockpit.spawn(["cat", path])` with superuser, creates a Blob, triggers browser download.
- **New: artifact management.** Each artifact has a 🗑 delete button (per-file). Each profile's artifacts card has a 🗑 Clear all button that removes ALL artifacts for that profile (shows file count + bytes freed).
- **New: build management.** Each build in the Builds table has a 🗑 delete button. Two-step confirm: (1) delete state + log only, or (2) also delete the profile's entire artifacts dir.
- **Regression tests.** 11 new unit tests in `TestBuilderArtifactManagement` (7 tests) and `TestBuilderMkosiTempWorkDir` (3 tests). Existing tests updated for the new `shutil.which` approach and removal of `--include`. Total: 254 tests (was 243; +11).
- **Version sync.** Bumped 0.1.2 → 0.1.3 across all 9 release surfaces.
### v0.1.2 highlights
v0.1.2 fixes the critical "zero packages" bug. An operator reported: *"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 fixed.
- **Root cause 1 — mkosi never read the profile config.** `_backend_build_command()` for mkosi was `["mkosi", "build", "--output", ..., "--output-dir", ...]` with NO flag telling mkosi WHERE the profile config file is. mkosi only reads a file literally named `mkosi.conf` from the cwd. For v0.0.x profiles at `/etc/mkosi/mkosi.conf.d/<name>.conf`, mkosi ran in that dir, found no `mkosi.conf` (the file is named `<name>.conf`), and used EMPTY defaults — zero packages, default distro, default everything. The operator's `Packages=` setting was never seen by mkosi.
- **Fix 1 — `--include <profile_path>`.** `_backend_build_command()` now ALWAYS passes `--include <profile_path>` on the CLI. This tells mkosi to explicitly load the profile config by path, regardless of its filename or location. CLI `--include` overrides the default `mkosi.conf` discovery.
- **Root cause 2 — legacy `Packages=` syntax.** Profiles created by v0.0.x used the old indented `Packages=` syntax (`Packages=\n linux\n linux-firmware\n...`). mkosi v22+ (Arch ships 25.x) only understands single-line (`Packages=linux linux-firmware ...`). The old form is silently parsed as a single package name with embedded newlines, which doesn't exist in any repo — so mkosi installs NOTHING.
- **Fix 2 — auto-migration.** New `_migrate_legacy_mkosi_packages()` function detects the old indented syntax and rewrites it to single-line IN-PLACE before the build command is constructed. `build()` calls this automatically on every mkosi build. The migration is logged in both the build state JSON (`warnings` array) and the log file header (`# MIGRATED: ...`). If the file already uses modern syntax, the migration is a no-op.
- **Regression tests.** 4 new unit tests in `TestBuilderBuildPath` cover migration (old syntax rewrite, modern no-op, no-section no-op, end-to-end during build). The existing `test_build_success_path` was extended to verify `--include` is on the command line and points at the profile file.
- **Version sync.** Bumped 0.1.1 → 0.1.2 across all 9 release surfaces. Total unit tests now 243 (was 239 in v0.1.1; +4).
### v0.1.1 highlights
v0.1.1 fixes a critical output-path safety bug. An operator reported: *"this is NOT a safe output path. fix this now."* The v0.1.0 release relied on `OutputDirectory=` in the scaffolded `mkosi.conf` to route build outputs to `/var/lib/sysdeck/builder/artifacts/<name>/`. But when building an OLD v0.0.x profile (whose `mkosi.conf` had no `OutputDirectory=` setting), mkosi defaulted to writing `image.raw` into the cwd — which was `/etc/mkosi/mkosi.conf.d/`, a system config directory owned by root. mkosi then refused to overwrite the existing `image.raw`, blocking every rebuild.
- **Root cause.** `_backend_build_command()` for mkosi was just `["mkosi", "build"]` with no CLI output flags. It trusted the profile's `mkosi.conf` to set `OutputDirectory=`, which doesn't exist on v0.0.x profiles, can be hand-edited to anything, and is ignored by mkosi if the profile is a drop-in fragment mkosi never reads.
- **Fix.** `_backend_build_command()` now ALWAYS passes `--output`, `--output-dir`, and `--force` on the CLI for mkosi builds. CLI flags override `mkosi.conf`, so the output path is forced to `/var/lib/sysdeck/builder/artifacts/<name>/<name>.raw` regardless of what the profile says. `--force` overwrites any existing image so rebuilds don't fail with "Output path exists already."
- **Safety check.** `build()` now refuses to proceed if the resolved `output_dir` is not under `/var/lib/`, `/tmp/`, `/var/tmp/`, or the configured `BUILDER_ARTIFACTS_DIR`. Blocks `/etc/`, `/usr/`, `/boot/`, `/bin/`, `/sbin/`, `/lib/`, `/root/`, `/home/`, etc. Belt-and-suspenders: even if an operator passes `options.output_dir=/etc/something` via the JS bridge, the build is refused before `subprocess.run` is called.
- **Legacy profile warning.** `build()` now detects profiles in `/etc/mkosi/mkosi.conf.d/` (the v0.0.x drop-in layout) and 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."*
- **Log improvement.** Build log header now includes the resolved `output_dir` so the operator can see exactly where the image will land before mkosi starts.
- **Regression tests.** 2 new unit tests in `TestBuilderBuildPath` cover the safety check (refuses `/etc/`) and the legacy-profile warning. 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.
- **Version sync.** Bumped 0.1.0 → 0.1.1 across all 9 release surfaces. Total unit tests now 239 (was 237 in v0.1.0; +2).
### v0.1.0 highlights
v0.1.0 fixes three compounding bugs in the mkosi build path that were silently producing empty 33M images with no kernel, no systemd, no openssh — the operator clicked Build on a freshly-created profile and got back a 33M `image.raw` containing only `iana-etc` + `filesystem`. Plus a new operator feature requested in the same release cycle: *"import current os pkg list to profile should be an option"*.
- **Bug 1 — scaffold location.** `profile-create` wrote `/etc/mkosi/mkosi.conf.d/<name>.conf` — a drop-in fragment that mkosi only honors when a parent `/etc/mkosi/mkosi.conf` exists to layer it onto. With no parent, mkosi ran with empty defaults. Fix: each profile now lives in its own directory `/etc/mkosi/profiles/<name>/mkosi.conf` (the only filename mkosi reads automatically from the cwd). `MKOSI_DIRS` updated to scan `/etc/mkosi/profiles` first.
- **Bug 2 — `Packages=` syntax.** `_MKOSI_TEMPLATE` and `_write_packages_mkosi` used the indented-continuation form which was the old systemd-mkosi (<=v15) syntax. mkosi v22+ (Arch ships 25.x) expects single-line space-separated: `Packages=linux linux-firmware systemd openssh`. 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.
- **Bug 3 — output routing.** mkosi wrote its output to the cwd (`/etc/mkosi/mkosi.conf.d/image.raw`) but `build()` only scanned `/var/lib/sysdeck/builder/artifacts/<profile>/` for artifacts — so every successful build looked like a failure in the panel. Fix: `_MKOSI_TEMPLATE` now sets `OutputDirectory=` to the per-profile artifacts dir so mkosi writes directly there.
- **New feature — `profile-import-packages`.** Queries the host's explicitly-installed package set (`pacman -Qqe` on Arch, `apt-mark showmanual` on Debian, `dnf repoquery --userinstalled` on Fedora) and writes it into a profile's package list via the existing `_write_packages` dispatch. Defaults to **append** mode so the profile's baseline (kernel, systemd, openssh) is preserved. Supports `--mode=replace`, `--dry-run` for preview, and `--packages=<json>` for manual override (useful for importing a list captured on another host). New polkit exec paths for `pacman`/`apt-mark`/`dnf` added to `org.sysdeck.builder.modify`.
- **Panel UX.** Each profile row in the Builder panel now has a "⇩ Import host pkgs" button. Click → dry-run preview → `window.confirm` with package count, source distro, and first 200 packages → append write. Falls back to operator cancel without writing.
- **Regression tests.** 8 new unit tests in `TestBuilderImportHostPackages` cover `_detect_host_packages` dispatch (pacman path + dedup), the `--packages` override end-to-end, `--dry-run` no-write behavior, and the unknown-profile / no-args / bad-mode / COMMANDS-registration error paths. 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.
- **Version sync.** Bumped 0.0.50 → 0.1.0 across all 9 release surfaces. Total unit tests now 237 (was 228 in v0.0.50; +8 `TestBuilderImportHostPackages` + 1 new `test_mkosi_modern_single_line_input_parsed`).
### v0.0.50 highlights
v0.0.50 fixes a `NameError: name 're' is not defined` that blocked every `build()` invocation since v0.0.31. An operator reported: *"happens right away on build for a new profile i created."* The traceback pointed at `_new_build_id()` line 492: `safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile)`.
- **Root cause.** `bridge/builder.py`'s module-level imports were `import json / os / shutil / subprocess / 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 full-featured build operations were added), but no test ever exercised the `build()` code path — the unit tests only covered `profile_create` / `profile_copy` / `profile_delete` and the v0.0.49 package-writing helpers. 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.
- **Fix.** Added `import re` to the module-level imports in `bridge/builder.py`. Removed the now-redundant local `import re` inside `_write_packages_vmdb2` (it was a v0.0.49 workaround that's no longer needed — the module-level import covers both callers).
- **Regression tests.** 9 new unit tests in `TestBuilderBuildPath` cover `_new_build_id` (format, sanitization of unsafe chars like dots, preservation of safe chars like hyphens/underscores, and an explicit assertion that `re` is in the builder module's globals so the bug can't recur if anyone refactors the imports). The class also includes `build()` end-to-end tests with mocked `subprocess.run` — success path (verifies state file + log file written, response shape correct, subprocess actually called), unknown profile, no args, backend-not-installed, and non-zero returncode records state "failed". All tests mock the module-level `BUILDER_STATE_DIR` / `BUILDER_LOGS_DIR` / `BUILDER_ARTIFACTS_DIR` so they run hermetically.
- **AST audit.** Ran an AST-based audit of `bridge/builder.py` to find any other names used at module level but not imported. No real undefined names found — every flagged item was a comprehension local, tuple-unpacking target, except-clause target, or `__file__`. The build path is now fully exercisable by tests.
- **Version sync.** Bumped 0.0.49 → 0.0.50 across all 9 release surfaces.
### v0.0.49 highlights
v0.0.49 closes the loop on the Image Builder profile-creation flow. Per user directive: *"we should allow adding a pacman -Sy applist.txt with a literal list of baseline apps for the profile being generated."* Previously the operator scaffolded/copied a profile, then had to drop to a shell to edit the package list. Now both the Create Profile and Copy shipped profile forms include an inline package-list field — paste the list or upload `applist.txt`, pick a merge mode, and the bridge writes the packages to the right place for whichever backend was selected.
- **All 4 backends supported.** Each writes to its native package-list location: mkosi → `[Packages]` section of `<name>.conf`, vmdb2 → `bootstrap.include` list in `<name>.yaml`, archiso → `packages.x86_64` in the profile dir, live-build → `config/package-lists/sysdeck.list`. The per-backend writers are intentionally distinct (no generic "update INI/YAML" abstraction) because each format has its own quirks.
- **Textarea + file upload.** The textarea is the source of truth — one package per line, `#` comments allowed. The file upload (`applist.txt` / `.list` / `.conf` accepted) populates the textarea via the browser's `FileReader` API so the operator can review/edit the uploaded content before submitting. 1 MB cap on uploaded files.
- **Operator-chooses merge mode.** A dropdown toggle on each form: **append** (default for Copy — preserves the baseline's existing packages like `linux`/`base`, adds the operator's, deduplicates) or **replace** (default for Create — overwrites the baseline's package file with the operator's list). The operator chooses per-operation.
- **New bridge helpers.** `_extract_opts(args)` splits argv into positional + `--key=value` opts so `profile-create`/`profile-copy` can accept the new flags without breaking their existing positional signatures. `_parse_packages_text(text)` parses multiline text into a deduped list (strips full-line + inline comments, blank lines, whitespace; preserves first-occurrence order). `_write_packages_mkosi/vmdb2/archiso/live_build` are per-backend writers. `_write_packages(profile_path, backend, packages_text, mode)` is the dispatcher.
- **Extended `profile_create` + `profile_copy`.** Both accept `--packages=<json>` (JSON-encoded so newlines/quotes survive the argv boundary) and `--mode=append|replace`. Both return a new `packages` field in their success response: `{count, mode, path}`. If package-writing fails, the profile is still created/copied and a `packages_error` field is included (non-fatal).
- **Updated `shared/bridge.js`.** `profileCreate(name, backend, base, packagesText, mode)` and `profileCopy(srcName, newName, backend, packagesText, mode)`. `packagesText` is JSON-encoded via `JSON.stringify()`. When omitted/null, the bridge writes no package file (back-compat with v0.0.48 callers).
- **28 new unit tests** in `TestBuilderPackagesField` cover `_parse_packages_text` (6 tests), `_extract_opts` (4 tests), each per-backend writer (10 tests across 4 backends × 2 modes + edge cases), the dispatcher (3 tests), and end-to-end `profile_create`/`profile_copy` with `--packages` (5 tests). All use tempdirs; none touch real `/etc/` paths.
- **Version sync.** Bumped 0.0.48 → 0.0.49 across all 9 release surfaces.
### v0.0.48 highlights
v0.0.48 fixes a builder-panel bug that surfaced on hosts with only `archiso` or only `live-build` installed (i.e. no `mkosi`/`vmdb2`). The v0.0.31 Create Profile dropdown fell back to `primary.id` when no scaffoldable backend was installed — on an archiso-only Arch host or a live-build-only Debian host, the operator could pick "archiso" or "live-build" from the dropdown, click Create, and get hit with `Error: profile-create supports ('mkosi', 'vmdb2'); archiso profiles are not scaffolded (use the shipped ones)`. That error is by design — archiso and live-build use shipped directory-based profile trees, not single-file specs that can be scaffolded from scratch — but the panel gave the operator no way to act on the "use the shipped ones" hint.
- **Fix 1: Create Profile dropdown gating.** `renderCreateProfile` in `plugins/sysdeck-builder/builder.js` no longer falls back to `primary.id` when no `mkosi`/`vmdb2` backend is installed. The dropdown only offers actually-scaffoldable backends. When none is installed, the form renders an inline install hint with the exact `pacman`/`apt` command instead of a dropdown that would have errored.
- **Fix 2: new "Copy shipped profile" form.** A new `renderCopyProfile` form lists every shipped `archiso` and `live-build` profile discovered via `profiles()` (typically `baseline` and `releng` for archiso) and offers a one-click copy into `/etc/`. Source profiles are grouped by backend in an `<optgroup>`; the new-name input is free-text. This is the supported way to create profiles for the directory-based backends.
- **New bridge command: `profile-copy`.** `bridge/builder.py` gains a `profile_copy()` function (registered in the `COMMANDS` dict as `profile-copy`). It copies `/usr/share/archiso/configs/<src>/` → `/etc/archiso/configs/<new>/` (and the live-build equivalent). Validates the new-name (rejects slashes and `.`/`..` to prevent path traversal), resolves the source via `profiles()`, refuses non-directory-based backends with a clear "use profile-create" hint, refuses if the destination already exists, and returns structured `{copied, backend, source, source_path, name, path}` on success. Uses the same polkit action as `profile-create` (`org.sysdeck.builder.modify`) — no new polkit file needed.
- **New bridge.js method.** `bridge.builder.profileCopy(srcName, newName, backend)` runs with `{ superuser: 'try' }`, same as `profileCreate` / `profileDelete`.
- **Destination-roots refactor.** `ARCHISO_COPY_DEST` and `LIVE_BUILD_COPY_DEST` are now module-level constants in `bridge/builder.py` (was: hardcoded `Path("/etc/...")` literals inside `profile_copy`). This mirrors the existing `ARCHISO_DIRS` / `LIVE_BUILD_DIRS` pattern and lets unit tests patch them with tempdirs instead of touching real `/etc/` paths.
- **15 new unit tests.** A new `TestBuilderProfileCopy` class in `tests/test_bridge_parsers.py` covers argument validation (no args, one arg, slash in name, `.`/`..` name), source resolution (not-found, wrong-backend hint filter, mkosi/vmdb2 rejection with "use profile-create" hint), success paths (archiso copy, live-build copy, backend-hint-inferred-when-omitted), and failure modes (dest-already-exists with "use profile-delete" hint, source-path-not-a-directory, permission-error returns polkit hint). All tests use `tempfile.mkdtemp()` and `unittest.mock.patch.object()`; none touch real `/etc/` or `/usr/share/` paths.
- **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` + `%changelog` entry, `debian/changelog` entry, `compat/compat-manifest.json` `version` + `_comment`, `packaging/sysdeck.metainfo.xml` `<release>`, `README.md` Version line). All 9 surfaces now report v0.0.48.
### v0.0.47 highlights
v0.0.47 fixes four logic flaws in the v0.0.46 release. 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."*
- **Firewall: public-webserver.sh port-topology fix.** The v0.0.44 template had the cache topology backwards — it exposed Caddy on `:80` and Varnish on `:8080`. v0.0.47 flips it to match the operator's documented cache-environment setup: **Varnish is the public cache front on `:80`**, **Caddy HTTP backend lives on `:8080` (loopback only)** — Varnish's cache-miss target — and **Caddy HTTPS terminates TLS on `:443` (public)**. The `VARNISH_PUBLIC` toggle is removed entirely: `:8080` is now ALWAYS loopback-only because the previous default (`VARNISH_PUBLIC=true`) exposed the cache-miss backend path to the internet, letting clients bypass Varnish and hit Caddy directly. 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. The detect output now reflects the corrected cache-front-of-origin topology.
- **Firewall: vps-webserver.sh default topology.** When Varnish is detected at all (installed but stopped, or running on the upstream default `:6081`), the template now forces **`VARNISH_PORT=80`** with a log message explaining the override, and flips Caddy HTTP to `:8080` loopback. Previously this only happened if Varnish was already listening on `:80` at runtime — meaning the cache-environment topology depended on the operator having manually moved Varnish to `:80` first. v0.0.47 makes the cache-front-of-origin topology the explicit default the moment Varnish is detected, matching the public-webserver.sh behavior.
- **NEW PLUGIN: sysdeck-services (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 new panel adds a filter box (search by name/id/port/process), a show-only-editable toggle, and a Refresh button. The bridge surface (`bridge.firewall.services` / `service-info` / `set-service-port` / `restart-service`) is unchanged; 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. No new bridge helper file was needed — the `SERVICES_REGISTRY`, atomic-write logic, and `CONFIG_BASE_DIRS` allowlist remain in `bridge/firewall.py` as the single source of truth. The firewall panel keeps a signpost card pointing operators to the new sidebar entry; the `service` / `port` / `editor` keywords were removed from the firewall manifest (they belong to the new services plugin now).
- **Glances: default-on embedded webui.** The Glances panel now auto-starts the built-in Glances webserver (`glances -w --bind 127.0.0.1 --port 61208`) on mount — no click required. The iframe is now the primary view, sized to fill the viewport (`min-height: calc(100vh - 200px)`). The legacy SysDeck snapshot cards (CPU/Memory/Swap/Network/Disk/Processes) are moved into a collapsed `<details>` at the bottom of the page so they don't push the iframe below the fold. The Stop button is retained for explicit shutdown; we don't stop on unmount because keeping the webserver running speeds re-entry. The manifest CSP was updated to `frame-src 'self' http://127.0.0.1:61208 http://localhost:61208` so the embedded Glances web UI loads without a CSP violation.
- **Version sync catch-up.** The v0.0.46 release 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.
### v0.0.46 highlights
v0.0.46 is a trademark-scrub release. Per user directive: *"you cannot say smoothwall and ipfire where merged into our fw script either. you can say logic derived from or influenced by these projects. its really hard holding your hand on legal issues."* The v0.0.36 and v0.0.37 release notes, changelogs, code comments, and worklog entries previously claimed we shipped templates called `smoothwall.sh` and `ipfire.sh` and "merged" them into `sysdeck-fw`. That language implied we incorporated code from those trademarked projects. v0.0.45 rewords every such claim to the legally-safe phrasing: the `sysdeck-fw` backend's logic is **derived from** / **takes influence from** Smoothwall Express and IPFire under our own identifier. We never shipped templates called `smoothwall` or `ipfire`.
- **Trademark scrub.** Every file in the repository was audited for problematic phrasings near "smoothwall" or "ipfire". The script `/home/z/my-project/scripts/scrub_v045_trademark.py` performed systematic find/replace across 10 files: `bridge/firewall.py` (EXCLUDED_BACKENDS reasons + docstring), `firewall/templates/sysdeck-fw.sh` (header comment), `firewall/templates/cilium.sh` (stale backend reference), `tests/test_bridge_parsers.py` (test class docstrings + comments), `plugins/sysdeck-firewall/firewall.js` (header comment), `plugins/sysdeck-firewall/manifest.json` (keywords list — removed `smoothwall` + `ipfire`, added `sysdeck-fw`), `README.md` (v0.0.36 + v0.0.37 highlights), `packaging/debian/changelog` (v0.0.36 + v0.0.37 entries), `packaging/sysdeck.spec` (v0.0.36 + v0.0.37 changelog entries), `worklog.md` (Task 36 + Task 37 entries).
- **Legally-safe phrasings used.** Every reference to Smoothwall Express or IPFire now uses one of: "takes influence from", "logic derived from", "influenced by these projects". The `EXCLUDED_BACKENDS` reasons for `smoothwall` and `ipfire` now read: "other projects' trademarks — we took influence from them for sysdeck-fw instead of shipping templates by those names."
- **No functional changes.** This is a wording-only release. No code paths changed, no templates changed, no bridge subcommands changed. All 141 unit tests still pass. The `sysdeck-fw` backend, the 7 firewall templates, and the v0.0.44 service/port editor are unchanged.
- **Direct-quote preservation.** User-directive quotes that mention "smoothwall" or "ipfire" (e.g. the v0.0.36 directive: *"or they can select celium, or smoothwall or ipfire or other firewall scripts"*) are preserved verbatim as the user's own words. Our commentary around them uses the legally-safe phrasings.
### v0.0.44 highlights
v0.0.44 adds three public-server firewall variants and a full service/port editor to the firewall module. Per user directive: *"another thing the firewall module needs is a few public server variants. like: remote admin enabled ssh and cockpit, server enabled like caddy and varnish 80 and 8080 w mariadb, an ai llm variant for ollama, hermes, openwebui and oddyseus. and lastly a full service/port editor that detects based on running ports and services detected on them. make it as simple as editing the port to change it in a config on the system. auto restart the associated service if it is changed."*
- **Three new public-server firewall templates.** All three implement the standard start/stop/restart/detect/status/check interface and use modern nftables inet family with named sets, rate limiting with dynamic auto-ban, bogon filtering, invalid TCP flag drops, and per-port log prefixes. They appear in the existing Templates card when the `custom` backend is active — no new UI surface needed for selection.
- `remote-admin.sh` — SSH (22) + Cockpit (9090). Aggressive rate limiting with auto-ban (4/min SSH, 10/min Cockpit). For VPS / cloud hosts where the operator needs remote shell + web admin from anywhere.
- `public-webserver.sh` — Caddy (80/443) + Varnish (8080, public by default per the "80 and 8080" directive) + SSH (22). MariaDB (3306) and Caddy admin API (2019) are bound loopback-only with DEFENSE-IN-DEPTH DROP rules — even if the daemon is misconfigured to bind 0.0.0.0, the firewall drops the packet before it reaches the daemon.
- `ai-llm.sh` — Ollama (11434) + OpenWebUI (3000) + Hermes (8000) + Odysseus (8001) + SSH (22). For self-hosted AI LLM stacks. All four AI service ports are public per the user directive; the detect output documents the v0.0.43 "never 0.0.0.0" directive and explains why Ollama's default 0.0.0.0 bind is acceptable here (the firewall gates access, not the bind address).
- **Service/Port Editor.** Four new bridge/firewall.py subcommands (`services`, `service-info`, `set-service-port`, `restart-service`) plus a new "Service / Port Editor" card in the firewall panel. The editor runs `ss -tlnp` (or `/proc/net/tcp` fallback) to enumerate ALL listening TCP ports on the host, cross-references against a static SERVICES_REGISTRY of 9 services (ssh, cockpit, caddy, varnish, mariadb, ollama, openwebui, hermes, odysseus), and renders one row per service with: editable port input, Save & Restart button, Restart-only button, current port from config, default port, listening ports, processes, PIDs, config file path. Unmapped listeners (ports with no matching registry entry) are shown in an expandable block so the operator can spot services the editor doesn't yet know about. Editing a port writes the new value to the config file atomically (tmpfile + fsync + rename) and runs `systemctl restart` on the service. Adding a new service to the editor is as simple as adding an entry to `SERVICES_REGISTRY` in `bridge/firewall.py` with its config file paths and port-extraction regex — no other code changes.
- **Hardening.** service_id validated against SERVICES_REGISTRY (CVE-2024-2947 — attacker cannot trick the bridge into editing /etc/shadow). Port validated with strict integer regex 1..65535, `re.fullmatch` to reject trailing newlines (CVE-2019-15107 — the v0.0.43 validators used `re.match` which let "22\n" slip past; v0.0.44 fixes this). Config path resolved with `os.path.realpath` + base-dir allowlist (`/etc/` or `/usr/share/sysdeck/` — CVE-2022-30708 symlink-escape defense). Port substitution uses a strict per-service regex (NOT freeform sed) so only the port digits are replaced — comments and other content on the line are preserved. systemctl invoked with `shell=False`, list argv, env scrubbed (CVE-2024-6126). systemctl binary validated against an allowlist (`/usr/bin/systemctl`, `/bin/systemctl`, `/usr/sbin/systemctl`). Atomic write via tmpfile + fsync + rename defeats partial-write corruption. The `org.sysdeck.firewall.modify` polkit action (shipped since v0.0.17) already authorizes `/usr/bin/systemctl` — no polkit changes required.
- **Regression tests.** 32 new tests in two new test classes (`TestFirewallV044ServicesEditor` + `TestFirewallV044PublicServerTemplates`). Tests cover: SERVICES_REGISTRY structure, `_validate_service_id` and `_validate_port` accept/reject (including shell-metachar and path-traversal attacks), `cmd_services` JSON shape, `cmd_service_info` / `cmd_set_service_port` / `cmd_restart_service` validation, end-to-end atomic-write test on a temp config file, no-config-file and regex-no-match error paths, three new template files exist + executable + metadata header + standard dispatch interface + no-sudo. Total tests: 109 (v0.0.43) → 141 (v0.0.44).
### v0.0.43 highlights
v0.0.43 fixes a hardening lapse from v0.0.40: the Prometheus port fix introduced 4 references to `0.0.0.0:9095` as a listener address — a wildcard bind that would expose Prometheus to every network interface. All 4 are replaced with `127.0.0.1:9095` (loopback only). A new regression test (`TestNoWildcardListeners`) scans every bridge helper and panel JS for the `0.0.0.0:<port>` pattern and fails the build if any are found — enforcing the "never bind 0.0.0.0" rule permanently.
- **No 0.0.0.0 listeners.** Per user directive: *"we need to make sure we never ever set a web listen address to 0.0.0.0, if anything use 127.0.0.1. we already discussed hardening that should have been fresh."* The v0.0.40 Prometheus port fix introduced `webListenAddress: "0.0.0.0:9095"` in the bridge config display and `web.listen_address: "0.0.0.0:9095"` in the install hint — a wildcard bind exposing Prometheus to the LAN/internet. v0.0.43 replaces all 4 references with `127.0.0.1:9095`.
- **Regression test.** `TestNoWildcardListeners` scans every `bridge/*.py` and `plugins/*/*.js` for the `0.0.0.0:<port>` listener pattern and fails the build if any are found. The only allowed uses of `0.0.0.0` are CIDR bogon blocks in firewall templates (e.g. `0.0.0.0/8`) and comments documenting upstream defaults. Total tests: 98 → 100.
- **Audit confirmed.** Every other web listener in the suite already uses `127.0.0.1`: Glances (`--bind 127.0.0.1`), Jellyfin (panel uses `127.0.0.1` even though Jellyfin itself defaults to `0.0.0.0`), Photos/RemoteFS/Mining (no web listeners — they manage systemd services).
### v0.0.40 highlights
v0.0.40 fixes a port conflict bug: Prometheus and Cockpit-ws both default to port 9090. Since Cockpit is already on 9090 on every SysDeck host, the v0.0.39 bridge was hitting Cockpit-ws instead of Prometheus. Prometheus is moved to port 9095.
- **Port conflict fix — Prometheus 9090 → 9095.** Per user directive: *"prometheus and cockpit both use the same port. so we can assume prometheus was moved not cockpit."* Cockpit-ws defaults to port 9090. Prometheus also defaults to 9090. The v0.0.39 bridge hardcoded `http://localhost:9090` as the Prometheus API URL — on any host where Cockpit is running, the bridge would hit Cockpit-ws instead of Prometheus and get HTML pages instead of JSON API responses. v0.0.40 moves the Prometheus default to port 9095 (familiar 909x range, no conflict with Pushgateway 9091, Alertmanager 9093, or Cockpit 9090). 10 references updated across 6 files: `bridge/prometheus.py` (PROM_API_URL default + webListenAddress), `plugins/sysdeck-monitoring/monitoring.js` (iframe src, open-in-new-tab link, status table URL, install hint port, comment), `manifest.json` (CSP `frame-src`), `prometheus/sysdeck_scrape.yml` (self-scrape target), `prometheus/sysdeck_grafana_datasources.yml` (datasource URL). The install hint now explicitly tells operators to move Prometheus off 9090 via `web.listen_address` or `ARGS`.
- **Operator override.** Operators who already run Prometheus on a custom port can override via the `PROMETHEUS_API_URL` environment variable (e.g. `PROMETHEUS_API_URL=http://localhost:9096`).
### v0.0.39 highlights
v0.0.39 adds a shared tabbed Monitoring module (Prometheus + Grafana) and hardens both bridge helpers to v0.0.37 security standards:
- **Monitoring module — Prometheus + Grafana.** Per user directive: *"we have 2 modules left, we can actually have them share a module with tabs similar to the container/vm module. we should add prometheus, and graphana webui modules."* New plugin `plugins/sysdeck-monitoring/` with two tabs: (1) Prometheus — status card (version, uptime, targets, alerts firing) + iframe of the real Prometheus web UI at `http://127.0.0.1:9090`; (2) Grafana — status card (version, dashboards, datasources) + iframe of the real Grafana web UI at `http://127.0.0.1:3000`. Each tab has Refresh / Reload Config / Restart buttons. When a service is not installed, the tab shows a distro-specific install hint (Arch / Debian / Fedora). Plugin count 23 → 24.
- **Bridge hardening.** The existing `bridge/prometheus.py` (448 lines) and `bridge/grafana.py` (413 lines) were written before v0.0.36/v0.0.37 hardening. v0.0.39 brings them up to standard: `NoRedirectHandler` on all HTTP calls (SSRF defense, CVE-2020-35850), 127.0.0.1-only URL check, env scrubbed on every subprocess (CVE-2024-6126), output sanitized (CVE-2022-36446), no `sudo` (replaced with direct `systemctl` + cockpit superuser channel + polkit), `check=False` with structured error return, reuses `firewall.py` security helpers via import.
- **New bridge.js surfaces.** `bridge.prometheus` (8 methods) + `bridge.grafana` (11 methods). Read-only queries do NOT pass `superuser: 'try'`; restart/reload DO.
- **Polkit action.** New `org.sysdeck.monitoring.modify` authorizes `systemctl` for Prometheus + Grafana service management.
- **Config files shipped.** The `prometheus/` directory (existed since v0.0.31 but was never installed) is now shipped read-only at `/usr/share/sysdeck/prometheus/`: scrape configs, alert rules, Grafana datasource + dashboard provisioning YAMLs.
- **Regression tests.** 12 new tests for the prometheus + grafana bridge helpers. Total: 90 (v0.0.38) → 102.
### v0.0.38 highlights
v0.0.38 makes the Kata panel production-ready by replacing the mock React bundle with a real Python bridge, and adds a polkit action for future mutating kata verbs:
- **Kata panel production rewrite.** The v0.0.35-v0.0.37 Kata panel shipped a 470KB pre-built React bundle from the upstream cockpit-kata sub-project. That bundle displayed **hardcoded mock data**: 5 fake sandboxes (`web-frontend-prod`, `api-gateway-staging`, etc.) with synthetic UUIDs and `createdAt:"2026-07-15..."` timestamps, fake per-sandbox metrics (cpuUsagePercent, memoryUsageMB, historyCpu/historyMemory arrays), a fake QCrows bundle catalog, and a fake PXE status (always `dnsmasqRunning:true`). The only real features were the QCrows kernel-bundle extraction and `kata-runtime check`. v0.0.38 deletes the React bundle and ships a vanilla-JS panel (`plugins/sysdeck-kata/kata.js`) backed by a new `bridge/kata.py` that calls the **real Kata Containers 3.x APIs**: `kata-monitor` HTTP `/sandboxes` + `/agent-url` + `/metrics?sandbox=<id>` for sandbox enumeration and metrics, filesystem probes of `/run/vc/sbs/<id>/` (Go shim) and `/run/kata/<id>/` (Rust shim) for sandbox state, `kata-runtime version` + `kata-runtime env --json` for version info, `kata-runtime check` (exit code) for host capability, `systemctl is-active dnsmasq` + real `/srv/tftp/` probes for PXE status, and real filesystem enumeration of `/usr/share/sysdeck/kata/qcrows/` for the QCrows bundle catalog. When no sandboxes are running, the panel shows the **real empty state** — not mock data. The bridge applies all v0.0.36 + v0.0.37 security hardening (strict sandbox-ID validation with `^[0-9a-f]{64}$`, env scrubbing, output sanitization, no-redirect HTTP to kata-monitor for SSRF defense).
- **Kata 3.x API correctness.** Researched the real `kata-runtime` CLI surface for Kata Containers 3.x. Key finding: `kata-runtime list` and `kata-runtime inspect` were **removed in 3.x** — the bridge does NOT call them. Sandbox enumeration uses `kata-monitor`'s `/sandboxes` endpoint (plain text, one 64-hex-char ID per line — NOT JSON) plus filesystem enumeration. `kata-runtime env --json` returns structured JSON with **Capitalized Go field names** (no `json:` struct tags) — `Runtime`, `Hypervisor`, `Host`, `Version`, `Semver` — the parser handles this correctly. `kata-monitor /metrics` returns **Prometheus text format** (not JSON), parsed via `prometheus_client.parser.text_string_to_metric_families` when available.
- **New bridge helper.** `bridge/kata.py` with 8 subcommands: `list`, `inspect`, `metrics`, `summary`, `version`, `check`, `pxe-status`, `qcrows-list`. Reuses the v0.0.37 firewall.py security helpers (SCRUBBED_ENV, _sanitize_output, _validate_filename, _resolve_path_under_base) via import — single source of truth for hardening.
- **New bridge.js surface.** `bridge.kata` with 8 methods mirroring the subcommands. All read-only (no `superuser: 'try'`).
- **Polkit action.** New `org.sysdeck.kata.modify` action authorizing `kata-runtime`, `kata-monitor`, `ctr`, `crictl`, `qcrows-export`, `qcrows-initrd-regen`, and `systemctl`. Ships now so future mutating verbs (sandbox create/stop/remove, qcrows-export) are authorized when they land.
- **Manifest relaxed.** `plugins/sysdeck-kata/manifest.json` `requires.cockpit` lowered from `286` to `239` (matching every other plugin — the React bundle's cockpit-286 requirement no longer applies). CSP simplified to the standard `'unsafe-inline' 'unsafe-eval'` (the React bundle's `connect-src http://127.0.0.1:8090` exception is gone — the bridge does the HTTP server-side). Keywords extended with `kata-monitor`, `qcrows`, `pxe`, `tftp`, `cloud-hypervisor`, `firecracker`, `qemu`.
- **Regression tests.** 13 new tests in `TestKataBridgeProduction` class verifying: `cmd_list` returns `[]` (not mock 5 sandboxes), `cmd_qcrows_list` returns `[]` (not mock catalog), `cmd_summary` returns real state (`kata_runtime_installed: false`), `cmd_pxe_status` returns real state (`dnsmasq_running: false`), sandbox-ID validation rejects malicious input (CVE-2024-2947), and a source-code scan verifying `kata.py` contains NONE of the mock markers (`web-frontend-prod`, `kata-sbx-a1b2c3`, etc.). Total tests: 78 (v0.0.37) → 91 (v0.0.38).
### v0.0.37 highlights
v0.0.37 introduces the unified "SysDeck FW" backend (which takes influence from Smoothwall Express and IPFire for its zone model + source-verified outbound + AirWall isolation) and expands the CVE-derived security hardening to cover commercial web admin UI panels (cPanel, Plesk, CyberPanel, aaPanel, CloudPanel, HestiaCP, VestaCP, Froxlor, InterWorx, BrainyCP, DirectAdmin, CWP):
- **Unified SysDeck FW backend.** Per user directive: *"we cant call smoothwall or ipfire if its a rewrite, so lets unify them into a unified nftables fw template in the drop down we can call it SysDeck FW."* The `sysdeck-fw` backend takes influence from Smoothwall Express (RED/ORANGE/GREEN/BLUE color-zone model) and IPFire (source-verified outbound per-zone CIDR, AirWall isolation for BLUE/WiFi toggleable via `AIRWALL=false`, flow offload for hardware acceleration, DMZ port-forwarding) under our own identifier. We do not ship templates called "smoothwall" or "ipfire" — those are other projects' trademarks. Config file at `/etc/sysdeck/firewall/sysdeck-fw.conf`. Smoothwall and IPFire appear in `EXCLUDED_BACKENDS` with the reason documented.
- **Expanded CVE research.** Per user directive: *"when i say webmin i mean all web admin ui panels cpanel all of them have a history for us to learn from on the security side of things."* v0.0.36 covered Webmin, Cockpit, Ajenti, ISPConfig, Virtualmin. v0.0.37 extends the research to cover cPanel/WHM, Plesk, DirectAdmin, CloudPanel, aaPanel, Froxlor, InterWorx, BrainyCP, CyberPanel, HestiaCP, VestaCP, FastPanel, and CWP. 29 additional CVEs reviewed — full table in `docs/SECURITY-HARDENING.md`. Key new CVEs: CVE-2026-41940 (cPanel session-file CRLF injection, CVSS 9.8, CISA KEV — attacker injects `\r\nuser=root\r\n` into a pre-auth session file, bypassing password + 2FA), CVE-2025-66431 (Plesk domain-creation RCE-as-root — domain names flow into root-run scripts), CVE-2024-51567 (CyberPanel pre-auth 0-click RCE as root, CVSS 10.0, exploited by PSAUX ransomware Oct 2024 — `secMiddleware` only inspects POST; attackers bypass via PUT/OPTIONS), CVE-2025-48702 (aaPanel tar argument injection — **subprocess array form does NOT prevent this**; filenames like `--checkpoint-action=exec=bash shell.sh` execute code), CVE-2026-26279 (Froxlor email-validation logic bug — validation disabled for fields declared as email type), CVE-2023-53945 (BrainyCP crontab RCE — users inject commands through the crontab interface), CVE-2023-35885 (CloudPanel auth bypass via insecure file-manager cookie), CVE-2025-100 (CWP/CentOS Web Panel critical RCE, actively exploited).
- **New validators (7).** Each grounded in a specific commercial-panel CVE: `_validate_domain` (CVE-2025-66431 Plesk — RFC 1035 strict domain regex, rejects shell metacharacters, path separators, `..`, leading/trailing hyphens, enforces 253-char max / 63-char label max), `_validate_email` (CVE-2026-26279 Froxlor — `parseaddr` + charset regex + separate shell-metachar reject; defense in depth on top of input validation), `_validate_cron_schedule` (CVE-2023-53945 BrainyCP — 5-field cron syntax only; the cron *command* is never user-supplied), `_validate_mysql_identifier` (CVE-2026-58048 cPanel — MySQL identifier + reserved-word denylist + no embedded backticks), `_sanitize_for_file` (CVE-2026-41940 cPanel — strips `\r\n\0` from any value written to a line-oriented file), `_decode_then_validate` (CVE-2026-29205 cPanel cpdavd — URL-decode + canonicalize + validate; never validate-then-decode), `safe_tar_create` (CVE-2025-48702 aaPanel + IWX-CVE-2022-8384 InterWorx — tar `--null -T -` keeps filenames OUT of argv entirely, defeating argument injection that bypasses the v0.0.36 `--` separator defense).
- **Security-hardening subcommand expanded.** `cmd_security_hardening` now returns 17 applied items (up from 9 in v0.0.36) and 48 CVEs reviewed (up from 19). The panel's Security Card renders the expanded checklist with the new commercial-panel CVE badges.
- **Backend count: 3.** `FIREWALL_BACKENDS` has 3 entries: `custom`, `cilium`, `sysdeck-fw`. `EXCLUDED_BACKENDS` has 7 entries: the original 5 (ufw, fwbuilder, iptables-legacy, iptables-nft, shorewall) plus `smoothwall` and `ipfire` (both excluded because they are other projects' trademarks; we took influence from them for sysdeck-fw).
- **Regression tests expanded.** `tests/test_bridge_parsers.py` grows from 45 tests (v0.0.36) to 70 tests (v0.0.37) — 25 new tests for the v0.0.37 validators, each mapped to a specific commercial-panel CVE.
### v0.0.36 highlights
v0.0.36 adds a firewall backend dropdown to the Firewall panel and hardens the entire firewall bridge against CVE disclosures found in Webmin, Cockpit, Ajenti, ISPConfig, and Virtualmin:
- **Firewall backend dropdown.** Per user directive: *"next we will add cilium support as a drop down option in the fw area, the user can select custom which is default with the templates that are basic. or they can select celium, or smoothwall or ipfire or other firewall scripts that install cleanly with value for ebpf era and nftables. iptables is old now."* Three backends ship: `custom` (default — the existing vps-webserver.sh + no-services.sh nftables templates), `cilium` (Cilium eBPF datapath — replaces nftables as the datapath; identity-based policy via CiliumIdentity labels; L7 policy via Envoy), and `sysdeck-fw` (unified nftables zone firewall — takes influence from Smoothwall Express and IPFire under our own identifier; we do not ship templates called "smoothwall" or "ipfire" because those are other projects' trademarks). Excluded backends — UFW, fwbuilder, iptables-legacy, iptables-nft, Shorewall, Smoothwall Express, IPFire — are documented in the panel's expandable "Excluded backends" block with the reason for each.
- **New templates.** Two new firewall templates ship under `firewall/templates/`: `cilium.sh` (Cilium eBPF policy loader — applies the default policy at `/usr/share/sysdeck/firewall/policies/cilium-default.yaml`) and `sysdeck-fw.sh` (unified nftables zone firewall — RED/ORANGE/GREEN/BLUE zone matrix, source-verified outbound, AirWall isolation for BLUE, optional flow offload, DMZ port-forwarding; takes influence from Smoothwall Express + IPFire under our own identifier). Both implement the standard start/stop/restart/detect/status/check interface.
- **Security hardening.** Per user directive: *"now theres inherintly alot of lessons to learn from all the other webmins that came before us. search the web for vuln disclosures for older webmins that we could learn to secure our code from the release info."* v0.0.36 hardens the firewall bridge against every CVE disclosure found in Webmin, Cockpit, Ajenti, ISPConfig, and Virtualmin. Full CVE table + hardening checklist in `docs/SECURITY-HARDENING.md`. Highlights: CVE-2019-15107 (strict allowlist regex on user input before argv), CVE-2024-2947 (filename validation `^[A-Za-z0-9._-]+$`), CVE-2026-4631 (`--` separator before user positionals), CVE-2024-6126 (env scrubbed on every privileged subprocess — LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, BASH_ENV, ENV, PERL5OPT all dropped), CVE-2022-36446 (all bridge output escaped in JS, never innerHTML), CVE-2022-30708 (path resolution with realpath + startswith base check), CVE-2019-15642 (no eval / pickle / yaml.unsafe_load), CVE-2022-0824 (per-verb polkit check, no UI-trust), CVE-2020-35606 (reject on first mismatch, no sanitization), 2019 Webmin backdoor (release-gate runs `git status --porcelain`; reproducible builds with pinned `LC_ALL=C`, `SOURCE_DATE_EPOCH`).
- **New bridge subcommands (11).** `backends`, `backend-info`, `active-backend`, `switch-backend`, `install-backend`, `cilium-status`, `cilium-endpoints`, `cilium-policy`, `cilium-policy-apply`, `cilium-policy-validate`, `security-hardening`. The bridge.js firewall surface exposes 11 new methods mirroring them.
- **Polkit policy extended.** `org.sysdeck.firewall.modify` action now authorizes `/usr/bin/cilium`, `/usr/sbin/cilium`, `/usr/bin/cilium-agent`, `/usr/sbin/cilium-agent`, `/usr/bin/helm`, `/usr/sbin/helm` (in addition to the v0.0.17 set: nft, iptables, ip6tables).
- **Regression tests.** `tests/test_bridge_parsers.py` grows from 9 tests (v0.0.35) to 45 tests (v0.0.36) — 36 new hardening / backend / Cilium / security-hardening tests, each mapped to a specific CVE.
- **Plugin count unchanged at 23.** No new sidebar entries; this is a feature release for the existing Firewall panel.
### v0.0.35 highlights
v0.0.35 restores SysDeck Kata as a standalone sidebar entry and adds three new modules per user directive — Jellyfin media server, photo manager, and remote filesystem manager:
- **Kata split.** Per user directive: *"kata containers should be called SysDeck Kata and moved out of the tools area. and dont call it hidden thats akward."* The v0.0.34 layout had Kata Containers demoted to a hidden "tools" entry inside the merged Containers & VMs panel — labeled "Kata Containers (hidden helper)" with priority -1, in `plugins/sysdeck-containers-kata/`. v0.0.35 splits Kata back out: renamed to **SysDeck Kata**, moved to `plugins/sysdeck-kata/`, converted from a `tools` manifest entry to a `menu` entry (label "SysDeck Kata", order 27), removed the "hidden helper" wording, dropped the priority -1, and restored a dedicated keywords list. The Containers panel now manages Podman only — the Kata tab and its iframe were removed. The pre-built cockpit-kata React bundle (`index.js` + `index.css`) is shipped unchanged.
- **Jellyfin media server module.** Per user directive: *"next we will integrate a jellyfin management module where it starts, stops, and loads the admin panel in the module."* New plugin `plugins/sysdeck-jellyfin/` + new bridge helper `bridge/jellyfin.py`. The bridge runs `systemctl start/stop/restart jellyfin.service` via the cockpit superuser channel (polkit `org.sysdeck.jellyfin.modify`); the panel iframes the running Jellyfin admin UI at `http://127.0.0.1:8096` — same pattern as the v0.0.34 Glances integration. Library list is best-effort via `GET /Library/VirtualFolders` on the local Jellyfin instance.
- **Photo manager module.** Per user directive: *"as well as a photo manager of equal quality. with its own module."* New plugin `plugins/sysdeck-photos/` + new bridge helper `bridge/photos.py`. Multi-backend design (same shape as the DB Control module): PhotoPrism (port 2342, MIT), Piwigo (port 80, GPL-2.0), Lychee (port 80, MIT), Nextcloud-Memories (port 80, AGPL-3.0), LibrePhotos (port 3000, MIT). Each backend is auto-detected; the bridge runs `systemctl start/stop/restart <service>` and the panel iframes its admin UI when running. Polkit action: `org.sysdeck.photos.modify`.
- **Remote FS manager module.** Per user directive: *"then a remote fs manager such as ceph, and others but not nfs or amanada fs."* New plugin `plugins/sysdeck-remotefs/` + new bridge helper `bridge/remotefs.py`. Multi-backend: Ceph (LGPL-2.1), GlusterFS (GPL-2.0), MooseFS (GPL-2.0), BeeGFS (BeeGFS EULA — free), OrangeFS (BSD-3). Each backend is auto-detected; the bridge runs `systemctl start/stop/restart <service>` and the cluster-info subcommand queries backend-specific cluster status (`ceph status --format=json`, `gluster pool list`, `moosefs-cli info`, `beegfs-ctl --listnodes`, `pvfs2-server -m`). Polkit action `org.sysdeck.remotefs.modify` authorizes the systemctl binary plus ceph / gluster / moosefs-cli / beegfs-ctl / pvfs2-server CLIs. **NFS and Amanda are explicitly EXCLUDED per directive** — documented in the panel footer and in `bridge/remotefs.py:EXCLUDED`.
- **Plugin count 20 → 23.** The v0.0.34 hidden helper (`sysdeck-containers-kata`) is renamed to `sysdeck-kata` and promoted to a visible sidebar entry; three new visible modules are added. `tests/check_manifest_consistency.py` expected count updated to 23. `scripts/generate-plugins.py` updated to back up + restore hand-maintained plugins (`sysdeck-kata` ships a pre-built React bundle that can't be regenerated by the suite generator).
### v0.0.34 highlights
v0.0.34 consolidates Containers + Kata into one module, integrates the Glances built-in web UI, and expands Themes + Mining to "1999 power-tool style" per user directive:
- **Containers + Kata consolidation.** Per user directive: "for the containers and kata containers will be merged into one module and replaced by this upload, i will merge this sub project into sysdeck directly and close the other project after this." The standalone `sysdeck-kata` plugin is removed; the Kata portion of the merged panel loads the pre-built cockpit-kata React app via iframe to a hidden helper plugin at `sysdeck-containers-kata/`. The visible sidebar entry is now **SysDeck Containers & VMs** (order 20) with two tabs: Podman Containers (vanilla JS panel calling `bridge.containers`) and Kata Sandboxes (iframe to the React app). The standalone cockpit-kata sub-project closes after this release.
- **Glances web UI integration.** Per user directive: "glances is not integrated yet i just assumed you would integrate the built in webui as a module." The bridge now ships `start-web / stop-web / web-status` subcommands that run `glances -w --bind 127.0.0.1 --port 61208` as a background process; the panel iframes the running web UI at `http://127.0.0.1:61208`. The full Glances web UI (every chart, every sensor, every top process, every history graph) is available without SysDeck re-implementing any of it. The existing snapshot cards (CPU / Memory / Swap / Network / Disk I/O / Processes) are kept for at-a-glance status.
- **Themes 1999 power-tool expansion.** Per user directive: "themes and mining they need to be expanded for maximum ui control. think 1999 power tool style here." The new `bridge/themes.py` surfaces: `read-config / write-config / get / set / unset / reset / preset-list / preset-apply / variable-list / variable-get / variable-set / variable-reset`. Six built-in presets (Midnight, Alpine, Forest, Amber, Violet, High Contrast) + operator-dropped JSON presets in `/var/lib/sysdeck/themes/presets/`. Twelve CSS variables (`--sysdeck-bg`, `--sysdeck-fg`, `--sysdeck-accent`, etc.) overridable live via `<input type=color>` / `<input type=number>` / `<select>` controls. The panel injects overrides as a `<style>` tag so the operator sees the new colors immediately.
- **Mining 1999 power-tool expansion.** The bridge now surfaces `summary / threads / pool-config-get / pool-config-set / threads-config-get / threads-config-set / algorithm-get / algorithm-set / pause / resume / pause-worker / resume-worker / start / stop / restart / service-status` — every XMRig REST API knob. The panel renders: summary stats (hashrate/pool/uptime), service controls (start/stop/restart `xmrig.service`), all-workers pause/resume, per-thread hashrate table with per-worker pause/resume buttons, pool config form, thread count form, algorithm picker with 7 RandomX variants.
### v0.0.33 highlights
v0.0.33 expands the Policy & Permissions module with the rest of the modern Linux LSM stack, applies a MoE (Mixture-of-Experts) QA pass across the codebase, and rewrites the project documentation:
- **Policy module — LSM expansion.** Per user directive: "lets now add smack, tomoyo, yama and others as well to the same policy module." Added **Smack**, **TOMOYO**, **Yama**, **LoadPin**, **Lockdown**, **BPF-LSM**, **Landlock**, plus **file capabilities (setcap/getcap)**. Each is **optional** — the bridge auto-detects via `/sys/kernel/security/<lsm>/` and the panel renders an enable hint with the kernel cmdline when the LSM is absent. **SELinux remains skipped** (native to the host distro). The `lsm-status` subcommand reads `/sys/kernel/security/lsm` and renders the active stack as a badge row in the panel header. The polkit `org.sysdeck.policy.modify` action now authorizes 30+ binaries across ACLs / cgroups / VLANs / eBPF / filecaps / AppArmor / Smack / TOMOYO.
- **MoE QA pass.** A senior QA analyst, senior Linux engineer, senior architect, senior admin, and project-manager-in-devops 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 (typed Python), POSIX (one function = one job, compose with pipes), SEI CERT (no `eval`, no `Function`, all spawn calls use the array form), and MISRA (limited cyclomatic complexity, single exit where practical) in mind. Step-down logic: when a fork of choices appeared, the option that composed best with the rest of the system won.
- **Documentation rewrite.** README, QUICKSTART, BLOG, and LICENSE rewritten with decisive language — no "restored / brought back / surviving artifact" wording. Every design choice is documented as a decision.
- **Polkit policy.** `org.sysdeck.policy.modify` extended to authorize `smackload`, `smackcipsos`, `tomoyo-setprofile`, `tomoyo-set-profile`, `tomoyo-savepolicy`, `tomoyo-init`, `setcap`, `getcap` (in addition to the v0.0.32 set: `setfacl`, `getfacl`, `mkdir`, `mount`, `ip`, `bpftool`, `lsns`, `aa-enforce`, `aa-complain`, `aa-status`).
### v0.0.32 highlights
v0.0.32 adds two modules — **Policy & Permissions** and **DB Control** — and brings the plugin count from 18 to 20:
- **Policy & Permissions module.** `cockpit-policy` — modern policy management and permissions manager for groups. 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). Bridge helper: `bridge/policy.py`. Polkit action: `org.sysdeck.policy.modify`.
- **DB Control module.** `cockpit-db` — unified control for SQL/NoSQL/vector/AI database engines. 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. The plugin panel renders per-family engine tables with Start/Stop/Restart buttons, a SQL query runner, and a connections viewer. All mutating operations run via the cockpit superuser channel (polkit `org.sysdeck.db.modify`) — no `sudo` shell-out from JS.
### v0.0.31 highlights
- **Firewall module — monitor → 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.
- **Packages module — sudo → cockpit way.** `update-all / install / remove / update` now actually run the package manager via subprocess; the JS panel passes `{ superuser: 'try' }` so polkit prompts the operator. Live output renders in an in-panel `<pre>` — no more `alert("Run this command with superuser privileges.")`.
- **Builder module — viewer → full-featured.** `build / profile-create / profile-delete / build-status / build-log / artifacts` subcommands. Builds stream stdout+stderr to `/var/lib/sysdeck/builder/logs/<build-id>.log`; state lives in `/var/lib/sysdeck/builder/state/<build-id>.json`; artifacts under `/var/lib/sysdeck/builder/artifacts/<profile>/`.
- **Fester rename.** Build orch panel menu label and panel title changed to "SysDeck Fester" per user directive.
### Two deployment shapes
| Shape | Use case | Lives at |
|-------|----------|----------|
| **Cockpit plugin** (default) | Drop into an existing cockpit install; access via `https://<host>:9090` | `/usr/share/cockpit/sysdeck-*/` |
| **Tarball source** | Build from source, customize, or contribute | `sysdeck-<version>/` source tree |
The cockpit plugin is the primary deliverable.
## Module catalog
| # | Module | Codename | Priority | Backend |
|---|--------|----------|----------|---------|
| 1 | Containers (Podman) | `cockpit-containers` | P0 | `podman ps` |
| 2 | Firewall Control | `cockpit-firewall` | P0 | `nft list ruleset` + template apply |
| 3 | Integrity Auditor | `cockpit-integrity` | P0 | `lynis audit system` |
| 4 | Network SOC | `cockpit-netsec` | P1 | `ss -tulpn` |
| 5 | Service Mesh | `cockpit-mesh` | P1 | `kubectl get svc` |
| 6 | Encryption Vault | `cockpit-vault` | P1 | `lsblk -J` |
| 7 | Fleet Compute | `cockpit-fleet` | P1 | `uptime`, cockpit peers |
| 8 | SysDeck Kata | `cockpit-kata` | P0 | kata-runtime (pre-built React app) |
| 9 | SysDeck Fester (build orchestration) | `cockpit-fester` | P1 | `systemctl list-units` |
| 10 | Firmware Control | `cockpit-firmware` | P2 | `fwupdmgr`, `tpm2_pcrread` |
| 11 | Image Builder | `cockpit-builder` | P2 | `mkosi` (Arch) / `vmdb2` (Debian) |
| 12 | Mining Dashboard (XMRig power tool) | `cockpit-mining` | P2 | XMRig REST API + service control |
| 13 | Theme Engine (1999 power tool) | `cockpit-themes` | P2 | `/etc/cockpit/cockpit.conf` + CSS variable surface + 6 presets |
| 14 | Hardware Auth | `cockpit-auth` | P2 | `pkcs11-tool`, `pcsc_scan` |
| 15 | System Monitor (Glances web UI) | `cockpit-glances` | P1 | `glances -w` (iframe) + snapshot cards |
| 16 | Hardware Sensors | `cockpit-sensors` | P1 | `sensors` (lm_sensors) |
| 17 | System Benchmark | `cockpit-benchmark` | P2 | `sysbench` |
| 18 | Package Manager | `cockpit-packages` | P1 | `pacman` / `dnf` / `apt` |
| 19 | Policy & Permissions | `cockpit-policy` | P1 | ACLs · cgroups v2 · VLANs · eBPF · namespaces · filecaps · LSM stack (AppArmor/Smack/TOMOYO/Yama/LoadPin/Lockdown/BPF-LSM/Landlock) |
| 20 | DB Control | `cockpit-db` | P1 | DB engine CLIs (SQL/NoSQL/vector/AI) |
| 21 | Jellyfin Media Server | `cockpit-jellyfin` | P1 | `systemctl start/stop/restart jellyfin.service` + admin UI iframe (port 8096) |
| 22 | Photo Manager | `cockpit-photos` | P1 | Multi-backend: PhotoPrism / Piwigo / Lychee / Nextcloud-Memories / LibrePhotos — start/stop + admin UI iframe |
| 23 | Remote FS Manager | `cockpit-remotefs` | P1 | Ceph / GlusterFS / MooseFS / BeeGFS / OrangeFS — start/stop + cluster-info (NFS & Amanda excluded per directive) |
| 24 | Prometheus | `cockpit-prometheus` | P1 | Prometheus pushgateway |
| 25 | Grafana | `cockpit-grafana` | P1 | Grafana API |
Each module fails closed when its backend tool is absent — the panel shows an install hint instead of crashing.
## Architecture
```
sysdeck-0.0.35/
├── Makefile # install / uninstall / check / dist / distcheck
├── manifest.json # not present (multi-plugin layout — see plugins/)
├── plugins/ # 23 standalone Cockpit plugins
│ ├── sysdeck-containers/ # v0.0.35: Podman only (Kata split out)
│ ├── sysdeck-firewall/
│ ├── sysdeck-integrity/
│ ├── sysdeck-netsec/
│ ├── sysdeck-mesh/
│ ├── sysdeck-vault/
│ ├── sysdeck-fleet/
│ ├── sysdeck-kata/ # v0.0.35: restored to standalone sidebar entry — pre-built cockpit-kata React app
│ ├── sysdeck-fester/
│ ├── sysdeck-firmware/
│ ├── sysdeck-builder/
│ ├── sysdeck-mining/
│ ├── sysdeck-themes/
│ ├── sysdeck-auth/
│ ├── sysdeck-glances/
│ ├── sysdeck-sensors/
│ ├── sysdeck-benchmark/
│ ├── sysdeck-packages/
│ ├── sysdeck-policy/ # Policy & Permissions module
│ ├── sysdeck-db/ # DB Control module
│ ├── sysdeck-jellyfin/ # v0.0.35: Jellyfin media server — start/stop + admin UI iframe
│ ├── sysdeck-photos/ # v0.0.35: Photo Manager — multi-backend start/stop + admin UI iframe
│ └── sysdeck-remotefs/ # v0.0.35: Remote FS Manager — Ceph/GlusterFS/MooseFS/BeeGFS/OrangeFS
├── shared/ # shared bridge.js + sysdeck.css + manifest.json
├── bridge/ # Python bridge helpers (called via cockpit.spawn)
│ ├── __init__.py # package init + distro detection
│ ├── containers.py # podman + systemd aggregation
│ ├── firewall.py # nft ruleset parser + template manager
│ ├── integrity.py # lynis audit runner
│ ├── firmware.py # fwupd + TPM PCR aggregation
│ ├── netsec.py # ss + nft counters aggregation
│ ├── fleet.py # local host + peer-hosts aggregation
│ ├── auth.py # pkcs11-tool + lsusb + pcscd state
│ ├── glances.py # v0.0.34: snapshot + start-web/stop-web
│ ├── sensors.py # lm_sensors normalization + alert thresholds
│ ├── benchmark.py # sysbench result parsing + baselines
│ ├── packages.py # pacman/dnf/apt unified package ops
│ ├── mining.py # v0.0.34: XMRig REST API power tool
│ ├── themes.py # v0.0.34: cockpit.conf + CSS variable surface
│ ├── policy.py # Policy & Permissions module (LSM stack)
│ ├── db.py # database engine control (32+ engines)
│ ├── jellyfin.py # v0.0.35: Jellyfin media server service control
│ ├── photos.py # v0.0.35: photo backend service control (5 backends)
│ ├── remotefs.py # v0.0.35: remote FS backend service control (5 backends, NFS/Amanda excluded)
│ ├── prometheus.py # Prometheus pushgateway log pipeline
│ ├── grafana.py # Grafana dashboard API
│ └── hwalert.py # hardware alert aggregation
├── firewall/ # v0.0.31 firewall templates
│ └── templates/
│ ├── vps-webserver.sh
│ └── no-services.sh
├── packaging/ # RPM spec + PKGBUILD + debian/ + setup.py + polkit/
├── compat/ # compat-manifest.json (per-distro dep matrix)
├── tests/ # unit tests + build-time guards
├── scripts/ # generate-plugins.py
├── prometheus/ # prometheus configs + grafana dashboards
├── standalone-plugins/ # external cockpit plugin sidebar registrations
├── docs/ # INSTALL.md
├── README.md
├── QUICKSTART.md
├── BLOG.md # release narrative
├── QA.md # QA notes per release
├── THIRD_PARTY.md # third-party attributions
├── LICENSE # MIT
├── worklog.md # per-task development log
├── sysdeck-diagnose.sh # diagnostic script (install issues)
└── cockpit-smoke-test.sh # smoke-test for cockpit itself
```
### Bridge layers
The bridge client is the only path to the system. It is layered so each concern can evolve independently:
| Layer | Responsibility | Module entry-point |
|-------|----------------|--------------------|
| **Transport** | Raw `cockpit.spawn` / `cockpit.file` / `cockpit.dbus` / `cockpit.metrics` | `rawSpawn`, `file`, `dbus`, `metricsTap` |
| **Resilience** | Retry with exponential backoff for transient failures | `withRetry` |
| **Pooling** | Collapse identical in-flight spawns into one bridge round-trip | `pooledSpawn` |
| **Permission** | Gate privileged calls on `cockpit.permission` state | `spawnPrivileged`, `permission` |
| **Per-module helpers** | Typed façade per domain (containers, firewall, policy, db, …) | `containers`, `firewall`, `policy`, `db`, … |
Panels import the per-module helpers and never touch the lower layers directly.
### Cross-cutting contracts
- **Cockpit manifest.** Each plugin's `manifest.json` registers it with cockpit under the `index` menu key. Cockpit serves `index.html` at `https://<host>:9090/cockpit/@localhost/sysdeck-<name>/index.html`.
- **cockpit.js.** The global `cockpit` object is loaded via `<script src="../base1/cockpit.js">` — a path relative to the plugin root that the cockpit-bridge resolves. The shared `bridge.js` accesses the global `window.cockpit` directly (the v0.0.22 `import cockpit from "../base1/cockpit.js"` pattern was broken because `cockpit.js` is a UMD/IIFE, not an ES module).
- **Module registry.** `scripts/generate-plugins.py` is the single declarative source for the 20-module catalog. Adding a module means appending one entry and dropping a plugin directory — no other wiring.
- **Python bridge.** The `bridge/` directory contains standalone CLI scripts invoked by absolute path: `python3 /usr/lib/sysdeck/bridge/<module>.py <subcommand> [args]`. No `python3 -m` flag, no `PYTHONPATH` magic (the v0.0.25 `-m sysdeck.bridge.<module>` pattern was broken because it required a nested Python package layout the install target never produced).
- **Polkit.** Privileged bridge operations run via the cockpit superuser channel: the JS panel passes `{ superuser: 'try' }` to `cockpit.spawn`, and the operator authenticates via polkit. The polkit policy at `/usr/share/polkit-1/actions/org.sysdeck.policy` defines eight privilege domains: `system.manage`, `firewall.modify`, `packages.modify`, `firmware.modify`, `vault.modify`, `builder.modify`, `fester.modify`, `policy.modify`, `db.modify`. No `sudo` shell-out from JS anywhere in the suite — this is the **cockpit way**.
## Quick start
See [QUICKSTART.md](./QUICKSTART.md) for the five-minute path. The short version:
```bash
tar xjf sysdeck-0.0.33.tar.bz2
cd sysdeck-0.0.33
sudo make install
sudo systemctl restart cockpit.socket
# open https://<host>:9090 → 20 "SysDeck <Name>" entries appear in the sidebar
```
## Coding standards
The codebase follows four reference standards, adapted to TypeScript/JavaScript/Python:
- **PEP 868 (spirit).** 4-space indentation in Python; 2-space in JS; trailing commas in multi-line literals. Type annotations on every public Python function.
- **POSIX.** Each function does one thing. Compose with pipes (event bus), not with hidden side effects. No function returns more than one type.
- **SEI CERT.** No `eval`, no `Function` constructor, no untrusted input reaching `spawn` without an allowlist. All `cockpit.spawn` calls use the array form.
- **MISRA (spirit).** Limited cyclomatic complexity per function. Single exit point where practical. No heap allocation in render hot paths.
### Refactor discipline
When modifying code, prefer in this order:
1. **Lookup table** — if the construct is a status-to-X mapping, use a `Record<string, X>` (JS) or `dict` / list-of-tuples (Python). The v0.0.33 policy module uses `LSM_PROBES`, `NON_LSM_CONCERNS`, `SMACK_FILE_MAP`, `TOMOYO_FILES`, and `YAMA_SCOPE_NAMES` for exactly this reason — adding a new LSM is one line in the table, not a new code path.
2. **Functional iterator** — `map` / `filter` / `reduce` / `flatMap` over `for` or `while`. The summary command in `bridge/policy.py` builds the entire capability matrix with two dict comprehensions over the lookup tables.
3. **Early return** — flatten nested `if` with guard clauses.
4. **Switch** — only when the case set is closed and a lookup table would be less readable.
When a fork of choices appears, apply **step-down logic**: pick the option that composes best with the rest of the system (Unix philosophy), document the decision in a comment, and move on.
### Comments
Code comments state decisions, not history. Use them to record *why* a non-obvious choice was made. Avoid "restored", "brought back", "was dropped", "surviving artifact", "previously" — these read as haphazard back-and-forth. Every comment should sound like a decisive decision.
## License
MIT — see [LICENSE](./LICENSE). Third-party attributions: see [THIRD_PARTY.md](./THIRD_PARTY.md). Author: Jeremy Anderson (<info@dcos.net>, <https://dcos.net>).
## Release notes
See [BLOG.md](./BLOG.md) for the v0.0.33 release narrative and prior-version history.
## Project history
See [worklog.md](./worklog.md) for the per-task development log.
## Detailed install
See [docs/INSTALL.md](./docs/INSTALL.md) for RPM, DEB, pip, and manual install paths.

311
THIRD_PARTY.md Executable file
View File

@ -0,0 +1,311 @@
# Third-Party Attributions
SysDeck integrates with external tools and plugins. Each
integration invokes the external tool as a **separate process** via
`cockpit.spawn` — no external code is bundled within the suite. The
suite (MIT) and the external tools remain independent programs.
This file satisfies the attribution requirements of the licenses listed
below and documents every external integration point.
---
## Bundled Dependencies (shipped with the suite)
None. The suite is self-contained MIT-licensed code with no vendored
third-party libraries.
---
## External Tool Integrations (invoked via cockpit.spawn)
These tools are called as separate processes. They must be installed
on the target system for their corresponding module to function. Each
module fails closed with an install hint when its tool is absent.
### Prometheus — Monitoring & Log Pipeline
| Field | Value |
|-------|-------|
| **Module** | `cockpit-prometheus` |
| **Tool** | Prometheus server / pushgateway |
| **License** | Apache-2.0 |
| **Author** | Prometheus Authors |
| **Source** | https://github.com/prometheus/prometheus |
| **Install** | `pacman -S prometheus` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.prometheus", "push-log"])` |
| **License compat** | MIT calling Apache-2.0 via subprocess — independent programs |
### Grafana — Dashboards & Visualization
| Field | Value |
|-------|-------|
| **Module** | `cockpit-grafana` |
| **Tool** | Grafana server |
| **License** | AGPL-3.0 |
| **Author** | Grafana Labs |
| **Source** | https://github.com/grafana/grafana |
| **Install** | `pacman -S grafana` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.grafana", "dashboards"])` |
| **License compat** | MIT calling AGPL-3.0 via subprocess — independent programs |
### DB Engines — Database Control
| Field | Value |
|-------|-------|
| **Module** | `cockpit-db` |
| **Tool** | `psql`, `mysql`, `sqlite3`, and others |
| **License** | PostgreSQL License (psql), GPL-2.0 (mysql), Public Domain (sqlite3) |
| **Author** | PostgreSQL Global Dev Group, Oracle, SQLite Contributors |
| **Source** | https://www.postgresql.org/, https://dev.mysql.com/, https://sqlite.org/ |
| **Install** | `pacman -S postgresql mysql sqlite` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.db", "summary"])` |
| **License compat** | MIT calling permissively-licensed CLIs via subprocess — independent programs |
### hwalert — Hardware Alert Aggregation
| Field | Value |
|-------|-------|
| **Module** | Bridge helper only (panel integration pending) |
| **Tool** | `sensors`, `smartctl`, `mcelog` |
| **License** | MIT / LGPL-2.1+ (lm_sensors), GPL-2.0 (smartmontools), GPL-2.0 (mcelog) |
| **Author** | Various |
| **Source** | https://github.com/lm-sensors/lm-sensors, https://github.com/smartmontools/smartmontools |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.hwalert", "summary"])` |
| **License compat** | MIT calling GPL/LGPL/MIT via subprocess — independent programs |
### Glances — System Monitor
| Field | Value |
|-------|-------|
| **Module** | `cockpit-glances` |
| **Tool** | `glances` CLI |
| **License** | GPL-3.0 |
| **Author** | Nicolargo |
| **Source** | https://github.com/nicolargo/glances |
| **Install** | `pip install glances` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.glances", "snapshot"])` |
| **License compat** | MIT calling GPL-3.0 via subprocess — independent programs |
### lm_sensors — Hardware Sensors
| Field | Value |
|-------|-------|
| **Module** | `cockpit-sensors` |
| **Tool** | `sensors` CLI (from lm_sensors) |
| **License** | MIT / LGPL-2.1+ (varies by component) |
| **Author** | lm_sensors project |
| **Source** | https://github.com/lm-sensors/lm-sensors |
| **Install** | `pacman -S lm_sensors` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.sensors", "summary"])` |
| **License compat** | MIT calling MIT/LGPL — compatible |
### cockpit-sensors — Standalone Cockpit Plugin (reference)
| Field | Value |
|-------|-------|
| **Module** | Referenced by `cockpit-sensors` (not bundled) |
| **License** | MIT |
| **Author** | ocristopfer |
| **Source** | https://github.com/ocristopfer/cockpit-sensors |
| **Note** | The suite provides its own sensor rendering panel. The standalone cockpit-sensors plugin may be installed separately via the in-suite **3rd-Party Modules** panel (v0.0.46+, see `plugins/sysdeck-modules/` and `bridge/modules3p.py`). The legacy `cockpit-module-pull.sh` script remains as a CLI fallback. |
### sysbench — System Benchmark
| Field | Value |
|-------|-------|
| **Module** | `cockpit-benchmark` |
| **Tool** | `sysbench` CLI |
| **License** | GPL-2.0 |
| **Author** | Alexey Kopytov |
| **Source** | https://github.com/akopytov/sysbench |
| **Install** | `pacman -S sysbench` |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.benchmark", "run-cpu"])` |
| **License compat** | MIT calling GPL-2.0 via subprocess — independent programs |
### cockpit-benchmark — Standalone Cockpit Plugin (reference)
| Field | Value |
|-------|-------|
| **Module** | Referenced by `cockpit-benchmark` (not bundled) |
| **License** | MIT |
| **Author** | ealier |
| **Source** | https://github.com/ealier/cockpit-benchmark |
| **Note** | The suite provides its own benchmark panel. The standalone cockpit-benchmark plugin may be installed separately. |
### pacman / dnf / apt — Package Manager
| Field | Value |
|-------|-------|
| **Module** | `cockpit-packages` |
| **Tool** | `pacman` (Arch), `dnf` (Fedora/RHEL), `apt` (Debian/Ubuntu) |
| **License** | GPL-2.0+ (pacman), GPL-2.0+ (dnf), GPL-2.0+ (apt) |
| **Author** | Pacman Development Team, RPM project, Debian project |
| **Source** | https://archlinux.org/pacman/, https://github.com/rpm-software-management/dnf, https://salsa.debian.org/apt-team/apt |
| **Install** | Pre-installed on respective distros |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.packages", "summary"])` |
| **License compat** | MIT calling GPL-2.0+ via subprocess — independent programs |
### cockpit-identities — Standalone Cockpit Plugin (reference)
| Field | Value |
|-------|-------|
| **Module** | Referenced by `cockpit-auth` identities extension (not bundled) |
| **License** | LGPL-2.1 |
| **Author** | cockpit-project |
| **Source** | https://github.com/cockpit-project/cockpit-identities |
| **Note** | The suite's auth identities enumeration invokes the same underlying tools (ssh-add, pkcs11-tool, klist). The standalone cockpit-identities plugin may be installed separately via the in-suite **3rd-Party Modules** panel (v0.0.46+, see `plugins/sysdeck-modules/` and `bridge/modules3p.py`). |
### OpenSSH — SSH Key Enumeration
| Field | Value |
|-------|-------|
| **Module** | `cockpit-auth` (identities) |
| **Tool** | `ssh-add`, `ssh-keygen` |
| **License** | BSD-2-Clause |
| **Author** | OpenBSD project |
| **Source** | https://www.openssh.com/ |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.auth", "ssh-keys"])` |
| **License compat** | MIT calling BSD-2-Clause — compatible |
### MIT Kerberos — Kerberos Principal Enumeration
| Field | Value |
|-------|-------|
| **Module** | `cockpit-auth` (identities) |
| **Tool** | `klist` |
| **License** | MIT (Kerberos) |
| **Author** | MIT Kerberos Consortium |
| **Source** | https://web.mit.edu/kerberos/ |
| **Integration** | `cockpit.spawn(["python3", "-m", "sysdeck.bridge.auth", "kerberos"])` |
| **License compat** | MIT calling MIT — compatible |
---
## External Tools Used by Pre-existing Modules (v0.0.10 and earlier)
Listed for completeness. All invoked as separate processes.
| Tool | Module | License | Author | Source |
|------|--------|---------|--------|--------|
| `podman` | cockpit-containers | Apache-2.0 | containers/podman | https://github.com/containers/podman |
| `nft` | cockpit-firewall | GPL-2.0 | netfilter project | https://git.netfilter.org/nftables |
| `lynis` | cockpit-integrity | GPL-3.0 | CISOfy | https://github.com/CISOfy/lynis |
| `ss` | cockpit-netsec | GPL-2.0 | iproute2 project | https://git.kernel.org/pub/scm/utils/iproute2/iproute2 |
| `kubectl` | cockpit-mesh | Apache-2.0 | Kubernetes | https://github.com/kubernetes/kubernetes |
| `lsblk` | cockpit-vault | GPL-2.0 | util-linux | https://git.kernel.org/pub/scm/utils/util-linux/util-linux |
| `fwupdmgr` | cockpit-firmware | LGPL-2.1+ | fwupd project | https://github.com/fwupd/fwupd |
| `tpm2_pcrread` | cockpit-firmware | BSD-3-Clause | tpm2-software | https://github.com/tpm2-software/tpm2-tools |
| `kata-runtime` | cockpit-kata | Apache-2.0 | kata-containers | https://github.com/kata-containers/kata-containers |
| `mkosi` | cockpit-builder | LGPL-2.1+ | systemd project | https://github.com/systemd/mkosi |
| `mkarchiso` | cockpit-builder | GPL-3.0 | Arch Linux | https://gitlab.archlinux.org/archlinux/archiso |
| `vmdb2` | cockpit-builder | GPL-3.0+ | LVM team (Debian) | https://gitlab.com/lvm-team/vmdb2 |
| `lb` (live-build) | cockpit-builder | GPL-3.0+ | Debian Live team | https://salsa.debian.org/live-team/live-build |
| `pkcs11-tool` | cockpit-auth | MIT (OpenSC) | OpenSC project | https://github.com/OpenSC/OpenSC |
### v0.0.35 — Jellyfin, Photos, Remote FS backends
| Tool | Module | License | Author | Source |
|------|--------|---------|--------|--------|
| `jellyfin` | cockpit-jellyfin | GPL-2.0 | Jellyfin contributors | https://github.com/jellyfin/jellyfin |
| `photoprism` | cockpit-photos | MIT | PhotoPrism contributors | https://github.com/photoprism/photoprism |
| `piwigo` | cockpit-photos | GPL-2.0 | Piwigo contributors | https://github.com/Piwigo/Piwigo |
| `lychee` | cockpit-photos | MIT | LycheeOrg | https://github.com/LycheeOrg/Lychee |
| `occ` (Nextcloud Memories) | cockpit-photos | AGPL-3.0 | pulsejet (memories) + Nextcloud | https://github.com/pulsejet/memories |
| `librephotos` | cockpit-photos | MIT | LibrePhotos contributors | https://github.com/LibrePhotos/librephotos |
| `ceph` | cockpit-remotefs | LGPL-2.1 | Ceph contributors | https://github.com/ceph/ceph |
| `gluster` | cockpit-remotefs | GPL-2.0 | GlusterFS contributors | https://github.com/gluster/glusterfs |
| `moosefs-cli` | cockpit-remotefs | GPL-2.0 | MooseFS contributors | https://github.com/moosefs/moosefs |
| `beegfs-ctl` | cockpit-remotefs | BeeGFS EULA (free) | BeeGFS / NetApp | https://www.beegfs.io/ |
| `pvfs2-server` | cockpit-remotefs | BSD-3 (OrangeFS) | OrangeFS / Omnibond | http://www.orangefs.org/ |
### Intentionally excluded (per v0.0.35 directive)
| Tool | Reason |
|------|--------|
| `nfsd` (NFS) | Kernel-builtin; no cluster; no remote-FS-as-data-store semantics. Use cockpit-nfs. |
| `amanda` (AMANDA) | Backup system (Advanced Maryland Automatic Network Disk Archiver), not a remote/distributed filesystem. Use a dedicated backup solution. |
---
## License Compatibility Summary
The suite is MIT licensed. All external tools are invoked as **separate
programs** via `cockpit.spawn` (subprocess). Under copyright law,
communicating with a separate program via pipes or sockets does not
create a combined work. The licenses of the external tools impose
obligations on the tools themselves, not on the suite.
For GPL-family tools (Glances, nft, lynis, sysbench, ss, lsblk, Grafana):
- The suite does not link against, embed, or distribute their code.
- The suite invokes them as subprocesses, which is permitted without
imposing GPL on the calling program.
- Users who distribute the suite alongside these tools should verify
their own compliance with each tool's license terms.
For MIT/LGPL/BSD/Apache tools: fully compatible with the suite's MIT license.
---
## v0.0.46 — In-Suite 3rd-Party Module Installer
Prior to v0.0.46, the only way to install third-party Cockpit modules
(45Drives Navigator, cockpit-pacman, cockpit-identities, etc.) was the
side-channel `cockpit-module-pull.sh` shell script. That script bundled
a single blanket license prompt at the top and wrote a post-install
text audit log — but it did not surface per-module license / credit /
install-plan disclosures BEFORE the pull, and it had no per-module
opt-out.
v0.0.46 replaces that flow with a first-class **3rd-Party Modules**
panel (`plugins/sysdeck-modules/`) backed by a Python bridge helper
(`bridge/modules3p.py`). Each catalog entry declares its license,
author, source URL, and install hook. The panel renders all four
fields INLINE in every row, right next to a 1-click Install button —
clicking Install is the operator's acceptance of the inline-displayed
license. No modal, no separate confirmation step.
The bridge itself refuses silent installs (no `--accept-license=1`
⇒ `license-not-accepted`) as a guard against malicious callers. The
JS always passes that flag because the license is rendered inline next
to the button — the click IS the acceptance gesture.
Every install / uninstall is appended to
`/etc/cockpit/MODULE_LICENSES.log` as a JSON record. Legacy plain-text
lines from `cockpit-module-pull.sh` are preserved as `{raw: ...}`
records, so a single audit view shows both old and new entries.
### Catalog entries (v0.0.46)
| Module | License | Author | Source | Kind |
|--------|---------|--------|--------|------|
| cockpit-machines | LGPL-2.1 | Cockpit Project | https://github.com/cockpit-project/cockpit-machines | pacman |
| cockpit-podman | LGPL-2.1 | Cockpit Project | https://github.com/cockpit-project/cockpit-podman | pacman |
| cockpit-storaged | LGPL-2.1 | Cockpit Project | https://github.com/cockpit-project/cockpit-storaged | pacman |
| cockpit-identities | LGPL-2.1 | Cockpit Project | https://github.com/cockpit-project/cockpit-identities | git |
| cockpit-navigator | GPL-3.0 | 45Drives | https://github.com/45Drives/cockpit-navigator | deb-tar |
| cockpit-file-sharing | GPL-3.0 | 45Drives | https://github.com/45Drives/cockpit-file-sharing | deb-tar |
| cockpit-zfs-manager | GPL-3.0 | 45Drives | https://github.com/45Drives/cockpit-zfs-manager | git |
| cockpit-pacman | GPL-3.0 | pfeifferj | https://github.com/pfeifferj/cockpit-pacman | git |
| cockpit-sensors | MIT | ocristopfer | https://github.com/ocristopfer/cockpit-sensors | tarball |
| cockpit-benchmark | MIT | ealier | https://github.com/ealier/cockpit-benchmark | git |
Adding a new module to the catalog is a single dict append to
`CATALOG` in `bridge/modules3p.py`. No per-module code branches.
### License compatibility
All entries are invoked as **separate processes** via
`cockpit.spawn`. The suite (MIT) does not link against, embed, or
distribute any of these modules. Under copyright law, communicating
with a separate program via pipes or sockets does not create a
combined work. The licenses of the upstream modules impose
obligations on the modules themselves, not on SysDeck.
For GPL-family modules (cockpit-navigator, cockpit-file-sharing,
cockpit-zfs-manager, cockpit-pacman): operators who distribute SysDeck
alongside these modules should verify their own compliance with each
module's license terms.
For MIT/LGPL/BSD/Apache modules: fully compatible with the suite's MIT
license.

118
bridge/__init__.py Executable file
View File

@ -0,0 +1,118 @@
"""
SysDeck - Python Bridge Helpers
Author: Jeremy Anderson (https://dcos.net)
This package contains helper scripts invoked from the JS bridge client
via cockpit.spawn. Each module is standalone and runnable as a CLI:
python3 /usr/lib/sysdeck/bridge/containers.py list
(v0.0.26+ invocation: absolute path, no PYTHONPATH, no -m flag.
The earlier `python3 -m sysdeck.bridge.containers` pattern was broken —
it required a nested Python package layout (sysdeck/bridge/containers.py)
that the Makefile install target never produced. bridge.js calls each
helper by absolute path.)
The helpers exist for operations that are too complex for a single CLI
call — e.g. cross-referencing podman and systemd, or aggregating TPM
PCR banks into a single JSON document.
"""
import os
import subprocess
from typing import Literal
__version__ = "0.2.0"
__author__ = "Jeremy Anderson"
__url__ = "https://dcos.net"
# ── Distro detection ────────────────────────────────────────────────
#
# Step-down: check /etc/os-release (standard across all modern distros),
# then fall back to checking which package manager is available.
# Returns a normalized distro identifier for use in dispatch tables.
DistroId = Literal["arch", "debian", "fedora", "rhel", "unknown"]
def detect_distro() -> DistroId:
"""Detect the running Linux distribution.
Priority order:
1. Parse /etc/os-release ID/ID_LIKE fields.
2. Fall back to package-manager presence (pacman → arch,
apt → debian, dnf → fedora).
Returns one of: 'arch', 'debian', 'fedora', 'rhel', 'unknown'.
"""
# Try /etc/os-release first (present on all modern distros).
try:
with open("/etc/os-release", encoding="utf-8") as fh:
os_release = dict(
line.split("=", 1) if "=" in line else ("", "")
for line in fh
if "=" in line
)
dist_id = os_release.get("ID", "").strip().strip('"').lower()
id_like = os_release.get("ID_LIKE", "").strip().strip('"').lower()
# Direct match on ID.
id_map = {"arch": "arch", "archlinux": "arch",
"debian": "debian", "ubuntu": "debian", "linuxmint": "debian", "pop": "debian",
"fedora": "fedora", "rhel": "rhel", "centos": "rhel", "rocky": "rhel", "alma": "rhel"}
if dist_id in id_map:
return id_map[dist_id]
# Fall back to ID_LIKE.
for like in id_like.split():
if like in id_map:
return id_map[like]
except (FileNotFoundError, PermissionError):
pass
# Fall back to package manager presence.
for cmd, distro in [("pacman", "arch"), ("apt", "debian"), ("dnf", "fedora")]:
try:
subprocess.run([cmd, "--version"], capture_output=True, check=True)
return distro
except (subprocess.CalledProcessError, FileNotFoundError):
continue
return "unknown"
# Detect once at import time — shared across all bridge modules.
DISTRO: DistroId = detect_distro()
# ── Package manager detection ───────────────────────────────────────
#
# Returns the command name for the system's package manager.
# Arch → pacman, Debian → apt, Fedora/RHEL → dnf.
PkgManager = Literal["pacman", "apt", "dnf", "unknown"]
def detect_pkg_manager() -> PkgManager:
"""Detect the system package manager based on distro."""
pkg_map: dict[DistroId, PkgManager] = {
"arch": "pacman",
"debian": "apt",
"fedora": "dnf",
"rhel": "dnf",
}
return pkg_map.get(DISTRO, "unknown")
PKG_MANAGER: PkgManager = detect_pkg_manager()
# ── Service management ──────────────────────────────────────────────
#
# All three target distros use systemd, so this is uniform.
# Kept here for documentation and future extension (e.g. openrc on Artix).
def service_cmd(action: str, unit: str) -> list[str]:
"""Build a systemctl command. All supported distros use systemd."""
return ["systemctl", action, unit]

260
bridge/auth.py Executable file
View File

@ -0,0 +1,260 @@
#!/usr/bin/env python3
"""
SysDeck - Auth Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates PKCS#11 token slots (opensc) with pcscd service state,
detected reader hardware (lsusb), and first-class identity objects
(PKCS#11 tokens, SSH keys, Kerberos principals) into a single JSON
document.
The identities subcommand enumerates identity objects that can be used
for authentication. It calls ssh-add -L, pkcs11-tool, and klist as
separate processes — the suite (MIT) and each tool remain independent
programs. No external code is bundled.
cockpit-identities (https://github.com/cockpit-project/cockpit-identities)
is LGPL-2.1 licensed by the cockpit-project. The suite's identities
enumeration invokes the same underlying tools; the standalone
cockpit-identities plugin may be installed separately and linked from
the sidebar.
Usage:
python3 -m sysdeck.bridge.auth summary
python3 -m sysdeck.bridge.auth slots
python3 -m sysdeck.bridge.auth readers
python3 -m sysdeck.bridge.auth identities
python3 -m sysdeck.bridge.auth ssh-keys
python3 -m sysdeck.bridge.auth kerberos
"""
import json
import os
import re
import subprocess
import sys
from typing import Any
IDENTITIES_LICENSE = "LGPL-2.1 (cockpit-identities)"
IDENTITIES_AUTHOR = "cockpit-project"
IDENTITIES_URL = "https://github.com/cockpit-project/cockpit-identities"
# Slot line: "Slot 0: Alcor Micro AU9540 00 00"
SLOT_RE = re.compile(r"^Slot\s+(?P<slot>\d+):\s+(?P<desc>.+)$")
# SSH key line: "ssh-rsa AAAA... comment"
SSH_KEY_RE = re.compile(r"^(?P<type>ssh-\S+|ecdsa-\S+|sk-\S+)\s+(?P<blob>\S+)(?:\s+(?P<comment>.+))?$")
# Kerberos principal line from klist: " user@REALM krbtgt/REALM@REALM"
KLIST_PRINCIPAL_RE = re.compile(r"^\s*Default principal:\s+(?P<principal>\S+)")
KLIST_TICKET_RE = re.compile(r"^\s*(?P<start>\S+)\s+(?P<end>\S+)\s+(?P<renew>\S+)\s+(?P<kvno>\S+)\s+(?P<principal>\S+)")
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
def slots() -> list[dict[str, str]]:
"""PKCS#11 token slots from opensc."""
raw = run(["pkcs11-tool", "--list-token-slots"])
parsed: list[dict[str, str]] = []
for line in raw.splitlines():
m = SLOT_RE.match(line)
if m:
parsed.append({
"slot": m.group("slot"),
"description": m.group("desc").strip(),
})
return parsed
def readers() -> list[dict[str, str]]:
"""Smartcard readers detected by lsusb (vendor:product filtered)."""
raw = run(["lsusb"])
return [
{"description": line.strip()}
for line in raw.splitlines()
if any(needle in line.lower() for needle in ("smart", "card", "reader", "pcsc"))
]
def pcscd_state() -> str:
"""pcscd.service state via systemctl."""
raw = run(["systemctl", "is-active", "pcscd"]).strip()
return raw or "unknown"
def ssh_keys() -> list[dict[str, Any]]:
"""SSH keys from ssh-add -L and ~/.ssh/."""
identities: list[dict[str, Any]] = []
# Keys loaded in the SSH agent
raw = run(["ssh-add", "-L"])
for line in raw.splitlines():
m = SSH_KEY_RE.match(line)
if m:
key_type = m.group("type")
# Derive key bits from type (heuristic — ssh-keygen -l gives exact bits)
bits_map = {"ssh-rsa": 4096, "ssh-dss": 1024, "ecdsa-sha2-nistp256": 256,
"ecdsa-sha2-nistp384": 384, "ecdsa-sha2-nistp521": 521,
"ssh-ed25519": 256, "sk-ssh-ed25519@openssh.com": 256}
identities.append({
"keyType": key_type,
"bits": bits_map.get(key_type, 0),
"fingerprint": m.group("blob")[:32] + "...",
"comment": m.group("comment") or "",
"path": "ssh-agent",
"passphrase": False,
"agentLoaded": True,
})
# Keys in ~/.ssh/ not in agent
ssh_dir = os.path.expanduser("~/.ssh")
if os.path.isdir(ssh_dir):
for fname in os.listdir(ssh_dir):
fpath = os.path.join(ssh_dir, fname)
if (fname.endswith(".pub") or fname.startswith(".")
or fname in ("known_hosts", "authorized_keys", "config")):
continue
if os.path.isfile(fpath):
# Heuristic: private key files don't have extensions like .pub, .old
identities.append({
"keyType": "unknown",
"bits": 0,
"fingerprint": "",
"comment": fname,
"path": fpath,
"passphrase": True,
"agentLoaded": any(k["path"] == "ssh-agent" and fname in k.get("comment", "") for k in identities),
})
return identities
def kerberos() -> list[dict[str, Any]]:
"""Kerberos ticket-granting tickets from klist."""
principals: list[dict[str, Any]] = []
raw = run(["klist"])
default_principal = None
for line in raw.splitlines():
m = KLIST_PRINCIPAL_RE.match(line)
if m:
default_principal = m.group("principal")
if default_principal:
user, realm = default_principal.split("@") if "@" in default_principal else (default_principal, "")
principals.append({
"principal": default_principal,
"realm": realm,
"kdc": "",
"startTime": "",
"endTime": "",
"renewUntil": "",
"keyType": "aes256-cts",
"kvno": 1,
"flags": [],
})
return principals
def identities() -> dict[str, Any]:
"""Aggregate all identity objects: PKCS#11 tokens, SSH keys, Kerberos principals."""
pkcs11_slots = slots()
ssh = ssh_keys()
krb = kerberos()
all_identities: list[dict[str, Any]] = []
# PKCS#11 tokens as identity objects
for i, s in enumerate(pkcs11_slots):
all_identities.append({
"id": f"pkcs11-{i}",
"type": "pkcs11-token",
"name": s.get("description", f"Slot {s.get('slot', i)}"),
"status": "active",
"createdAt": "",
"details": {
"slot": int(s.get("slot", i)),
"label": s.get("description", ""),
"manufacturer": "",
"model": "",
"serial": "",
"tokenType": "PKCS#11",
"flags": [],
"algorithms": [],
},
})
# SSH keys as identity objects
for i, k in enumerate(ssh):
all_identities.append({
"id": f"ssh-{i}",
"type": "ssh-key",
"name": k.get("comment") or k.get("path", f"key-{i}"),
"status": "active" if k.get("agentLoaded") else "inactive",
"createdAt": "",
"details": k,
})
# Kerberos principals as identity objects
for i, p in enumerate(krb):
all_identities.append({
"id": f"krb-{i}",
"type": "kerberos-principal",
"name": p.get("principal", f"principal-{i}"),
"status": "active" if p.get("endTime") else "expired",
"createdAt": p.get("startTime", ""),
"expiresAt": p.get("endTime", ""),
"details": p,
})
return {
"pkcs11Tokens": len(pkcs11_slots),
"sshKeys": len(ssh),
"kerberosPrincipals": len(krb),
"identities": all_identities,
}
def summary() -> dict[str, Any]:
"""Aggregate slots + readers + pcscd state + identities."""
return {
"slots": slots(),
"readers": readers(),
"pcscdState": pcscd_state(),
"identities": identities(),
}
COMMANDS = {
"summary": lambda _args: summary(),
"slots": lambda _args: slots(),
"readers": lambda _args: readers(),
"identities": lambda _args: identities(),
"ssh-keys": lambda _args: ssh_keys(),
"kerberos": lambda _args: kerberos(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

169
bridge/benchmark.py Executable file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
SysDeck - Benchmark Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Wraps system benchmark operations using sysbench and phoronix-test-suite,
with attribution to the cockpit-benchmark plugin
(https://github.com/ealier/cockpit-benchmark) which is MIT licensed
by ealier.
This bridge helper invokes benchmark tools as separate processes — the
suite (MIT) and sysbench (GPL-2.0) / phoronix-test-suite (GPL-3.0)
remain independent programs. No cockpit-benchmark code is bundled.
Usage:
python3 -m sysdeck.bridge.benchmark list-tests
python3 -m sysdeck.bridge.benchmark run-cpu
python3 -m sysdeck.bridge.benchmark run-memory
python3 -m sysdeck.bridge.benchmark run-io
python3 -m sysdeck.bridge.benchmark phoronix-list
"""
import json
import subprocess
import sys
from typing import Any
BENCHMARK_LICENSE = "MIT (cockpit-benchmark) + GPL-2.0 (sysbench)"
BENCHMARK_AUTHOR = "ealier (cockpit-benchmark), sysbench project"
BENCHMARK_URL = "https://github.com/ealier/cockpit-benchmark"
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True, timeout=300,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return ""
def list_tests() -> list[dict[str, str]]:
"""List available sysbench tests."""
raw = run(["sysbench", "--help"])
# Parse available test names from sysbench --help output.
tests: list[dict[str, str]] = []
known_tests = ["cpu", "memory", "fileio", "threads", "oltp_read_only", "oltp_write_only"]
for name in known_tests:
if name in raw:
tests.append({"name": name, "tool": "sysbench"})
return tests
def run_cpu() -> dict[str, Any]:
"""Run sysbench CPU benchmark and return structured results."""
raw = run(["sysbench", "cpu", "run"])
return _parse_sysbench(raw)
def run_memory() -> dict[str, Any]:
"""Run sysbench memory benchmark and return structured results."""
raw = run(["sysbench", "memory", "run"])
return _parse_sysbench(raw)
def run_io() -> dict[str, Any]:
"""Run sysbench file I/O benchmark and return structured results."""
# Prepare test files first.
run(["sysbench", "fileio", "prepare"])
raw = run(["sysbench", "fileio", "run"])
run(["sysbench", "fileio", "cleanup"])
return _parse_sysbench(raw)
def run_test(args: list[str]) -> dict[str, Any]:
"""Run an arbitrary sysbench test by name.
The benchmark.js panel lists tests returned by ``list-tests`` (cpu,
memory, fileio, threads, oltp_read_only, oltp_write_only) and renders
a "Run" button next to each. Clicking the button calls this helper
with the test name as the first argument.
Returns the parsed sysbench result (same shape as ``run_cpu`` etc.):
``{"raw": <str>, "events_per_sec": <float|None>, "latency_ms": <float|None>}``.
On failure (sysbench absent or the test name unknown), the captured
stderr is surfaced via the ``raw`` field so the panel can render a
useful message instead of an opaque empty result.
"""
if not args:
return {
"raw": "",
"events_per_sec": None,
"latency_ms": None,
"error": "no test name provided",
}
test_name = args[0]
# Some sysbench tests (fileio) require a prepare step before run.
# We deliberately keep this simple — for arbitrary test names, just
# invoke ``sysbench <name> run``. If the user wants fileio with
# prepare/cleanup, they should use the dedicated run-io button.
proc = subprocess.run(
["sysbench", test_name, "run"],
capture_output=True, text=True, timeout=300,
)
raw = proc.stdout if proc.returncode == 0 else (
proc.stdout + ("\n--- stderr ---\n" + proc.stderr if proc.stderr else "")
)
parsed = _parse_sysbench(raw)
if proc.returncode != 0:
parsed["error"] = f"sysbench exited {proc.returncode}"
return parsed
def phoronix_list() -> list[dict[str, str]]:
"""List available Phoronix Test Suite benchmarks."""
raw = run(["phoronix-test-suite", "list-tests"])
return [
{"name": line.strip(), "tool": "phoronix-test-suite"}
for line in raw.splitlines()
if line.strip() and not line.strip().startswith("#")
][:20] # Cap at 20 entries for display.
def _parse_sysbench(output: str) -> dict[str, Any]:
"""Parse sysbench text output into structured data."""
result: dict[str, Any] = {"raw": output, "events_per_sec": None, "latency_ms": None}
for line in output.splitlines():
if "events per second:" in line.lower():
try:
result["events_per_sec"] = float(line.split(":")[-1].strip())
except ValueError:
pass
if "avg:" in line.lower() and "latency" not in result:
parts = line.split()
for i, p in enumerate(parts):
if p == "avg:" and i + 1 < len(parts):
try:
result["latency_ms"] = float(parts[i + 1])
except ValueError:
pass
return result
COMMANDS = {
"list-tests": lambda _args: list_tests(),
"run-cpu": lambda _args: run_cpu(),
"run-memory": lambda _args: run_memory(),
"run-io": lambda _args: run_io(),
"run-test": lambda args: run_test(args),
"phoronix-list": lambda _args: phoronix_list(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

2071
bridge/builder.py Executable file

File diff suppressed because it is too large Load Diff

76
bridge/containers.py Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
SysDeck - Containers Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates podman container state with systemd unit status so the JS
panel can render a unified view in a single spawn call.
Usage:
python3 -m sysdeck.bridge.containers list
python3 -m sysdeck.bridge.containers inspect <id>
"""
import json
import subprocess
import sys
from typing import Any
def run(argv: list[str]) -> str:
"""Run a command and return stdout. Raises CalledProcessError on failure."""
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
def list_containers() -> list[dict[str, Any]]:
"""Return containers with their matching systemd unit name (if any)."""
try:
raw = run(["podman", "ps", "-a", "--format", "json"])
containers = json.loads(raw) if raw.strip() else []
except (subprocess.CalledProcessError, FileNotFoundError):
return []
# Step-down: enrich each container with its systemd scope unit.
# cgroup name pattern: /machine.slice/libpod-<id>.scope
return [
{
"id": c.get("Id", "")[:12],
"name": c.get("Names", [""])[0],
"image": c.get("Image", ""),
"status": c.get("Status", ""),
"state": c.get("State", ""),
"systemdUnit": f"libpod-{c.get('Id', '')}.scope",
}
for c in containers
]
def inspect(container_id: str) -> dict[str, Any]:
"""Inspect a single container by ID prefix."""
raw = run(["podman", "inspect", container_id])
data = json.loads(raw)
return data[0] if data else {}
COMMANDS = {
"list": lambda _args: list_containers(),
"inspect": lambda args: inspect(args[0]) if args else {},
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

390
bridge/db.py Executable file
View File

@ -0,0 +1,390 @@
#!/usr/bin/env python3
"""SysDeck - DB Control Bridge
Unified control surface for every database engine on the host.
Detects running services, queries status, and dispatches actions.
Supported engine families:
SQL: PostgreSQL, MySQL/MariaDB, SQLite, CockroachDB, TiDB
NoSQL: MongoDB, CouchDB, RethinkDB, DynamoDB-local
Vector: Milvus, Qdrant, Weaviate, Chroma, pgvector
TimeSeries: InfluxDB, TimescaleDB, QuestDB, ClickHouse
Graph: Neo4j, ArangoDB, OrientDB
Embedded: Redis, KeyDB, LevelDB, RocksDB, LMDB, BadgerDB
Cloud: Firestore-emulator, Supabase-local
AI: LanceDB, DuckDB, Tile38
Subcommands:
summary - list all detected engines with status
status <id> - detailed status for one engine
start <id> - start engine via systemctl
stop <id> - stop engine via systemctl
restart <id> - restart engine via systemctl
query <id> <sql> - execute a query (SQL engines only)
backup <id> - trigger a backup
connections <id> - list active connections
Cockpit way (v0.0.31+ pattern, applied here in v0.0.32): mutating
ops (start / stop / restart / query) run via subprocess directly —
no `sudo` shell-out. The JS panel passes { superuser: 'try' } to
cockpit.spawn so the cockpit bridge prompts the operator via polkit
for the org.sysdeck.db.modify action (added in v0.0.32 — authorizes
/usr/bin/systemctl for engine service control). The bridge runs as
the cockpit user and gets root privileges via polkit when the
operator authenticates.
Author: Jeremy Anderson (https://dcos.net)
"""
import json
import subprocess
import sys
import os
import re
from datetime import datetime
# ── Engine Registry ──────────────────────────────────────────────
# Each entry: (id, name, family, default_port, systemd_unit, cli_tool, config_paths, log_paths)
ENGINE_REGISTRY = [
# SQL
("postgresql", "PostgreSQL", "sql", 5432, "postgresql.service", "psql", ["/etc/postgresql/postgresql.conf", "/var/lib/pgsql/data/postgresql.conf"], ["/var/log/postgresql/"]),
("mariadb", "MariaDB", "sql", 3306, "mariadb.service", "mariadb", ["/etc/my.cnf", "/etc/my.cnf.d/"], ["/var/log/mariadb/"]),
("mysql", "MySQL", "sql", 3306, "mysqld.service", "mysql", ["/etc/my.cnf", "/etc/mysql/"], ["/var/log/mysql/"]),
("sqlite", "SQLite", "sql", 0, "", "sqlite3", [], []),
("cockroachdb", "CockroachDB", "sql", 26257, "cockroachdb.service", "cockroach", ["/etc/cockroachdb/"], ["/var/log/cockroachdb/"]),
("tidb", "TiDB", "sql", 4000, "tidb.service", "tidb", ["/etc/tidb/"], ["/var/log/tidb/"]),
# NoSQL
("mongodb", "MongoDB", "nosql", 27017, "mongod.service", "mongosh", ["/etc/mongod.conf"], ["/var/log/mongodb/"]),
("couchdb", "CouchDB", "nosql", 5984, "couchdb.service", "curl", ["/etc/couchdb/"], ["/var/log/couchdb/"]),
("rethinkdb", "RethinkDB", "nosql", 28015, "rethinkdb.service", "rethinkdb", ["/etc/rethinkdb/"], ["/var/log/rethinkdb/"]),
("dynamodb-local", "DynamoDB Local", "nosql", 8000, "dynamodb-local.service", "aws", [], []),
# Vector / Embedding / AI-native
("milvus", "Milvus", "vector", 19530, "milvus.service", "milvus-cli", ["/etc/milvus/milvus.yaml"], ["/var/log/milvus/"]),
("qdrant", "Qdrant", "vector", 6333, "qdrant.service", "curl", ["/etc/qdrant/config.yaml"], ["/var/log/qdrant/"]),
("weaviate", "Weaviate", "vector", 8080, "weaviate.service", "curl", ["/etc/weaviate/"], ["/var/log/weaviate/"]),
("chroma", "Chroma", "vector", 8000, "chroma.service", "curl", [], []),
("pgvector", "pgvector", "vector", 5432, "postgresql.service", "psql", ["/etc/postgresql/"], ["/var/log/postgresql/"]),
# Time-series
("influxdb", "InfluxDB", "timeseries", 8086, "influxdb.service", "influx", ["/etc/influxdb/"], ["/var/log/influxdb/"]),
("timescaledb", "TimescaleDB", "timeseries", 5432, "postgresql.service", "psql", ["/etc/postgresql/"], ["/var/log/postgresql/"]),
("questdb", "QuestDB", "timeseries", 9000, "questdb.service", "curl", ["/etc/questdb/"], ["/var/log/questdb/"]),
("clickhouse", "ClickHouse", "timeseries", 8123, "clickhouse.service", "clickhouse-client", ["/etc/clickhouse-server/"], ["/var/log/clickhouse-server/"]),
# Graph
("neo4j", "Neo4j", "graph", 7474, "neo4j.service", "cypher-shell", ["/etc/neo4j/neo4j.conf"], ["/var/log/neo4j/"]),
("arangodb", "ArangoDB", "graph", 8529, "arangodb.service", "arangosh", ["/etc/arangodb3/"], ["/var/log/arangodb3/"]),
("orientdb", "OrientDB", "graph", 2480, "orientdb.service", "console.sh", ["/etc/orientdb/"], ["/var/log/orientdb/"]),
# Embedded / KV
("redis", "Redis", "embedded", 6379, "redis.service", "redis-cli", ["/etc/redis/redis.conf"], ["/var/log/redis/"]),
("keydb", "KeyDB", "embedded", 6379, "keydb.service", "keydb-cli", ["/etc/keydb/"], ["/var/log/keydb/"]),
("valkey", "ValKey", "embedded", 6379, "valkey.service", "valkey-cli", ["/etc/valkey/"], ["/var/log/valkey/"]),
("rocksdb", "RocksDB", "embedded", 0, "", "rocksdb", [], []),
("lmdb", "LMDB", "embedded", 0, "", "python3", [], []),
("badgerdb", "BadgerDB", "embedded", 0, "", "badger", [], []),
# Cloud-local
("firestore-emulator", "Firestore Emulator", "cloud", 8080, "firestore-emulator.service", "gcloud", [], []),
("supabase-local", "Supabase Local", "cloud", 54321, "supabase.service", "supabase", [], []),
# AI / Analytical / Geospatial-AI
("lancedb", "LanceDB", "ai", 0, "", "lancedb", [], []),
("duckdb", "DuckDB", "ai", 0, "", "duckdb", [], []),
("tile38", "Tile38", "ai", 9851, "tile38.service", "tile38-cli", ["/etc/tile38/"], ["/var/log/tile38/"]),
]
def run(cmd, timeout=10):
"""Run a command, return stdout or empty string."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
except Exception:
return ""
def run_rc(cmd, timeout=10):
"""Run a command, return (rc, stdout, stderr) — never raises.
v0.0.32 added so the start/stop/restart subcommands can surface
the actual exit code and stderr to the JS panel rather than
discarding them like the old `run()` helper did.
"""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip()
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def systemctl_status(unit):
"""Return (active, sub, uptime_seconds) for a systemd unit."""
out = run(["systemctl", "show", unit, "--property=ActiveState,SubState,ActiveEnterTimestamp"], timeout=5)
active = "unknown"
sub = "unknown"
uptime = 0
for line in out.splitlines():
k, _, v = line.partition("=")
if k == "ActiveState":
active = v
elif k == "SubState":
sub = v
elif k == "ActiveEnterTimestamp":
try:
# systemd timestamp format: Day YYYY-MM-DD HH:MM:SS TZ
dt = datetime.strptime(v[:25], "%a %Y-%m-%d %H:%M:%S")
uptime = int((datetime.now() - dt).total_seconds())
except Exception:
pass
return active, sub, uptime
def detect_engine(entry):
"""Build a DbEngine dict from a registry entry."""
eid, name, family, port, unit, cli, configs, logs = entry
# Check if CLI is available
cli_available = bool(run(["which", cli]))
# Check systemd unit
if unit:
active, sub, uptime = systemctl_status(unit)
if active == "active":
status = "running"
elif active == "activating":
status = "starting"
elif active == "failed":
status = "error"
elif cli_available or sub == "dead":
status = "stopped"
else:
status = "uninstalled"
else:
# No systemd unit — detect by port or CLI
if port > 0:
port_check = run(["ss", "-tlnp"])
if f":{port} " in port_check:
status = "running"
uptime = 0
elif cli_available:
status = "stopped"
uptime = 0
else:
status = "uninstalled"
uptime = 0
elif cli_available:
status = "stopped"
uptime = 0
else:
status = "uninstalled"
uptime = 0
# Detect version
version = ""
if cli_available:
v_out = run([cli, "--version"], timeout=5)
# Take first line, strip to 40 chars
version = (v_out.splitlines()[0] if v_out else "")[:40]
# Detect data size
size_mb = 0
# Try common data directories
data_dirs = [f"/var/lib/{eid}", f"/var/lib/{name.lower().replace(' ', '')}"]
if eid == "postgresql":
data_dirs.append("/var/lib/pgsql/data")
elif eid in ("mysql", "mariadb"):
data_dirs.append("/var/lib/mysql")
elif eid == "mongodb":
data_dirs.append("/var/lib/mongo")
elif eid == "redis":
data_dirs.append("/var/lib/redis")
elif eid == "influxdb":
data_dirs.append("/var/lib/influxdb")
elif eid == "clickhouse":
data_dirs.append("/var/lib/clickhouse")
elif eid == "neo4j":
data_dirs.append("/var/lib/neo4j")
for d in data_dirs:
du_out = run(["du", "-sm", d], timeout=5)
m = re.match(r'(\d+)', du_out)
if m:
size_mb = int(m.group(1))
break
# Detect memory (RSS) via ps if running
memory_mb = 0
if status == "running" and unit:
ps_out = run(["ps", "-o", "rss=", "-C", cli] if cli else ["systemctl", "show", unit, "--property=MemoryCurrent"])
try:
vals = [int(x) for x in ps_out.split() if x.isdigit()]
if vals:
memory_mb = sum(vals) // 1024 # KB -> MB
except ValueError:
pass
# Detect connections
connections = 0
if status == "running" and port > 0:
ss_out = run(["ss", "-tnp"])
connections = ss_out.count(f":{port} ")
# Find first existing config
config_path = ""
for c in configs:
if os.path.exists(c):
config_path = c
break
# Find first existing log dir
log_path = ""
for l in logs:
if os.path.exists(l):
log_path = l
break
return {
"id": eid,
"name": name,
"family": family,
"status": status,
"version": version,
"port": port,
"dataDir": data_dirs[0] if data_dirs else "",
"uptime": uptime,
"connections": connections,
"sizeMB": size_mb,
"memoryMB": memory_mb,
"serviceUnit": unit,
"cli": cli,
"configPath": config_path,
"logPath": log_path,
"supported": True,
}
def cmd_summary():
"""Return summary of all detected engines."""
engines = [detect_engine(e) for e in ENGINE_REGISTRY]
running = [e for e in engines if e["status"] == "running"]
return {
"engines": engines,
"totalEngines": len(engines),
"runningCount": len(running),
"totalSizeMB": sum(e["sizeMB"] for e in engines),
"totalMemoryMB": sum(e["memoryMB"] for e in engines),
"totalConnections": sum(e["connections"] for e in engines),
}
def cmd_status(engine_id):
"""Detailed status for a single engine."""
for e in ENGINE_REGISTRY:
if e[0] == engine_id:
return detect_engine(e)
return {"error": f"Unknown engine: {engine_id}"}
def cmd_start(engine_id):
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from
# the bridge fails when the cockpit user has no passwordless sudo
# (the typical case). The cockpit way: the JS panel passes
# { superuser: 'try' } to cockpit.spawn so the cockpit bridge
# prompts the operator via polkit for the org.sysdeck.db.modify
# action. The bridge runs systemctl directly as root (the cockpit
# superuser channel escalates privileges via polkit when the
# operator authenticates).
rc, out, err = run_rc(["systemctl", "start", f"{engine_id}.service"], timeout=30)
return {"action": "start", "engine": engine_id, "rc": rc,
"output": out or err or "started", "success": rc == 0,
"stderr": err}
def cmd_stop(engine_id):
rc, out, err = run_rc(["systemctl", "stop", f"{engine_id}.service"], timeout=30)
return {"action": "stop", "engine": engine_id, "rc": rc,
"output": out or err or "stopped", "success": rc == 0,
"stderr": err}
def cmd_restart(engine_id):
rc, out, err = run_rc(["systemctl", "restart", f"{engine_id}.service"], timeout=30)
return {"action": "restart", "engine": engine_id, "rc": rc,
"output": out or err or "restarted", "success": rc == 0,
"stderr": err}
def cmd_connections(engine_id):
"""List active connections for an engine (best-effort)."""
for e in ENGINE_REGISTRY:
if e[0] == engine_id:
port = e[3]
if port <= 0:
return {"connections": [], "count": 0}
ss_out = run(["ss", "-tnp", f"sport = {port}"])
lines = [l for l in ss_out.splitlines() if "ESTAB" in l]
return {"connections": lines, "count": len(lines)}
return {"error": f"Unknown engine: {engine_id}"}
def cmd_query(engine_id, sql):
"""Execute a SQL query against an engine (SQL family only)."""
# Safety: refuse DDL/DML for certain contexts
for e in ENGINE_REGISTRY:
if e[0] == engine_id:
family, cli = e[2], e[5]
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
return {"error": "Query only supported for SQL-family engines"}
if cli == "psql":
out = run(["psql", "-tAc", sql], timeout=30)
elif cli in ("mysql", "mariadb"):
out = run(["mysql", "-e", sql], timeout=30)
elif cli == "cockroach":
out = run(["cockroach", "sql", "-e", sql], timeout=30)
elif cli == "clickhouse-client":
out = run(["clickhouse-client", "-q", sql], timeout=30)
elif cli == "sqlite3":
out = run(["sqlite3", sql], timeout=30)
else:
return {"error": f"No query handler for {cli}"}
return {"output": out, "engine": engine_id, "query": sql}
return {"error": f"Unknown engine: {engine_id}"}
def main():
if len(sys.argv) < 2:
print(json.dumps(cmd_summary()))
return
cmd = sys.argv[1]
if cmd == "summary":
print(json.dumps(cmd_summary()))
elif cmd == "status":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_status(eid)))
elif cmd == "start":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_start(eid)))
elif cmd == "stop":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_stop(eid)))
elif cmd == "restart":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_restart(eid)))
elif cmd == "connections":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_connections(eid)))
elif cmd == "query":
eid = sys.argv[2] if len(sys.argv) > 2 else ""
sql = sys.argv[3] if len(sys.argv) > 3 else ""
print(json.dumps(cmd_query(eid, sql)))
else:
print(json.dumps({"error": f"Unknown command: {cmd}"}))
if __name__ == "__main__":
main()

287
bridge/fester.py Executable file
View File

@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""
SysDeck - Fester Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.2.0 REAL INTEGRATION. The v0.1.x helper was a stub: its only
subcommand (`build-jobs`) listed systemd units whose name contained
"fester" or "build" — it never talked to a build orchestrator. The
Cockpit edition now ships against the vendored fester service (the
same one the Web Edition runs): web/mini-services/fester, a
distributed DAG build orchestrator speaking REST + WebSocket on
127.0.0.1:3010.
This helper is a thin stdlib-only REST client (urllib.request + json,
4s timeout — no requests library, no curl dependency). Every
subcommand prints the service's JSON response; `status` additionally
enriches the health payload with the local connection facts. HTTP
error bodies (409 cancel conflicts, 404 unknown builds, 400
validation) are JSON on this service and are surfaced verbatim.
Connection failures are graceful, like the other bridge helpers:
{"ok": false, "error": ...} with a remediation hint, exit code 0.
Subcommands:
status GET /api/health (+ port / transport / base_url)
metrics GET /api/metrics
builds GET /api/builds (live builds + history)
build <id> GET /api/builds/<id>
nodes GET /api/nodes
targets GET /api/targets (project catalog)
timeline <id> GET /api/timeline/<id>
sessions GET /api/sessions
start-build --project <p> --targets <csv>
[--no-cache] [--retries <0-3>] [--fail-action <a>]
POST /api/build → build_id
cancel <id> POST /api/builds/<id>/cancel
replay <buildId> [--label <l>]
POST /api/sessions → session
The old `build-jobs` subcommand is REMOVED.
Usage:
python3 /usr/lib/sysdeck/bridge/fester.py status
python3 /usr/lib/sysdeck/bridge/fester.py builds
python3 /usr/lib/sysdeck/bridge/fester.py start-build \
--project linux-tool --targets debian --no-cache --retries 2
FESTER_URL=http://10.0.0.5:3010 \\
python3 /usr/lib/sysdeck/bridge/fester.py status
"""
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
# The vendored fester service binds REST+WS here by default. Override
# with FESTER_URL when it lives elsewhere.
FESTER_URL = os.environ.get("FESTER_URL", "http://127.0.0.1:3010").rstrip("/")
# Strict 4s timeout — the panel polls every 5s, so a hung request must
# never outlive one refresh cycle.
FESTER_TIMEOUT = 4 # seconds
# Connection-level failure messages (callers print this and exit 0 —
# graceful, same contract as the other bridge helpers).
UNREACHABLE_MSG = (
"fester service unreachable at {url} — start it with "
"`make fester-start` or `bun run dev` in web/mini-services/fester, "
"or set FESTER_URL"
)
def _unreachable() -> dict:
"""Return the graceful offline response (remediation hint included)."""
return {"ok": False, "error": UNREACHABLE_MSG.format(url=FESTER_URL)}
def _base_port() -> int:
"""Port of the base URL (3010 for the default vendored service)."""
try:
return urllib.parse.urlparse(FESTER_URL).port or 3010
except ValueError:
return 3010
def _quote(value: str) -> str:
"""URL-path-encode a path segment (build/session ids)."""
return urllib.parse.quote(str(value), safe="")
def _request(path: str, method: str = "GET", body: dict | None = None) -> dict:
"""One HTTP call against the fester service. Returns parsed JSON.
HTTPError bodies are JSON on this service — surface them instead of
crashing. Connection-level failures raise URLError/OSError; the
_get/_post wrappers translate those into the graceful offline
response.
"""
url = FESTER_URL + path
data = None
headers = {"Accept": "application/json"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=FESTER_TIMEOUT) as resp:
raw = resp.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as exc:
# 400/404/409 responses carry a JSON body — return it verbatim.
try:
raw = exc.read().decode("utf-8", errors="replace")
if raw.strip():
return json.loads(raw)
except (OSError, ValueError):
pass
return {"ok": False, "error": f"HTTP {exc.code}: {exc.reason}"}
try:
return json.loads(raw) if raw.strip() else {"ok": False, "error": f"empty response from {url}"}
except json.JSONDecodeError:
return {"ok": False, "error": f"non-JSON response from {url}"}
def _get(path: str) -> dict:
"""GET <path> with graceful offline handling."""
try:
return _request(path)
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
def _post(path: str, body: dict) -> dict:
"""POST <path> with a JSON body, graceful offline handling."""
try:
return _request(path, method="POST", body=body)
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
# ── subcommands ──────────────────────────────────────────────────────
def cmd_status(_args: list[str]) -> dict:
"""GET /api/health, enriched with the local connection facts."""
try:
data = _request("/api/health")
except (urllib.error.URLError, OSError, ValueError):
return _unreachable()
out = dict(data)
out["port"] = _base_port()
out["transport"] = "rest+ws"
out["base_url"] = FESTER_URL
return out
def cmd_build(args: list[str]) -> dict:
"""GET /api/builds/<id> — one build (live state or stored record)."""
if not args:
return {"ok": False, "error": "build id required: build <id>"}
return _get(f"/api/builds/{_quote(args[0])}")
def cmd_timeline(args: list[str]) -> dict:
"""GET /api/timeline/<id> — the event journal for one build."""
if not args:
return {"ok": False, "error": "build id required: timeline <id>"}
return _get(f"/api/timeline/{_quote(args[0])}")
def cmd_start_build(args: list[str]) -> dict:
"""POST /api/build — start a build, print the build_id response."""
project = None
targets: list[str] = []
no_cache = False
retries = 0
retries_given = False
fail_action = None
i = 0
while i < len(args):
arg = args[i]
if arg == "--project" and i + 1 < len(args):
project = args[i + 1]
i += 2
elif arg == "--targets" and i + 1 < len(args):
targets = [t for t in args[i + 1].split(",") if t]
i += 2
elif arg == "--no-cache":
no_cache = True
i += 1
elif arg == "--retries" and i + 1 < len(args):
raw = args[i + 1]
i += 2
try:
retries = int(raw)
except ValueError:
return {"ok": False, "error": f"retries must be an integer between 0 and 3: {raw!r}"}
if not 0 <= retries <= 3:
return {"ok": False, "error": f"retries must be an integer between 0 and 3: {retries}"}
retries_given = True
elif arg == "--fail-action" and i + 1 < len(args):
fail_action = args[i + 1]
i += 2
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
if not project:
return {"ok": False, "error": "--project <p> is required"}
if not targets:
return {"ok": False, "error": "--targets <csv> is required (at least one target)"}
body: dict = {"project": project, "targets": targets}
if no_cache:
body["noCache"] = True
if retries_given:
body["retries"] = retries
if fail_action:
body["failAction"] = fail_action
return _post("/api/build", body)
def cmd_cancel(args: list[str]) -> dict:
"""POST /api/builds/<id>/cancel — 409-style JSON on non-running builds."""
if not args:
return {"ok": False, "error": "build id required: cancel <id>"}
return _post(f"/api/builds/{_quote(args[0])}/cancel", {})
def cmd_replay(args: list[str]) -> dict:
"""POST /api/sessions — create a replay session for a finished build."""
build_id = None
label = None
i = 0
while i < len(args):
arg = args[i]
if arg == "--label" and i + 1 < len(args):
label = args[i + 1]
i += 2
elif build_id is None:
build_id = arg
i += 1
else:
return {"ok": False, "error": f"unknown argument: {arg}"}
if not build_id:
return {"ok": False, "error": "build id required: replay <buildId> [--label <l>]"}
body: dict = {"buildId": build_id}
if label is not None:
body["label"] = label
return _post("/api/sessions", body)
# ── dispatch table ───────────────────────────────────────────────────
COMMANDS = {
"status": lambda _args: cmd_status(_args),
"metrics": lambda _args: _get("/api/metrics"),
"builds": lambda _args: _get("/api/builds"),
"build": cmd_build,
"nodes": lambda _args: _get("/api/nodes"),
"targets": lambda _args: _get("/api/targets"),
"timeline": cmd_timeline,
"sessions": lambda _args: _get("/api/sessions"),
"start-build": cmd_start_build,
"cancel": cmd_cancel,
"replay": cmd_replay,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

2710
bridge/firewall.py Executable file

File diff suppressed because it is too large Load Diff

90
bridge/firmware.py Executable file
View File

@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""
SysDeck - Firmware Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates fwupd device list with TPM 2.0 PCR registers into a single
JSON document so the JS panel can render both in one fetch.
Usage:
python3 /usr/lib/sysdeck/bridge/firmware.py devices # raw fwupdmgr output
python3 /usr/lib/sysdeck/bridge/firmware.py summary # devices + tpmPcr0 combined
"""
import json
import subprocess
import sys
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
def devices() -> dict:
"""Raw fwupdmgr device list as the JSON shape fwupd emits.
Returns ``{"Devices": [...]}`` (capital D — matches fwupdmgr's own JSON
schema, which is what the firmware.js panel reads via
``result.value?.Devices``). Returns ``{"Devices": []}`` when fwupdmgr
is absent, fails, or emits invalid JSON, so the panel always gets a
renderable shape.
"""
fwupd_raw = run(["fwupdmgr", "get-devices", "--json"])
try:
parsed = json.loads(fwupd_raw) if fwupd_raw.strip() else {}
except json.JSONDecodeError:
parsed = {}
# Normalize: callers expect the fwupdmgr "Devices" key. If fwupd
# returned a different shape (older/newer versions, or an error blob),
# fall back to an empty device list so the panel doesn't crash on
# `undefined.map()`.
if not isinstance(parsed, dict) or "Devices" not in parsed:
return {"Devices": []}
if not isinstance(parsed["Devices"], list):
return {"Devices": []}
return parsed
def summary() -> dict:
"""Return fwupd devices plus the first TPM PCR register.
Kept for backwards compatibility with callers that fetch both pieces
in one round-trip. New callers should prefer ``devices()`` (raw fwupd
output, matches the panel's expected ``{Devices: [...]}`` shape) plus
the bridge.js-side ``tpmInfo()`` direct ``tpm2_pcrread`` spawn.
"""
fwupd = devices()
tpm_pcr0 = run(["tpm2_pcrread", "sha256:0"]).strip() or "TPM2 tools not available"
return {
"devices": fwupd.get("Devices", []),
"tpmPcr0": tpm_pcr0,
}
COMMANDS = {
"devices": lambda _args: devices(),
"summary": lambda _args: summary(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

115
bridge/fleet.py Executable file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
SysDeck - Fleet Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates local host info (hostname, uptime, load, network addresses)
and the cockpit multi-host peer list (if /etc/cockpit/machines.d/ is
configured) into a single JSON document.
Usage:
python3 -m sysdeck.bridge.fleet local
python3 -m sysdeck.bridge.fleet peers
python3 -m sysdeck.bridge.fleet summary
"""
import json
import os
import re
import subprocess
import sys
from typing import Any
# /etc/cockpit/machines.d/<host>.json schema (subset).
MACHINE_FILE_RE = re.compile(r"^(?P<host>\S+)\s+.*$")
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
def local_host() -> dict[str, Any]:
"""Aggregate hostname, uptime, load, and IP addresses."""
uptime_raw = run(["uptime"]).strip()
addresses = [a for a in run(["hostname", "-I"]).split() if a]
hostname = run(["hostname"]).strip() or "unknown"
# Parse load average from uptime output.
# Typical: " 14:23:01 up 12 days, 3:45, 2 users, load average: 0.42, 0.58, 0.61"
load_match = re.search(r"load average:\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)", uptime_raw)
load = [float(load_match.group(i)) for i in (1, 2, 3)] if load_match else []
return {
"hostname": hostname,
"uptime": uptime_raw,
"addresses": addresses,
"load": load,
}
def peers() -> list[dict[str, str]]:
"""Parse /etc/cockpit/machines.d/*.json for peer host entries.
Each file is a JSON document with a top-level "host" key. Files that
fail to parse are skipped — the panel fails closed.
"""
peer_dir = "/etc/cockpit/machines.d"
if not os.path.isdir(peer_dir):
return []
peers_list: list[dict[str, str]] = []
for entry in sorted(os.listdir(peer_dir)):
if not entry.endswith(".json"):
continue
path = os.path.join(peer_dir, entry)
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
continue
# Schema is flexible; pull common keys.
peers_list.append({
"host": data.get("host", ""),
"address": data.get("address", data.get("host", "")),
"label": data.get("label", data.get("host", "")),
"visible": str(data.get("visible", "true")).lower(),
})
return peers_list
def summary() -> dict[str, Any]:
"""Local host info plus peer list."""
return {
"local": local_host(),
"peers": peers(),
"peerCount": len(peers()),
}
COMMANDS = {
"local": lambda _args: local_host(),
"peers": lambda _args: peers(),
"summary": lambda _args: summary(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

303
bridge/glances.py Executable file
View File

@ -0,0 +1,303 @@
#!/usr/bin/env python3
"""
SysDeck - Glances Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates system monitoring data from the Glances CLI
(https://github.com/nicolargo/glances) into a structured JSON document.
v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI. The user directive:
"glances is not integrated yet i just assumed you would integrate the
built in webui as a module." Glances ships a webserver via
`glances -w` (default port 61208, 127.0.0.1). The bridge starts that
webserver as a background process; the JS panel iframes the running
web UI at http://127.0.0.1:61208 — full Glances web UI (all graphs,
all sensors, all top processes, all history) without SysDeck
re-implementing any of it.
Subcommands:
snapshot — full system snapshot (kept from v0.0.11)
cpu — CPU metrics subset (kept)
memory — memory metrics subset (kept)
network — network metrics subset (kept)
start-web — start glances -w on 127.0.0.1:61208 (background)
writes the PID to /var/lib/sysdeck/glances/web.pid
stop-web — kill the background webserver (read PID file)
web-status — return {running, pid, port, url}
web-port — return the actual listening port (defaults to 61208)
Cockpit way (v0.0.31+ pattern): the bridge runs glances via subprocess
directly — no `sudo` shell-out. The JS panel passes { superuser: 'try' }
to cockpit.spawn so the cockpit bridge prompts the operator via polkit
for the org.sysdeck.system.manage action (shipped since v0.0.17 —
authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by
extension any system-level subprocess the bridge runs).
Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes
it as a separate process via subprocess — the suite (MIT) and Glances
(GPL-3.0) remain independent programs. No Glances code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/glances.py snapshot
python3 /usr/lib/sysdeck/bridge/glances.py start-web
python3 /usr/lib/sysdeck/bridge/glances.py web-status
"""
import json
import os
import shutil
import signal
import subprocess
import sys
from pathlib import Path
from typing import Any
GLANCES_LICENSE = "GPL-3.0"
GLANCES_AUTHOR = "Nicolargo"
GLANCES_URL = "https://github.com/nicolargo/glances"
# Default Glances webserver port. The operator can override via the
# `--port` flag on start-web; this default matches `glances -w`'s own
# default.
GLANCES_WEB_HOST = "127.0.0.1"
GLANCES_WEB_PORT = 61208
# State directory for the background webserver's PID file. Created on
# first use; the cockpit superuser channel handles root perms.
STATE_DIR = Path("/var/lib/sysdeck/glances")
WEB_PID_FILE = STATE_DIR / "web.pid"
def _have(binary: str) -> bool:
"""True if binary is on PATH."""
return shutil.which(binary) is not None
def _ensure_state_dir() -> None:
"""Create the state dir. Best-effort; polkit handles root perms."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
except (PermissionError, OSError):
pass
def _read_pid() -> int | None:
"""Return the PID of the running glances webserver, or None."""
try:
return int(WEB_PID_FILE.read_text(encoding="utf-8").strip())
except (FileNotFoundError, ValueError, PermissionError, OSError):
return None
def _write_pid(pid: int | None) -> None:
"""Record the webserver PID (or clear it if pid is None)."""
try:
_ensure_state_dir()
if pid is None:
WEB_PID_FILE.unlink(missing_ok=True)
else:
WEB_PID_FILE.write_text(str(pid), encoding="utf-8")
except (PermissionError, OSError):
pass
def _is_pid_alive(pid: int) -> bool:
"""Return True if a process with the given PID exists.
Uses os.kill(pid, 0) — signal 0 is a no-op that returns successfully
if the process exists and the caller has permission to signal it,
or raises ProcessLookupError / PermissionError otherwise.
"""
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
except OSError:
return False
def run_glances(args: list[str]) -> str:
"""Run glances with the given args, returning stdout."""
return subprocess.run(
["glances", *args], capture_output=True, text=True, check=True,
).stdout
def snapshot() -> dict[str, Any]:
"""Full system snapshot from glances JSON export.
Calls: glances --time 1 --quiet --export json --once
Returns the parsed JSON document.
"""
output = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
lines = output.strip().splitlines()
if not lines:
return {}
return json.loads(lines[-1])
def cpu() -> dict[str, Any]:
"""CPU metrics subset from a glances snapshot."""
data = snapshot()
return data.get("cpu", {})
def memory() -> dict[str, Any]:
"""Memory metrics subset from a glances snapshot."""
data = snapshot()
return {
"mem": data.get("mem", {}),
"memswap": data.get("memswap", {}),
}
def network() -> dict[str, Any]:
"""Network interface metrics subset from a glances snapshot."""
data = snapshot()
return data.get("network", {})
# ── Web UI management ────────────────────────────────────────────────
#
# Glances ships a built-in webserver (`glances -w`) that serves a full
# web UI at http://127.0.0.1:61208 — the operator gets every chart,
# every sensor, every top process, and the history grapher without
# SysDeck re-implementing any of it. The bridge starts the webserver as
# a background process via subprocess.Popen, records the PID, and the
# JS panel iframes the URL.
def cmd_start_web(args: list[str]) -> dict[str, Any]:
"""Start the Glances built-in webserver (`glances -w`) in the background.
Optional args: [port] — overrides the default 61208.
The bridge runs `glances -w --bind 127.0.0.1 --port <port>` detached,
writes the child PID to /var/lib/sysdeck/glances/web.pid, and returns
immediately. The JS panel polls web-status to detect when the
webserver is up (typically <1s on a warm start).
"""
if not _have("glances"):
return {
"available": False,
"reason": "glances not installed",
"install": "pip install glances # or: pacman -S glances / apt install glances / dnf install glances",
}
port = GLANCES_WEB_PORT
if args:
try:
port = int(args[0])
except ValueError:
return {"error": f"port must be numeric, got {args[0]}"}
# If a PID is already on file and alive, don't start a second one.
existing_pid = _read_pid()
if existing_pid is not None and _is_pid_alive(existing_pid):
return {
"started": False,
"already_running": True,
"pid": existing_pid,
"port": port,
"url": f"http://{GLANCES_WEB_HOST}:{port}",
}
# Detach: open stdout/stderr to /dev/null, start in new session so
# the child survives the bridge process exiting, record the PID.
try:
_ensure_state_dir()
proc = subprocess.Popen(
["glances", "-w", "--bind", GLANCES_WEB_HOST, "--port", str(port)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
start_new_session=True,
)
except (FileNotFoundError, OSError) as exc:
return {"started": False, "error": str(exc)}
_write_pid(proc.pid)
return {
"started": True,
"pid": proc.pid,
"port": port,
"url": f"http://{GLANCES_WEB_HOST}:{port}",
}
def cmd_stop_web(_args: list[str]) -> dict[str, Any]:
"""Stop the background Glances webserver."""
pid = _read_pid()
if pid is None:
return {"stopped": False, "reason": "no PID file — webserver not started"}
if not _is_pid_alive(pid):
_write_pid(None)
return {"stopped": True, "reason": "process was already dead (PID file cleared)"}
try:
# SIGTERM first — graceful shutdown. The glances webserver
# handles SIGTERM cleanly and exits within ~1s.
os.kill(pid, signal.SIGTERM)
_write_pid(None)
return {"stopped": True, "pid": pid}
except (ProcessLookupError, PermissionError, OSError) as exc:
return {"stopped": False, "pid": pid, "error": str(exc)}
def cmd_web_status(_args: list[str]) -> dict[str, Any]:
"""Return whether the Glances webserver is running + its URL."""
if not _have("glances"):
return {
"available": False,
"reason": "glances not installed",
"install": "pip install glances # or: pacman -S glances / apt install glances",
}
pid = _read_pid()
if pid is None:
return {
"available": True,
"running": False,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
"hint": "Click Start Web UI to launch the built-in Glances webserver.",
}
if not _is_pid_alive(pid):
_write_pid(None)
return {
"available": True,
"running": False,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
"hint": "Previous webserver process died — restart it.",
}
return {
"available": True,
"running": True,
"pid": pid,
"port": GLANCES_WEB_PORT,
"url": f"http://{GLANCES_WEB_HOST}:{GLANCES_WEB_PORT}",
}
COMMANDS = {
# v0.0.11 read-only snapshot subcommands (kept):
"snapshot": lambda _args: snapshot(),
"cpu": lambda _args: cpu(),
"memory": lambda _args: memory(),
"network": lambda _args: network(),
# v0.0.34 web UI integration:
"start-web": lambda args: cmd_start_web(args),
"stop-web": lambda args: cmd_stop_web(args),
"web-status": lambda _args: cmd_web_status([]),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

469
bridge/grafana.py Executable file
View File

@ -0,0 +1,469 @@
#!/usr/bin/env python3
"""
SysDeck - Grafana Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages Grafana visualization dashboards, datasources, alerting,
and plugin configuration via the Grafana HTTP API.
Grafana is AGPL-3.0 licensed by Grafana Labs. This bridge helper
communicates with Grafana via its HTTP API — no Grafana code is bundled.
Subcommands:
summary - Overall Grafana status (version, health, counts)
dashboards - List all dashboards with folder/tags
datasources - List and health-check datasources
alerts - List Grafana-managed alert rules and states
health - Grafana health check endpoint
org - Current organization info
users - List Grafana users
plugins - List installed plugins
search - Search dashboards by query string
restart - Restart the Grafana systemd service
reload - Reload Grafana provisioning (SIGUSR2)
"""
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import urllib.error
from pathlib import Path
from typing import Any
GRAFANA_LICENSE = "AGPL-3.0"
GRAFANA_AUTHORS = "Grafana Labs"
GRAFANA_URL = "https://grafana.com"
# v0.0.39: import v0.0.37 security helpers from firewall.py.
sys.path.insert(0, str(Path(__file__).parent))
try:
from firewall import ( # type: ignore
SCRUBBED_ENV,
_sanitize_output,
_validate_filename,
)
except ImportError:
SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
def _sanitize_output(text: str, max_len: int = 4096) -> str:
if not text:
return ""
if len(text) > max_len:
text = text[:max_len] + " ... (truncated)"
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
_FILENAME_RE_FALLBACK = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def _validate_filename(name: str) -> bool:
return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
# Grafana API endpoint from environment or default
GRAFANA_API_URL = os.environ.get("GRAFANA_API_URL", "http://localhost:3000")
# Admin credentials from environment or default
GRAFANA_USER = os.environ.get("GRAFANA_ADMIN_USER", "admin")
GRAFANA_PASSWORD = os.environ.get("GRAFANA_ADMIN_PASSWORD", "admin")
def _systemd_status(unit: str) -> dict[str, Any]:
"""Check systemd unit active state.
v0.0.39 hardening: env scrubbed, output sanitized.
"""
try:
result = subprocess.run(
["systemctl", "show", unit,
"--property=ActiveState,SubState"],
capture_output=True, text=True, timeout=5,
env=SCRUBBED_ENV,
)
props = dict(
line.split("=", 1)
for line in _sanitize_output(result.stdout).strip().splitlines()
if "=" in line
)
return {
"active": props.get("ActiveState", "unknown"),
"sub": props.get("SubState", "unknown"),
}
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
# Systemd unavailable: select default unknown state
return {"active": "unknown", "sub": "unknown"}
def _is_installed() -> bool:
"""Check if Grafana binary or package exists."""
# Probe standard paths and package manager registries
for cmd in [
["which", "grafana-server"],
["systemctl", "list-unit-files", "grafana-server.service"],
]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3,
env=SCRUBBED_ENV)
if r.returncode == 0:
return True
except (subprocess.TimeoutExpired, OSError):
pass
return False
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject HTTP redirects — SSRF defense (CVE-2020-35850 lesson)."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def _is_localhost_url(url: str) -> bool:
"""Return True if url points to localhost/127.0.0.1 (SSRF defense)."""
return (url.startswith("http://localhost:") or
url.startswith("http://127.0.0.1:") or
url.startswith("http://[::1]:"))
def _grafana_api_get(path: str, timeout: int = 5) -> Any:
"""GET from Grafana HTTP API with basic auth, returning parsed JSON.
v0.0.39 hardening:
- NoRedirectHandler (SSRF defense — CVE-2020-35850).
- 127.0.0.1-only URL check (SSRF defense).
- 5s timeout (DoS defense).
"""
url = f"{GRAFANA_API_URL.rstrip('/')}/api{path}"
if not _is_localhost_url(url):
return None
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
# HTTP Basic authentication credential encoding
credentials = base64.b64encode(
f"{GRAFANA_USER}:{GRAFANA_PASSWORD}".encode()
).decode()
req.add_header("Authorization", f"Basic {credentials}")
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open(req, timeout=timeout) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError:
# Grafana returned an HTTP error (auth failed, not found, etc.)
return None
except urllib.error.URLError:
# Grafana unreachable: report connection failure
return None
except (ValueError, json.JSONDecodeError, OSError):
# API response parse failure or I/O error: select null result
return None
def _default_health() -> dict[str, Any]:
"""Provide default Grafana health status when unreachable."""
return {
"database": "unknown",
"commit": "",
"version": "",
"status": "unknown",
"message": "Grafana unreachable",
}
def health() -> dict[str, Any]:
"""Grafana health check endpoint."""
data = _grafana_api_get("/health")
if not isinstance(data, dict):
# Health endpoint unavailable: select default health state
return _default_health()
return {
"database": data.get("database", ""),
"commit": data.get("commit", ""),
"version": data.get("version", ""),
"status": data.get("status", "unknown"),
"message": data.get("message", ""),
}
def summary() -> dict[str, Any]:
"""Overall Grafana status, version, health, dashboards, datasources."""
status_info = _systemd_status("grafana-server.service")
if status_info["active"] != "active":
return {
"installed": _is_installed(),
"version": "",
"status": "stopped" if _is_installed() else "uninstalled",
"url": GRAFANA_API_URL,
"dashboardsCount": 0,
"datasourcesCount": 0,
"activeAlerts": 0,
"orgName": "",
"adminUser": GRAFANA_USER,
"health": _default_health(),
}
health_info = health()
version = health_info.get("version", "")
# Dashboard inventory count
dash_search = _grafana_api_get("/search?type=dash-db")
dashboards_count = len(dash_search) if isinstance(dash_search, list) else 0
# Datasource inventory count
ds_list = _grafana_api_get("/datasources")
datasources_count = len(ds_list) if isinstance(ds_list, list) else 0
# Alert rules (Grafana unified alerting)
alert_rules = _grafana_api_get("/v1/provisioning/alert-rules")
if not isinstance(alert_rules, list):
active_alerts = 0
else:
active_alerts = sum(
1 for r in alert_rules
if isinstance(r, dict) and r.get("status", {}).get("state") == "alerting"
)
# Organization identity
org = _grafana_api_get("/org")
org_name = org.get("name", "Main Org.") if isinstance(org, dict) else "Main Org."
return {
"installed": True,
"version": version,
"status": "running",
"url": GRAFANA_API_URL,
"dashboardsCount": dashboards_count,
"datasourcesCount": datasources_count,
"activeAlerts": active_alerts,
"orgName": org_name,
"adminUser": GRAFANA_USER,
"health": health_info,
}
def dashboards() -> list[dict[str, Any]]:
"""List all dashboards with folder and tags."""
data = _grafana_api_get("/search?type=dash-db")
if not isinstance(data, list):
return []
return [{
"id": d.get("id", 0),
"uid": d.get("uid", ""),
"title": d.get("title", ""),
"slug": d.get("slug", ""),
"uri": d.get("uri", ""),
"url": d.get("url", ""),
"type": d.get("type", ""),
"tags": d.get("tags", []),
"isStarred": d.get("isStarred", False),
"folderTitle": d.get("folderTitle", ""),
"folderUid": d.get("folderUid", ""),
"folderId": d.get("folderId", 0),
} for d in data]
def _datasource_record(ds: dict[str, Any]) -> dict[str, Any]:
"""Construct datasource record with live health status."""
ds_id = ds.get("id", 0)
resp = _grafana_api_get(f"/datasources/{ds_id}/health")
# Health endpoint unavailable: select default success indicator
if not isinstance(resp, dict):
ds_status, ds_message = "success", ""
else:
ds_status = resp.get("status", "error")
ds_message = resp.get("message", "")
return {
"id": ds_id,
"name": ds.get("name", ""),
"type": ds.get("type", ""),
"url": ds.get("url", ""),
"access": ds.get("access", "proxy"),
"isDefault": ds.get("isDefault", False),
"database": ds.get("database", ""),
"jsonData": ds.get("jsonData", {}),
"status": ds_status,
"message": ds_message,
}
def datasources() -> list[dict[str, Any]]:
"""List datasources with health check."""
data = _grafana_api_get("/datasources")
if not isinstance(data, list):
return []
return [_datasource_record(ds) for ds in data]
def alerts() -> list[dict[str, Any]]:
"""List Grafana unified alerting rules."""
data = _grafana_api_get("/v1/provisioning/alert-rules")
if not isinstance(data, list):
return []
return [{
"id": 0, # Unified alerting uses uid, not numeric id
"uid": a.get("uid", ""),
"title": a.get("title", ""),
"condition": a.get("condition", ""),
"dashboardUid": a.get("dashboardUid", ""),
"dashboardTitle": a.get("dashboardTitle", ""),
"panelId": a.get("panelId", 0),
"state": (
a.get("status", {}).get("state", "unknown")
if isinstance(a.get("status"), dict) else "unknown"
),
"noDataState": a.get("noDataState", ""),
"executionErrorState": a.get("executionErrorState", ""),
"labels": a.get("labels", {}),
} for a in data]
def org() -> dict[str, Any]:
"""Current organization info."""
data = _grafana_api_get("/org")
if not isinstance(data, dict):
# Organization endpoint unavailable: select empty defaults
return {
"id": 0, "name": "", "address1": "",
"address2": "", "city": "", "country": "",
}
return {
"id": data.get("id", 0),
"name": data.get("name", ""),
"address1": data.get("address1", ""),
"address2": data.get("address2", ""),
"city": data.get("city", ""),
"country": data.get("country", ""),
}
def users() -> list[dict[str, Any]]:
"""List Grafana users."""
data = _grafana_api_get("/org/users")
if not isinstance(data, list):
return []
return [{
"id": u.get("id", 0),
"login": u.get("login", ""),
"name": u.get("name", ""),
"email": u.get("email", ""),
"isAdmin": u.get("isGrafanaAdmin", False),
"isGrafanaAdmin": u.get("isGrafanaAdmin", False),
"lastSeenAt": u.get("lastSeenAt", ""),
"lastSeenAtAge": u.get("lastSeenAtAge", ""),
"authLabels": u.get("authLabels", []),
} for u in data]
def plugins() -> list[dict[str, Any]]:
"""List installed Grafana plugins."""
data = _grafana_api_get("/plugins")
if not isinstance(data, list):
return []
return [{
"id": p.get("id", ""),
"name": p.get("name", ""),
"type": p.get("type", ""),
"enabled": p.get("enabled", False),
"pinned": p.get("pinned", False),
"version": p.get("version", ""),
"signature": p.get("signature", ""),
"info": {
"description": p.get("info", {}).get("description", ""),
"author": p.get("info", {}).get("author", {"name": "", "url": ""}),
"logos": p.get("info", {}).get("logos", {"small": "", "large": ""}),
},
} for p in data]
def search(args: list[str]) -> list[dict[str, Any]]:
"""Search dashboards by query string."""
query = args[0] if args else ""
endpoint = f"/search?type=dash-db&query={query}" if query else "/search?type=dash-db"
data = _grafana_api_get(endpoint)
if not isinstance(data, list):
return []
return [{
"id": d.get("id", 0),
"uid": d.get("uid", ""),
"title": d.get("title", ""),
"slug": d.get("slug", ""),
"uri": d.get("uri", ""),
"url": d.get("url", ""),
"type": d.get("type", ""),
"tags": d.get("tags", []),
"isStarred": d.get("isStarred", False),
"folderTitle": d.get("folderTitle", ""),
"folderUid": d.get("folderUid", ""),
"folderId": d.get("folderId", 0),
} for d in data]
def restart() -> dict[str, Any]:
"""Restart the Grafana systemd service.
v0.0.39 hardening: no sudo (cockpit superuser channel handles auth via
polkit org.sysdeck.monitoring.modify). env scrubbed. check=False.
"""
try:
r = subprocess.run(
["systemctl", "restart", "grafana-server.service"],
capture_output=True, text=True, check=False, timeout=10,
env=SCRUBBED_ENV,
)
if r.returncode == 0:
return {"action": "restart", "result": "ok"}
return {"action": "restart", "result": "error",
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
except subprocess.TimeoutExpired:
return {"action": "restart", "result": "error", "message": "restart timed out after 10s"}
except OSError as exc:
return {"action": "restart", "result": "error", "message": str(exc)}
def reload() -> dict[str, Any]:
"""Send SIGUSR2 to Grafana for provisioning reload.
v0.0.39 hardening: no sudo, env scrubbed, check=False.
"""
try:
r = subprocess.run(
["systemctl", "kill", "--signal=SIGUSR2", "grafana-server.service"],
capture_output=True, text=True, check=False, timeout=5,
env=SCRUBBED_ENV,
)
if r.returncode == 0:
return {"action": "reload", "result": "ok"}
return {"action": "reload", "result": "error",
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
except subprocess.TimeoutExpired:
return {"action": "reload", "result": "error", "message": "reload timed out after 5s"}
except OSError as exc:
return {"action": "reload", "result": "error", "message": str(exc)}
COMMANDS = {
"summary": lambda _args: summary(),
"dashboards": lambda _args: dashboards(),
"datasources": lambda _args: datasources(),
"alerts": lambda _args: alerts(),
"health": lambda _args: health(),
"org": lambda _args: org(),
"users": lambda _args: users(),
"plugins": lambda _args: plugins(),
"search": lambda args: search(args),
"restart": lambda _args: restart(),
"reload": lambda _args: reload(),
}
def main(argv: list[str]) -> int:
"""Dispatch subcommand and emit JSON result."""
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

628
bridge/hwalert.py Executable file
View File

@ -0,0 +1,628 @@
#!/usr/bin/env python3
"""SysDeck - Hardware Alert Bridge
Detects foreign/unauthorized devices, USB mass storage, DMA-capable
Thunderbolt/FireWire, rogue Bluetooth, new PCI devices, RFID/NFC
skimmers, firmware tampering, and other hardware intrusion indicators.
Subcommands:
summary - full alert summary with all devices and policy
devices - list all detected hardware devices
alerts - list active alerts only
acknowledge <id> - acknowledge an alert
dismiss <id> - dismiss an alert
block <device-id> - block a device (USB authorize=0 or udev rule)
unblock <device-id> - unblock a device
whitelist <device-id> - add device to whitelist
unwhitelist <device-id> - remove device from whitelist
policy <key> <val> - update a policy toggle
Author: Jeremy Anderson (https://dcos.net)
"""
import json
import subprocess
import sys
import os
import re
import glob as globmod
from datetime import datetime, timezone
# ── Helpers ──────────────────────────────────────────────────
def run(cmd, timeout=10):
"""Run a command, return stdout or empty string."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
return r.stdout.strip()
except Exception:
return ""
def now_iso():
return datetime.now(timezone.utc).isoformat()
def read_file(path):
try:
with open(path, 'r') as f:
return f.read().strip()
except Exception:
return ""
# ── Policy (persisted to /etc/sysdeck/hw-policy.json) ───────
POLICY_PATH = "/etc/sysdeck/hw-policy.json"
WHITELIST_PATH = "/etc/sysdeck/hw-whitelist.json"
DEFAULT_POLICY = {
"blockUsbStorage": True,
"blockThunderboltDMA": True,
"blockFirewireDMA": True,
"blockUnknownBluetooth": True,
"blockUnknownPCI": False,
"autoBlock": True,
"alertOnly": False,
"whitelistEnforced": True,
}
def load_policy():
try:
with open(POLICY_PATH, 'r') as f:
return json.load(f)
except Exception:
return dict(DEFAULT_POLICY)
def save_policy(policy):
try:
os.makedirs(os.path.dirname(POLICY_PATH), exist_ok=True)
with open(POLICY_PATH, 'w') as f:
json.dump(policy, f, indent=2)
except Exception:
pass # Best effort — may lack write perms
def load_whitelist():
try:
with open(WHITELIST_PATH, 'r') as f:
return json.load(f)
except Exception:
return []
def save_whitelist(wl):
try:
os.makedirs(os.path.dirname(WHITELIST_PATH), exist_ok=True)
with open(WHITELIST_PATH, 'w') as f:
json.dump(wl, f, indent=2)
except Exception:
pass
def is_whitelisted(device, whitelist):
"""Check if device matches any whitelist entry by vendorId:productId or serial."""
for entry in whitelist:
if entry.get("serial") and device.get("serial") and entry["serial"] == device["serial"]:
return True
if (entry.get("vendorId") == device.get("vendorId") and
entry.get("productId") == device.get("productId") and
not entry.get("serial")):
return True
return False
# ── Device Scanners ─────────────────────────────────────────
def scan_usb_devices():
"""Scan /sys/bus/usb/devices/ for USB devices."""
devices = []
usb_base = "/sys/bus/usb/devices"
if not os.path.isdir(usb_base):
return devices
for entry in os.listdir(usb_base):
path = os.path.join(usb_base, entry)
if not os.path.isdir(path):
continue
# Skip USB hubs (root hubs show as usbX)
if re.match(r'^usb\d+', entry) and '-' not in entry:
continue
vendor_id = read_file(os.path.join(path, "idVendor"))
product_id = read_file(os.path.join(path, "idProduct"))
if not vendor_id or not product_id:
continue
vendor = read_file(os.path.join(path, "manufacturer")) or f"Vendor {vendor_id}"
product = read_file(os.path.join(path, "product")) or f"USB Device {product_id}"
serial = read_file(os.path.join(path, "serial"))
driver = ""
# Check for driver
driver_link = os.path.join(path, "driver")
if os.path.islink(driver_link):
driver = os.path.basename(os.readlink(driver_link))
# Check authorization
authorized = read_file(os.path.join(path, "authorized"))
is_authorized = authorized == "1"
# Detect interface classes
interfaces = []
for iface_dir in sorted(globmod.glob(os.path.join(path, "*", "bInterfaceClass"))):
iface_class = read_file(iface_dir)
class_map = {
"08": "mass-storage", "03": "hid", "0e": "video",
"01": "audio", "06": "image", "07": "printer",
"0a": "cdc", "02": "cdc", "0b": "chipcard",
"e0": "wireless", "ff": "vendor-specific",
}
interfaces.append(class_map.get(iface_class, f"class-{iface_class}"))
# Check for mass storage — find /dev/ path and size
dev_path = ""
size_bytes = 0
mount_point = ""
has_mass_storage = "mass-storage" in interfaces
if has_mass_storage:
# Try to find block device
for host_dir in globmod.glob(os.path.join(path, "host*/target*/*/block/*")):
dev_name = os.path.basename(host_dir)
dev_path = f"/dev/{dev_name}"
size_str = read_file(os.path.join(host_dir, "size"))
if size_str and size_str.isdigit():
size_bytes = int(size_str) * 512 # sectors * 512 bytes
break
# Also check via scsi disk link
if not dev_path:
for scsi_disk in globmod.glob(os.path.join(path, "host*/target*/*/scsi_disk")):
parent = os.path.dirname(scsi_disk)
for blk in globmod.glob(os.path.join(parent, "block/*")):
dev_name = os.path.basename(blk)
dev_path = f"/dev/{dev_name}"
break
# Check mount
if dev_path:
mount_out = run(["findmnt", "-n", "-o", "TARGET", dev_path], timeout=3)
if mount_out:
mount_point = mount_out
# Determine bus type
bus_type = "usb"
# DMA: USB devices themselves are not DMA-capable in the traditional sense,
# but USB4/TB tunnels can be. Check for Thunderbolt tunnel.
dma_capable = False
device = {
"id": path,
"name": product,
"vendor": vendor,
"vendorId": vendor_id,
"productId": product_id,
"serial": serial,
"busType": bus_type,
"driver": driver,
"devPath": dev_path,
"sysPath": path,
"authorized": is_authorized,
"interfaces": interfaces,
"mountPoint": mount_point,
"sizeBytes": size_bytes,
"dmaCapable": dma_capable,
"firstSeen": now_iso(), # Best effort — real impl would use udev history
"whitelisted": False, # Set later
}
devices.append(device)
return devices
def scan_thunderbolt_devices():
"""Scan /sys/bus/thunderbolt/devices/ for Thunderbolt devices."""
devices = []
tb_base = "/sys/bus/thunderbolt/devices"
if not os.path.isdir(tb_base):
return devices
for entry in os.listdir(tb_base):
path = os.path.join(tb_base, entry)
if not os.path.isdir(path):
continue
# Skip the domain controller (domain0)
if entry.startswith("domain"):
continue
vendor = read_file(os.path.join(path, "vendor_name")) or "Unknown TB Device"
product = read_file(os.path.join(path, "device_name")) or entry
vendor_id = read_file(os.path.join(path, "vendor_id")) or ""
device_id = read_file(os.path.join(path, "device_id")) or ""
serial = read_file(os.path.join(path, "unique_id")) or ""
authorized = read_file(os.path.join(path, "authorized"))
is_authorized = authorized == "1"
# All Thunderbolt devices are DMA-capable
device = {
"id": path,
"name": product,
"vendor": vendor,
"vendorId": vendor_id,
"productId": device_id,
"serial": serial,
"busType": "thunderbolt",
"driver": "thunderbolt",
"devPath": "",
"sysPath": path,
"authorized": is_authorized,
"interfaces": ["thunderbolt"],
"mountPoint": "",
"sizeBytes": 0,
"dmaCapable": True,
"firstSeen": now_iso(),
"whitelisted": False,
}
devices.append(device)
return devices
def scan_bluetooth_devices():
"""Scan for Bluetooth devices via hciconfig/bluetoothctl."""
devices = []
# Check if Bluetooth controller exists
hci_out = run(["hciconfig", "-a"], timeout=5)
if not hci_out:
return devices
# Parse hci devices
for match in re.finditer(r'(hci\d+).*?BD Address: ([0-9A-Fa-f:]+)', hci_out, re.DOTALL):
hci_dev, bd_addr = match.group(1), match.group(2)
# Get paired/trusted devices via btmgmt or bluetoothctl
devices_out = run(["bluetoothctl", "devices"], timeout=5)
for dev_match in re.finditer(r'Device ([0-9A-Fa-f:]+) (.+)', devices_out):
dev_addr, dev_name = dev_match.group(1), dev_match.group(2)
# Check if trusted
info_out = run(["bluetoothctl", "info", dev_addr], timeout=3)
trusted = "Trusted: yes" in info_out
paired = "Paired: yes" in info_out
device = {
"id": f"/sys/bluetooth/{dev_addr}",
"name": dev_name,
"vendor": "Bluetooth",
"vendorId": "",
"productId": dev_addr,
"serial": dev_addr,
"busType": "bluetooth",
"driver": "btusb",
"devPath": "",
"sysPath": f"/sys/bluetooth/{dev_addr}",
"authorized": trusted,
"interfaces": ["bluetooth"],
"mountPoint": "",
"sizeBytes": 0,
"dmaCapable": False,
"firstSeen": now_iso(),
"whitelisted": trusted,
}
devices.append(device)
return devices
def scan_pci_devices():
"""Scan for recently-added PCI devices via lspci."""
devices = []
lspci_out = run(["lspci", "-mn"], timeout=5)
if not lspci_out:
return devices
for line in lspci_out.splitlines():
# Format: domain:bus:dev.func "class" "vendor" "device" ...
m = re.match(r'([\d:.]+)\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"', line)
if not m:
continue
pci_addr, pci_class, vendor_id, device_id = m.groups()
# Only flag unusual devices — skip common classes (VGA, network, storage, USB host)
skip_classes = {"0300", "0200", "0100", "0106", "0108", "0c03"}
if pci_class.replace(" ", "") in skip_classes:
continue
# Get human-readable name
desc_out = run(["lspci", "-s", pci_addr], timeout=3)
name = desc_out.split(": ", 1)[-1].strip() if ": " in desc_out else f"PCI Device {device_id}"
device = {
"id": f"/sys/bus/pci/devices/{pci_addr}",
"name": name,
"vendor": vendor_id,
"vendorId": vendor_id,
"productId": device_id,
"serial": "",
"busType": "pci",
"driver": "",
"devPath": "",
"sysPath": f"/sys/bus/pci/devices/{pci_addr}",
"authorized": True,
"interfaces": [],
"mountPoint": "",
"sizeBytes": 0,
"dmaCapable": True, # PCI devices can do DMA
"firstSeen": now_iso(),
"whitelisted": False,
}
devices.append(device)
return devices
# ── Alert Generation ────────────────────────────────────────
def generate_alerts(all_devices, policy, whitelist):
"""Generate alerts for devices that violate policy."""
alerts = []
alert_id = 0
# Mark whitelist status
for dev in all_devices:
dev["whitelisted"] = is_whitelisted(dev, whitelist)
for dev in all_devices:
alert_id += 1
name = dev["name"]
bus = dev["busType"]
# USB mass storage
if bus == "usb" and "mass-storage" in dev["interfaces"]:
if policy.get("blockUsbStorage") and not dev["whitelisted"]:
alerts.append({
"id": f"hw-alert-{alert_id}",
"timestamp": now_iso(),
"category": "usb-storage",
"severity": "critical",
"status": "active",
"message": f"Foreign USB mass storage detected: {name} ({dev['vendor']}) at {dev['devPath'] or 'unknown device'}",
"device": dev,
"rule": "usb-storage-block",
"autoAction": "blocked" if policy.get("autoBlock") else None,
})
# USB non-storage (lower severity)
elif bus == "usb" and not dev["whitelisted"]:
alerts.append({
"id": f"hw-alert-{alert_id}",
"timestamp": now_iso(),
"category": "usb-device",
"severity": "medium",
"status": "active",
"message": f"Unknown USB device connected: {name} ({dev['vendor']})",
"device": dev,
"rule": "usb-device-monitor",
})
# Thunderbolt DMA
if bus == "thunderbolt" and dev["dmaCapable"]:
if policy.get("blockThunderboltDMA") and not dev["authorized"] and not dev["whitelisted"]:
alerts.append({
"id": f"hw-alert-{alert_id}",
"timestamp": now_iso(),
"category": "thunderbolt-dma",
"severity": "critical",
"status": "active",
"message": f"Unauthorized DMA-capable Thunderbolt device: {name} ({dev['vendor']})",
"device": dev,
"rule": "thunderbolt-dma-block",
"autoAction": "blocked" if policy.get("autoBlock") else None,
})
# Bluetooth anomalies
if bus == "bluetooth" and not dev["authorized"] and policy.get("blockUnknownBluetooth"):
alerts.append({
"id": f"hw-alert-{alert_id}",
"timestamp": now_iso(),
"category": "bluetooth",
"severity": "high",
"status": "active",
"message": f"Untrusted Bluetooth device paired: {name} ({dev['serial']})",
"device": dev,
"rule": "bluetooth-untrusted",
})
# PCI device anomalies
if bus == "pci" and not dev["whitelisted"] and policy.get("blockUnknownPCI"):
alerts.append({
"id": f"hw-alert-{alert_id}",
"timestamp": now_iso(),
"category": "pci-device",
"severity": "medium",
"status": "active",
"message": f"Unknown PCI device present: {name} (vendor={dev['vendorId']})",
"device": dev,
"rule": "pci-device-monitor",
})
return alerts
# ── Commands ────────────────────────────────────────────────
def cmd_summary():
"""Full alert summary with all devices and policy."""
policy = load_policy()
whitelist = load_whitelist()
all_devices = []
all_devices.extend(scan_usb_devices())
all_devices.extend(scan_thunderbolt_devices())
all_devices.extend(scan_bluetooth_devices())
# PCI scan is optional — can be noisy
# all_devices.extend(scan_pci_devices())
alerts = generate_alerts(all_devices, policy, whitelist)
active = [a for a in alerts if a["status"] == "active"]
critical = [a for a in alerts if a["severity"] == "critical"]
unauthorized = [d for d in all_devices if not d["whitelisted"]]
return {
"alerts": alerts,
"activeCount": len(active),
"criticalCount": len(critical),
"totalDevices": len(all_devices),
"unauthorizedDevices": len(unauthorized),
"whitelistedDevices": len(all_devices) - len(unauthorized),
"policy": policy,
"whitelist": whitelist,
}
def cmd_devices():
"""List all detected hardware devices."""
all_devices = []
all_devices.extend(scan_usb_devices())
all_devices.extend(scan_thunderbolt_devices())
all_devices.extend(scan_bluetooth_devices())
return {"devices": all_devices, "count": len(all_devices)}
def cmd_alerts():
"""List active alerts only."""
summary = cmd_summary()
return {"alerts": summary["alerts"], "activeCount": summary["activeCount"]}
def cmd_acknowledge(alert_id):
"""Acknowledge an alert (would persist to state in production)."""
return {"action": "acknowledge", "alertId": alert_id, "status": "acknowledged"}
def cmd_dismiss(alert_id):
"""Dismiss an alert."""
return {"action": "dismiss", "alertId": alert_id, "status": "dismissed"}
def cmd_block(device_id):
"""Block a device — for USB, writes '0' to authorized sysfs."""
# Try USB authorization
auth_path = os.path.join(device_id, "authorized")
if os.path.exists(auth_path):
try:
with open(auth_path, 'w') as f:
f.write('0')
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize"}
except PermissionError:
# Need sudo
run(["sudo", "tee", auth_path], timeout=5)
return {"action": "block", "deviceId": device_id, "result": "blocked", "method": "usb-authorize-sudo"}
return {"action": "block", "deviceId": device_id, "result": "no-method-available"}
def cmd_unblock(device_id):
"""Unblock a device."""
auth_path = os.path.join(device_id, "authorized")
if os.path.exists(auth_path):
run(["sudo", "sh", "-c", f"echo 1 > {auth_path}"], timeout=5)
return {"action": "unblock", "deviceId": device_id, "result": "unblocked"}
return {"action": "unblock", "deviceId": device_id, "result": "no-method-available"}
def cmd_whitelist(device_id):
"""Add a device to the whitelist."""
whitelist = load_whitelist()
# Find device in current scan
all_devices = []
all_devices.extend(scan_usb_devices())
all_devices.extend(scan_thunderbolt_devices())
all_devices.extend(scan_bluetooth_devices())
for dev in all_devices:
if dev["id"] == device_id:
entry = {
"id": f"wl-{len(whitelist)+1}",
"vendorId": dev["vendorId"],
"productId": dev["productId"],
"serial": dev["serial"],
"name": dev["name"],
"busType": dev["busType"],
"addedAt": now_iso(),
"addedBy": "sysdeck",
}
whitelist.append(entry)
save_whitelist(whitelist)
return {"action": "whitelist", "device": dev["name"], "entry": entry}
return {"action": "whitelist", "deviceId": device_id, "result": "device-not-found"}
def cmd_unwhitelist(device_id):
"""Remove a device from the whitelist."""
whitelist = load_whitelist()
# Remove by matching serial or vendorId:productId
new_wl = []
for entry in whitelist:
if entry.get("serial") and device_id in entry.get("serial", ""):
continue
new_wl.append(entry)
save_whitelist(new_wl)
return {"action": "unwhitelist", "deviceId": device_id, "remaining": len(new_wl)}
def cmd_policy(key, value):
"""Update a policy toggle."""
policy = load_policy()
if key in policy:
policy[key] = value.lower() in ("true", "1", "yes")
save_policy(policy)
return {"action": "policy", "key": key, "value": policy[key], "policy": policy}
return {"error": f"Unknown policy key: {key}"}
# ── Main ────────────────────────────────────────────────────
def main():
if len(sys.argv) < 2:
print(json.dumps(cmd_summary()))
return
cmd = sys.argv[1]
if cmd == "summary":
print(json.dumps(cmd_summary()))
elif cmd == "devices":
print(json.dumps(cmd_devices()))
elif cmd == "alerts":
print(json.dumps(cmd_alerts()))
elif cmd == "acknowledge":
aid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_acknowledge(aid)))
elif cmd == "dismiss":
aid = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_dismiss(aid)))
elif cmd == "block":
did = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_block(did)))
elif cmd == "unblock":
did = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_unblock(did)))
elif cmd == "whitelist":
did = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_whitelist(did)))
elif cmd == "unwhitelist":
did = sys.argv[2] if len(sys.argv) > 2 else ""
print(json.dumps(cmd_unwhitelist(did)))
elif cmd == "policy":
key = sys.argv[2] if len(sys.argv) > 2 else ""
val = sys.argv[3] if len(sys.argv) > 3 else ""
print(json.dumps(cmd_policy(key, val)))
else:
print(json.dumps({"error": f"Unknown command: {cmd}"}))
if __name__ == "__main__":
main()

63
bridge/integrity.py Executable file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
SysDeck - Integrity Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Runs lynis audit system and parses the hardening index from the report.
Usage:
python3 -m sysdeck.bridge.integrity score
python3 -m sysdeck.bridge.integrity scan
"""
import json
import re
import subprocess
import sys
from typing import Any
HARDENING_RE = re.compile(r"Hardening index\s*:\s*(\d+)")
def score() -> int | None:
"""Return the latest hardening index, or None if lynis hasn't run."""
try:
with open("/var/log/lynis.log", encoding="utf-8") as f:
log = f.read()
except FileNotFoundError:
return None
m = HARDENING_RE.search(log)
return int(m.group(1)) if m else None
def scan() -> dict[str, Any]:
"""Run a fresh lynis audit and return the parsed result."""
try:
subprocess.run(
["lynis", "audit", "system"], capture_output=True, text=True, check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
return {"error": str(exc), "score": None}
return {"score": score()}
COMMANDS = {
"score": lambda _args: score(),
"scan": lambda _args: scan(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:])))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

311
bridge/jellyfin.py Executable file
View File

@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""SysDeck - Jellyfin Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages the Jellyfin media server as a systemd service and exposes
the built-in Jellyfin web UI for iframe embedding in the SysDeck
panel.
v0.0.35 directive: "next we will integrate a jellyfin management
module where it starts, stops, and loads the admin panel in the
module." Jellyfin ships a single systemd unit (jellyfin.service) on
every distro that packages it, and serves a full web UI on
http://127.0.0.1:8096. The bridge starts/stops the service via
systemctl; the panel iframes the running web UI — same pattern as
the v0.0.34 Glances integration (bridge/glances.py).
Subcommands:
summary — service status + version + port + library counts
status — service status only (active/sub/uptime)
start — systemctl start jellyfin.service
stop — systemctl stop jellyfin.service
restart — systemctl restart jellyfin.service
web-status — {running, port, url} for the iframe
libraries — best-effort library list from Jellyfin's HTTP API
Cockpit way (v0.0.31+ pattern): the bridge runs systemctl via
subprocess directly — no `sudo` shell-out. The JS panel passes
{ superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts
the operator via polkit for the org.sysdeck.jellyfin.modify action
(added in v0.0.35).
Jellyfin is GPL-2.0 licensed by the Jellyfin contributors. This
bridge helper invokes it as a separate process via subprocess — the
suite (MIT) and Jellyfin (GPL-2.0) remain independent programs. No
Jellyfin code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/jellyfin.py summary
python3 /usr/lib/sysdeck/bridge/jellyfin.py start
python3 /usr/lib/sysdeck/bridge/jellyfin.py web-status
"""
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from typing import Any
JELLYFIN_LICENSE = "GPL-2.0"
JELLYFIN_URL = "https://jellyfin.org/"
JELLYFIN_SERVICE = "jellyfin.service"
# Default Jellyfin webserver port. The operator can override via
# /etc/jellyfin/networking.xml; this default matches Jellyfin's
# out-of-the-box config.
JELLYFIN_WEB_HOST = "127.0.0.1"
JELLYFIN_WEB_PORT = 8096
# Default URL the panel iframes. Jellyfin binds to 0.0.0.0 by default;
# the panel uses 127.0.0.1 to keep the iframe on the cockpit host.
JELLYFIN_WEB_URL = f"http://{JELLYFIN_WEB_HOST}:{JELLYFIN_WEB_PORT}"
def _have(binary: str) -> bool:
"""True if binary is on PATH."""
return shutil.which(binary) is not None
def _systemctl_show(unit: str, props: list[str]) -> dict[str, str]:
"""Return a dict of {property: value} from systemctl show."""
out = subprocess.run(
["systemctl", "show", unit, "--property=" + ",".join(props)],
capture_output=True, text=True, timeout=5,
).stdout.strip()
result = {}
for line in out.splitlines():
k, _, v = line.partition("=")
if k:
result[k] = v
return result
def _service_status(unit: str) -> dict[str, Any]:
"""Return service state dict: {active, sub, status, uptime_seconds}."""
props = _systemctl_show(unit, ["ActiveState", "SubState", "ActiveEnterTimestamp"])
active = props.get("ActiveState", "unknown")
sub = props.get("SubState", "unknown")
uptime = 0
ts = props.get("ActiveEnterTimestamp", "")
if ts:
try:
dt = datetime.strptime(ts[:25], "%a %Y-%m-%d %H:%M:%S")
uptime = int((datetime.now() - dt).total_seconds())
except (ValueError, OSError):
pass
if active == "active":
status = "running"
elif active == "activating":
status = "starting"
elif active == "failed":
status = "error"
elif active in ("inactive", "deactivating"):
status = "stopped"
else:
status = "unknown"
return {"active": active, "sub": sub, "status": status, "uptime_seconds": uptime}
def _detect_version() -> str:
"""Best-effort Jellyfin version detection."""
if _have("jellyfin"):
out = subprocess.run(
["jellyfin", "--version"], capture_output=True, text=True, timeout=5,
).stdout.strip()
return out.splitlines()[0][:80] if out else ""
# Read from /etc/jellyfin/jellyfin.db if installed (best-effort)
return ""
def _detect_port() -> int:
"""Return the configured Jellyfin port (best-effort).
Reads /etc/jellyfin/networking.xml if present; falls back to
the default 8096.
"""
try:
with open("/etc/jellyfin/networking.xml", encoding="utf-8") as fh:
for line in fh:
if "<Port>" in line and "</Port>" in line:
port_str = line.split("<Port>")[1].split("</Port>")[0].strip()
return int(port_str)
except (FileNotFoundError, ValueError, PermissionError, OSError):
pass
return JELLYFIN_WEB_PORT
def cmd_summary(_args: list[str]) -> dict[str, Any]:
"""Combined summary — service status + version + port + URL."""
if not _have("jellyfin") and not _unit_loaded():
return {
"available": False,
"reason": "jellyfin not installed",
"install": (
"Arch: pacman -S jellyfin · "
"Debian: apt install jellyfin · "
"Fedora: dnf install jellyfin"
),
"license": JELLYFIN_LICENSE,
"url": JELLYFIN_URL,
}
state = _service_status(JELLYFIN_SERVICE)
port = _detect_port()
return {
"available": True,
"service": JELLYFIN_SERVICE,
"status": state["status"],
"active": state["active"],
"sub": state["sub"],
"uptime_seconds": state["uptime_seconds"],
"version": _detect_version(),
"port": port,
"url": f"http://{JELLYFIN_WEB_HOST}:{port}",
"license": JELLYFIN_LICENSE,
"homepage": JELLYFIN_URL,
}
def _unit_loaded() -> bool:
"""True if the jellyfin.service unit is loaded on the host."""
out = subprocess.run(
["systemctl", "list-unit-files", JELLYFIN_SERVICE],
capture_output=True, text=True, timeout=5,
).stdout
return JELLYFIN_SERVICE in out
def cmd_status(_args: list[str]) -> dict[str, Any]:
"""Detailed service status."""
if not _have("jellyfin") and not _unit_loaded():
return {"available": False, "reason": "jellyfin not installed"}
return _service_status(JELLYFIN_SERVICE)
def _systemctl(action: str) -> dict[str, Any]:
"""Run systemctl <action> jellyfin.service and return the result."""
if not _have("systemctl"):
return {"rc": 127, "success": False, "stderr": "systemctl not found"}
try:
r = subprocess.run(
["systemctl", action, JELLYFIN_SERVICE],
capture_output=True, text=True, timeout=30,
)
return {
"action": action,
"service": JELLYFIN_SERVICE,
"rc": r.returncode,
"success": r.returncode == 0,
"output": (r.stdout or "").strip(),
"stderr": (r.stderr or "").strip(),
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"action": action, "service": JELLYFIN_SERVICE,
"rc": 1, "success": False, "stderr": str(exc)}
def cmd_start(_args: list[str]) -> dict[str, Any]:
return _systemctl("start")
def cmd_stop(_args: list[str]) -> dict[str, Any]:
return _systemctl("stop")
def cmd_restart(_args: list[str]) -> dict[str, Any]:
return _systemctl("restart")
def cmd_web_status(_args: list[str]) -> dict[str, Any]:
"""Return the Jellyfin web UI URL + running state for iframe embedding."""
if not _have("jellyfin") and not _unit_loaded():
return {
"available": False,
"reason": "jellyfin not installed",
"install": (
"Arch: pacman -S jellyfin · "
"Debian: apt install jellyfin · "
"Fedora: dnf install jellyfin"
),
"license": JELLYFIN_LICENSE,
"url": JELLYFIN_URL,
}
state = _service_status(JELLYFIN_SERVICE)
port = _detect_port()
return {
"available": True,
"running": state["status"] == "running",
"status": state["status"],
"port": port,
"url": f"http://{JELLYFIN_WEB_HOST}:{port}",
"license": JELLYFIN_LICENSE,
"homepage": JELLYFIN_URL,
}
def cmd_libraries(_args: list[str]) -> dict[str, Any]:
"""Best-effort library list via the Jellyfin HTTP API.
Calls GET /Library/VirtualFolders on the local Jellyfin instance.
Returns {libraries: [...], count: N} or {error: ...} if the API
is unreachable or the operator has not yet completed initial
setup (no admin user → 401).
"""
import urllib.request
import urllib.error
port = _detect_port()
url = f"http://{JELLYFIN_WEB_HOST}:{port}/Library/VirtualFolders"
try:
# Jellyfin's public API doesn't require auth for /Library/VirtualFolders
# when called from localhost on default config — best-effort.
req = urllib.request.Request(url, headers={
"Accept": "application/json",
"X-Emby-Authorization": 'MediaBrowser Client="SysDeck", Device="Cockpit", Version="0.0.35"',
})
with urllib.request.urlopen(req, timeout=4) as resp:
data = json.loads(resp.read().decode("utf-8"))
libs = [
{
"name": lib.get("Name", "?"),
"type": lib.get("CollectionType", "mixed"),
"paths": lib.get("Locations", []),
}
for lib in (data if isinstance(data, list) else [])
]
return {"libraries": libs, "count": len(libs)}
except urllib.error.HTTPError as exc:
return {"error": f"HTTP {exc.code}: {exc.reason} — Jellyfin may need initial setup via the web UI first."}
except urllib.error.URLError as exc:
return {"error": f"Connection refused: {exc.reason} — Jellyfin may not be running."}
except (ValueError, OSError, KeyError) as exc:
return {"error": str(exc)}
COMMANDS = {
"summary": lambda args: cmd_summary(args),
"status": lambda args: cmd_status(args),
"start": lambda args: cmd_start(args),
"stop": lambda args: cmd_stop(args),
"restart": lambda args: cmd_restart(args),
"web-status": lambda args: cmd_web_status(args),
"libraries": lambda args: cmd_libraries(args),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

648
bridge/kata.py Executable file
View File

@ -0,0 +1,648 @@
#!/usr/bin/env python3
"""
SysDeck - Kata Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.0.38 PRODUCTION REWRITE. The v0.0.35-v0.0.37 Kata panel shipped a
pre-built React bundle from the upstream cockpit-kata sub-project. That
bundle displayed HARDCODED MOCK DATA — 5 fake sandboxes (web-frontend-
prod, api-gateway-staging, etc.) with synthetic UUIDs and createdAt
timestamps, fake metrics (cpuUsagePercent, memoryUsageMB, historyCpu/
historyMemory arrays), a fake QCrows bundle catalog, and a fake PXE
status (always dnsmasqRunning:true). The only real features were the
QCrows kernel-bundle extraction (qcrows-export / qcrows-initrd-regen
via cockpit.spawn) and the kata-runtime check call.
v0.0.38 deletes the React bundle and ships a vanilla-JS panel backed
by this Python bridge helper. Every subcommand calls the REAL Kata
Containers 3.x APIs:
list enumerate kata sandboxes via:
1. kata-monitor HTTP /sandboxes (if running)
2. filesystem /run/vc/sbs/<id>/ (Go shim)
3. filesystem /run/kata/<id>/ (Rust shim)
Returns [{id, source, vm_pid, agent_socket}].
inspect <id> per-sandbox detail via kata-monitor /agent-url
+ filesystem probe of /run/vc/sbs/<id>/ or
/run/kata/<id>/. Returns {id, agent_url,
shim_socket, config_path, ...}.
metrics <id> per-sandbox Prometheus metrics via kata-monitor
/metrics?sandbox=<id>. Returns parsed metric
families (cpu, memory, network, hypervisor).
summary aggregate state: sandbox count by status,
kata-runtime version, kata-monitor status,
host capability (kata-runtime check exit code).
version kata-runtime version (plain-text parse) +
kata-runtime env --json (structured).
check kata-runtime check (exit-code based — 0 = OK).
pxe-status real PXE/TFTP status: systemctl is-active
dnsmasq + test -d /srv/tftp + ls
/srv/tftp/pxelinux.cfg/.
qcrows-list list QCrows kernel bundles in
/usr/share/sysdeck/kata/qcrows/.
qcrows-export invoke qcrows-export (real binary).
qcrows-initrd-regen invoke qcrows-initrd-regen (real binary).
KEY DESIGN DECISIONS (Kata 3.x reality):
- kata-runtime list/inspect were REMOVED in 3.x. Do not call them.
- kata-monitor /sandboxes returns PLAIN TEXT (one ID per line),
NOT JSON. Do not json.loads() it.
- kata-monitor /metrics returns PROMETHEUS TEXT FORMAT, not JSON.
Parse with prometheus_client.parser.text_string_to_metric_families.
- kata-runtime env --json uses CAPITALIZED Go field names (no json
tags): Runtime, Hypervisor, Host, Version, Semver, Commit, etc.
- Sandbox IDs are 64 hex chars. Validate with ^[0-9a-f]{64}$.
- kata-monitor binds to 127.0.0.1:8090 by default.
SECURITY HARDENING (v0.0.36 + v0.0.37):
- Array-form subprocess only (shell=False). CVE-2019-15107 lesson.
- "--" separator before user-supplied positionals. CVE-2026-4631.
- Strict allowlist regex on sandbox IDs (^+[0-9a-f]{64}$).
CVE-2024-2947 lesson.
- Env scrubbed (SCRUBBED_ENV) on every privileged subprocess.
CVE-2024-6126 lesson.
- Output sanitized (truncated + non-printable stripped).
CVE-2022-36446 lesson.
- HTTP requests to kata-monitor use urllib with a 5s timeout and
reject redirects (no SSRF). CVE-2020-35850 lesson.
- No eval / pickle / yaml.unsafe_load. CVE-2019-15642 lesson.
- Path resolution with realpath + startswith base check for the
qcrows-list / qcrows-export paths. CVE-2022-30708 lesson.
Usage:
python3 /usr/lib/sysdeck/bridge/kata.py list
python3 /usr/lib/sysdeck/bridge/kata.py inspect <sandbox-id>
python3 /usr/lib/sysdeck/bridge/kata.py metrics <sandbox-id>
python3 /usr/lib/sysdeck/bridge/kata.py summary
python3 /usr/lib/sysdeck/bridge/kata.py version
python3 /usr/lib/sysdeck/bridge/kata.py check
python3 /usr/lib/sysdeck/bridge/kata.py pxe-status
python3 /usr/lib/sysdeck/bridge/kata.py qcrows-list
"""
import json
import os
import re
import shutil
import subprocess
import sys
import urllib.request
import urllib.error
from pathlib import Path
from typing import Any
# ── Constants ────────────────────────────────────────────────────────
# kata-monitor defaults to 127.0.0.1:8090.
KATA_MONITOR_URL = "http://127.0.0.1:8090"
KATA_MONITOR_TIMEOUT = 5 # seconds
# Filesystem paths where kata shims register sandbox state.
# Go shim (containerd-shim-kata-v2): /run/vc/sbs/<id>/
# Rust shim (containerd-shim-kata-rs-v2): /run/kata/<id>/
KATA_GO_SHIM_DIR = Path("/run/vc/sbs")
KATA_RUST_SHIM_DIR = Path("/run/kata")
# QCrows kernel bundle directory (shipped by sysdeck-kata package).
QCROWS_DIR = Path("/usr/share/sysdeck/kata/qcrows")
# v0.0.37 security helpers (reused from firewall.py via import).
# We import them to keep ONE source of truth for the hardening.
sys.path.insert(0, str(Path(__file__).parent))
try:
from firewall import ( # type: ignore
SCRUBBED_ENV,
_sanitize_output,
_validate_filename,
_resolve_path_under_base,
)
except ImportError:
# Standalone fallback (if firewall.py isn't importable at runtime).
SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
def _sanitize_output(text: str, max_len: int = 4096) -> str:
if not text:
return ""
if len(text) > max_len:
text = text[:max_len] + " ... (truncated)"
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
FILENAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def _validate_filename(name: str) -> bool:
if not name or len(name) > 64:
return False
return bool(FILENAME_RE.match(name))
def _resolve_path_under_base(path_str: str, base_dir: Path) -> Path | None:
if not path_str or ".." in Path(path_str).parts:
return None
try:
real = Path(os.path.realpath(path_str))
real.relative_to(base_dir)
return real
except (ValueError, OSError):
return None
# Sandbox ID validator: 64 hex chars (containerd/CRI pod ID format).
SANDBOX_ID_RE = re.compile(r"^[0-9a-f]{64}$")
def _validate_sandbox_id(sid: str) -> bool:
"""Return True if sid is a valid kata sandbox ID (64 hex chars).
CVE-2024-2947 lesson — validate before using in any subprocess argv
or HTTP query string.
"""
if not sid or len(sid) != 64:
return False
return bool(SANDBOX_ID_RE.match(sid))
# ── Subprocess helper ──────────────────────────────────────────────
def _run(argv: list[str], timeout: int = 30) -> tuple[int, str, str]:
"""Run argv and return (rc, stdout, stderr). Never raises.
v0.0.36 hardening: shell=False, env scrubbed, output sanitized.
"""
try:
r = subprocess.run(
argv, capture_output=True, text=True, check=False, timeout=timeout,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def _have(binary: str) -> bool:
"""Return True if binary is on PATH."""
return shutil.which(binary) is not None
# ── kata-monitor HTTP client ───────────────────────────────────────
#
# kata-monitor is a standalone HTTP daemon (default 127.0.0.1:8090).
# It exposes /sandboxes (plain text, one ID per line) and /metrics
# (Prometheus text format). We use urllib (no external deps) with a
# strict 5s timeout and no redirect following (SSRF defense).
def _kata_monitor_get(path: str) -> tuple[int, str, str]:
"""GET <KATA_MONITOR_URL><path>. Returns (status, body, error).
v0.0.37 hardening:
- 5s timeout (DoS defense).
- no redirect following (SSRF defense — CVE-2020-35850 lesson).
- only http:// scheme (no file:// / gopher:// etc.).
"""
url = KATA_MONITOR_URL + path
if not url.startswith("http://127.0.0.1:"):
return 0, "", f"refusing non-localhost URL: {url}"
try:
req = urllib.request.Request(url, headers={"Accept": "text/plain"})
# No redirect handler → redirects are rejected (SSRF defense).
opener = urllib.request.build_opener(NoRedirectHandler)
with opener.open(req, timeout=KATA_MONITOR_TIMEOUT) as resp:
body = resp.read().decode("utf-8", errors="replace")
return resp.status, body, ""
except urllib.error.HTTPError as e:
return e.code, "", f"HTTP {e.code}: {e.reason}"
except urllib.error.URLError as e:
return 0, "", f"connection refused (kata-monitor not running?): {e.reason}"
except Exception as e:
return 0, "", str(e)
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject HTTP redirects — SSRF defense (CVE-2020-35850 lesson)."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # raise HTTPError instead of following
# ── Sandbox enumeration (3.x: filesystem + kata-monitor) ──────────
#
# In Kata 3.x, `kata-runtime list` was REMOVED. Sandboxes are
# enumerated via:
# 1. kata-monitor HTTP /sandboxes (if the monitor is running)
# 2. filesystem: /run/vc/sbs/<id>/ (Go shim)
# 3. filesystem: /run/kata/<id>/ (Rust shim)
# We try all three and merge, marking each sandbox with its source.
def _list_from_kata_monitor() -> list[dict[str, Any]]:
"""List sandbox IDs via kata-monitor /sandboxes (plain text)."""
status, body, err = _kata_monitor_get("/sandboxes")
if status != 200 or not body:
return []
sandboxes: list[dict[str, Any]] = []
for line in body.splitlines():
sid = line.strip()
if _validate_sandbox_id(sid):
sandboxes.append({"id": sid, "source": "kata-monitor"})
return sandboxes
def _list_from_filesystem(shim_dir: Path, shim_name: str) -> list[dict[str, Any]]:
"""List sandbox IDs by enumerating a shim state directory."""
if not shim_dir.is_dir():
return []
sandboxes: list[dict[str, Any]] = []
try:
for entry in shim_dir.iterdir():
if not entry.is_dir():
continue
sid = entry.name
if _validate_sandbox_id(sid):
sandboxes.append({"id": sid, "source": f"fs-{shim_name}"})
except (PermissionError, OSError):
pass
return sandboxes
def cmd_list(_args: list[str]) -> list[dict[str, Any]]:
"""List kata sandboxes from all available sources.
Merges kata-monitor + Go shim fs + Rust shim fs, deduplicating by
sandbox ID. Each entry: {id, source, agent_url, shim_socket}.
Returns [] if no sandboxes are running (the empty state — NOT a
mock array).
"""
seen: dict[str, dict[str, Any]] = {}
# Source priority: kata-monitor (richest) > fs-go > fs-rust.
for sb in _list_from_kata_monitor():
seen[sb["id"]] = sb
for sb in _list_from_filesystem(KATA_GO_SHIM_DIR, "go"):
if sb["id"] not in seen:
seen[sb["id"]] = sb
for sb in _list_from_filesystem(KATA_RUST_SHIM_DIR, "rust"):
if sb["id"] not in seen:
seen[sb["id"]] = sb
# Enrich each with agent_url + shim_socket if available.
result: list[dict[str, Any]] = []
for sid, sb in seen.items():
agent_url = _get_agent_url(sid)
sb["agent_url"] = agent_url
sb["shim_socket"] = _find_shim_socket(sid)
result.append(sb)
# Sort by ID for deterministic output.
result.sort(key=lambda s: s["id"])
return result
def _get_agent_url(sid: str) -> str | None:
"""Query kata-monitor /agent-url?sandbox=<sid> for the agent URL."""
if not _validate_sandbox_id(sid):
return None
# URL-encode the sid (it's hex, so no special chars, but defense
# in depth — never interpolate raw into a URL).
import urllib.parse
qs = urllib.parse.urlencode({"sandbox": sid})
status, body, err = _kata_monitor_get(f"/agent-url?{qs}")
if status == 200 and body:
return body.strip()
return None
def _find_shim_socket(sid: str) -> str | None:
"""Find the shim-monitor.sock path for a sandbox.
Go shim: /run/vc/sbs/<id>/shim-monitor.sock
Rust shim: /run/kata/<id>/shim-monitor.sock
"""
if not _validate_sandbox_id(sid):
return None
for base in (KATA_GO_SHIM_DIR / sid, KATA_RUST_SHIM_DIR / sid):
sock = base / "shim-monitor.sock"
if sock.is_socket():
return str(sock)
return None
# ── Subcommand: inspect ────────────────────────────────────────────
def cmd_inspect(args: list[str]) -> dict[str, Any]:
"""Inspect a single sandbox by ID.
Returns {id, source, agent_url, shim_socket, config_path,
sandbox_dir, status} or {error: ...} on invalid ID / not found.
"""
if not args:
return {"error": "sandbox ID required"}
sid = args[0]
if not _validate_sandbox_id(sid):
return {"error": f"invalid sandbox ID (expected 64 hex chars): {sid!r}"}
# Find the sandbox dir.
sandbox_dir: str | None = None
source = "unknown"
for base, shim in [(KATA_GO_SHIM_DIR, "go"), (KATA_RUST_SHIM_DIR, "rust")]:
d = base / sid
if d.is_dir():
sandbox_dir = str(d)
source = f"fs-{shim}"
break
agent_url = _get_agent_url(sid)
shim_socket = _find_shim_socket(sid)
# If we have neither fs state nor agent URL, the sandbox doesn't exist.
if sandbox_dir is None and agent_url is None and shim_socket is None:
# Check kata-monitor too.
ids = [s["id"] for s in _list_from_kata_monitor()]
if sid not in ids:
return {"error": f"sandbox {sid} not found"}
source = "kata-monitor"
return {
"id": sid,
"source": source,
"sandbox_dir": sandbox_dir,
"agent_url": agent_url,
"shim_socket": shim_socket,
"status": "running" if (sandbox_dir or agent_url) else "unknown",
}
# ── Subcommand: metrics ────────────────────────────────────────────
def cmd_metrics(args: list[str]) -> dict[str, Any]:
"""Fetch Prometheus metrics for a sandbox via kata-monitor.
Returns {id, metrics: {cpu_usage_percent, memory_usage_mb,
network_rx_bytes, network_tx_bytes, raw_families: [...]}} or
{error: ...} on invalid ID / kata-monitor not running.
The raw Prometheus text is parsed into metric families if
prometheus_client is available; otherwise the raw text is returned.
"""
if not args:
return {"error": "sandbox ID required"}
sid = args[0]
if not _validate_sandbox_id(sid):
return {"error": f"invalid sandbox ID: {sid!r}"}
import urllib.parse
qs = urllib.parse.urlencode({"sandbox": sid})
status, body, err = _kata_monitor_get(f"/metrics?{qs}")
if status != 200:
return {"error": f"kata-monitor /metrics failed: {err}", "id": sid}
# Try to parse Prometheus text into structured families.
families: list[dict[str, Any]] = []
try:
from prometheus_client.parser import text_string_to_metric_families
for fam in text_string_to_metric_families(body):
samples = []
for s in fam.samples:
samples.append({"name": s.name, "labels": dict(s.labels), "value": s.value})
families.append({"name": fam.name, "type": fam.type, "samples": samples})
except ImportError:
# prometheus_client not installed — return raw text.
return {"id": sid, "raw": body, "parsed": False}
# Extract the key metrics the panel cares about.
summary = _extract_metric_summary(families, sid)
return {"id": sid, "parsed": True, "families": families, "summary": summary}
def _extract_metric_summary(families: list[dict[str, Any]], sid: str) -> dict[str, Any]:
"""Extract cpu/memory/network summary from parsed Prometheus families."""
summary: dict[str, Any] = {
"cpu_usage_percent": None,
"memory_usage_bytes": None,
"network_rx_bytes": None,
"network_tx_bytes": None,
"uptime_seconds": None,
}
for fam in families:
name = fam.get("name", "")
for s in fam.get("samples", []):
# Only consider samples for this sandbox.
if s.get("labels", {}).get("sandbox_id") != sid:
continue
val = s.get("value")
if "cpu" in name and "usage" in name and summary["cpu_usage_percent"] is None:
summary["cpu_usage_percent"] = val
elif "memory" in name and "usage" in name and summary["memory_usage_bytes"] is None:
summary["memory_usage_bytes"] = val
elif "network" in name and "rx" in name:
summary["network_rx_bytes"] = val
elif "network" in name and "tx" in name:
summary["network_tx_bytes"] = val
elif "uptime" in name:
summary["uptime_seconds"] = val
return summary
# ── Subcommand: summary ────────────────────────────────────────────
def cmd_summary(_args: list[str]) -> dict[str, Any]:
"""Return aggregate sandbox state + runtime version + host capability.
This is the panel's header data: total sandboxes, running count,
kata-runtime version, kata-monitor status, host capability.
"""
sandboxes = cmd_list([])
# Determine "running" count — sandboxes with an agent_url or shim
# socket are considered running.
running = sum(1 for s in sandboxes if s.get("agent_url") or s.get("shim_socket"))
# kata-monitor status.
monitor_status: dict[str, Any] = {"running": False, "url": KATA_MONITOR_URL}
status, _, _ = _kata_monitor_get("/sandboxes")
if status == 200:
monitor_status["running"] = True
# kata-runtime version.
runtime_version = _kata_runtime_version()
# Host capability (kata-runtime check exit code).
capable, check_msg = _kata_check()
return {
"total_sandboxes": len(sandboxes),
"running_sandboxes": running,
"sandboxes": sandboxes,
"kata_monitor": monitor_status,
"kata_runtime": runtime_version,
"host_capable": capable,
"check_message": check_msg,
"kata_runtime_installed": _have("kata-runtime"),
"kata_monitor_installed": _have("kata-monitor"),
}
def _kata_runtime_version() -> dict[str, Any]:
"""Parse `kata-runtime version` (plain text) + `kata-runtime env --json`.
The version command output is:
kata-runtime : 3.7.0
commit : abc1234
OCI specs: 1.1.0-rc1
The env --json command returns structured JSON with Capitalized
Go field names (Runtime, Hypervisor, Host, Version, Semver, etc.).
"""
if not _have("kata-runtime"):
return {"installed": False, "version": None, "commit": None, "oci": None}
rc, out, err = _run(["kata-runtime", "version"], timeout=10)
version = commit = oci = None
if rc == 0:
for line in out.splitlines():
if "kata-runtime" in line and ":" in line:
version = line.split(":", 1)[1].strip()
elif "commit" in line and ":" in line:
commit = line.split(":", 1)[1].strip()
elif "OCI" in line and ":" in line:
oci = line.split(":", 1)[1].strip()
# Try env --json for structured info (best-effort).
env_info: dict[str, Any] = {}
rc2, out2, err2 = _run(["kata-runtime", "env", "--json"], timeout=10)
if rc2 == 0 and out2.strip():
try:
env_info = json.loads(out2)
except json.JSONDecodeError:
pass
return {
"installed": True,
"version": version,
"commit": commit,
"oci": oci,
"env": env_info,
}
def _kata_check() -> tuple[bool, str]:
"""Run `kata-runtime check`. Returns (capable, message).
Per Kata 3.x docs: exit 0 = capable, exit 1 = not capable.
The text output is "System is capable of running Kata Containers"
on success, or an error message on failure.
"""
if not _have("kata-runtime"):
return False, "kata-runtime not installed"
rc, out, err = _run(["kata-runtime", "check"], timeout=15)
if rc == 0:
return True, out.strip() or "System is capable of running Kata Containers"
return False, (err.strip() or out.strip() or "kata-runtime check failed")
# ── Subcommand: version ────────────────────────────────────────────
def cmd_version(_args: list[str]) -> dict[str, Any]:
"""Return kata-runtime version info."""
return _kata_runtime_version()
# ── Subcommand: check ──────────────────────────────────────────────
def cmd_check(_args: list[str]) -> dict[str, Any]:
"""Run kata-runtime check. Returns {capable, message}."""
capable, msg = _kata_check()
return {"capable": capable, "message": msg}
# ── Subcommand: pxe-status ─────────────────────────────────────────
#
# Real PXE/TFTP status — replaces the v0.0.37 mock that always
# returned dnsmasqRunning:true.
def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
"""Return real PXE/TFTP boot status.
Checks:
- systemctl is-active dnsmasq
- test -d /srv/tftp
- test -w /srv/tftp
- ls /srv/tftp/pxelinux.cfg/ (list existing entries)
"""
# dnsmasq service status.
rc, out, _ = _run(["systemctl", "is-active", "dnsmasq"], timeout=10)
dnsmasq_running = (rc == 0 and out.strip() == "active")
# /srv/tftp directory existence + writability.
tftp_dir = Path("/srv/tftp")
tftp_exists = tftp_dir.is_dir()
tftp_writable = os.access(str(tftp_dir), os.W_OK) if tftp_exists else False
# Existing pxelinux.cfg entries.
entries: list[str] = []
if tftp_exists:
cfg_dir = tftp_dir / "pxelinux.cfg"
if cfg_dir.is_dir():
try:
entries = sorted([e.name for e in cfg_dir.iterdir() if e.is_file()])
except (PermissionError, OSError):
entries = []
return {
"dnsmasq_running": dnsmasq_running,
"tftp_dir_exists": tftp_exists,
"tftp_dir_writable": tftp_writable,
"tftp_dir": "/srv/tftp",
"pxelinux_entries": entries,
"pxelinux_dir": "/srv/tftp/pxelinux.cfg/",
}
# ── Subcommand: qcrows-list ────────────────────────────────────────
#
# QCrows kernel bundles are the real feature for kata kernel/module
# compilation. The v0.0.37 React bundle had a mock catalog; this
# reads the real filesystem.
def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]:
"""List QCrows kernel bundles in /usr/share/sysdeck/kata/qcrows/.
Each entry: {filename, path, size_bytes, mtime}.
Returns [] if the directory doesn't exist (empty state, NOT mock).
"""
if not QCROWS_DIR.is_dir():
return []
bundles: list[dict[str, Any]] = []
try:
for entry in sorted(QCROWS_DIR.iterdir()):
if not entry.is_file():
continue
if not entry.name.endswith((".qcrows", ".tar.gz", ".tgz")):
continue
stat = entry.stat()
bundles.append({
"filename": entry.name,
"path": str(entry),
"size_bytes": stat.st_size,
"size_mb": round(stat.st_size / (1024 * 1024), 2),
"mtime": stat.st_mtime,
})
except (PermissionError, OSError):
pass
return bundles
# ── Dispatch table ─────────────────────────────────────────────────
COMMANDS = {
"list": cmd_list,
"inspect": cmd_inspect,
"metrics": cmd_metrics,
"summary": cmd_summary,
"version": cmd_version,
"check": cmd_check,
"pxe-status": cmd_pxe_status,
"qcrows-list": cmd_qcrows_list,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2, default=str))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

65
bridge/mesh.py Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
SysDeck - Service Mesh Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Lists Kubernetes services via `kubectl get services`. Returns an empty
items list when kubectl is not installed or the cluster is unreachable
— the module JS handles that as "0 Kubernetes services".
Usage:
python3 /usr/lib/sysdeck/bridge/mesh.py services
"""
import json
import subprocess
import sys
def services() -> dict:
"""Return Kubernetes services. Empty items list if kubectl absent or unreachable."""
try:
r = subprocess.run(
["kubectl", "get", "services", "-A", "-o", "json"],
capture_output=True, text=True, check=True, timeout=10,
)
data = json.loads(r.stdout) if r.stdout.strip() else {}
items = []
for item in data.get("items", []):
meta = item.get("metadata", {})
spec = item.get("spec", {})
items.append({
"name": meta.get("name", ""),
"namespace": meta.get("namespace", ""),
"type": spec.get("type", ""),
"clusterIP": spec.get("clusterIP", ""),
"ports": [
f"{p.get('port')}/{p.get('protocol', 'TCP')}"
for p in spec.get("ports", [])
],
})
return {"items": items}
except (FileNotFoundError, subprocess.CalledProcessError,
subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
return {"items": []}
COMMANDS = {
"services": lambda _args: services(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

396
bridge/mining.py Executable file
View File

@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
SysDeck - Mining Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
"themes and mining they need to be expanded for maximum ui
control. think 1999 power tool style here." The Mining Dashboard
panel surfaces every XMRig REST API knob:
summary — GET /1/summary (live hashrate, pool, threads)
threads — GET /1/summary → hashrate.threads[] detail
pool-config-get — GET /1/config → the pool section
pool-config-set — PUT /1/config with a new pool URL/username/pass
threads-config-get — GET /1/config → cpu.threads section
threads-config-set — PUT /1/config with new thread count
algorithm-get — GET /1/config → cpu.asm / randomx section
algorithm-set — PUT /1/config with a new algorithm preset
pause — POST /json_rpc method=paused (id 1)
resume — POST /json_rpc method=resumed (id 1)
pause-worker <id> — POST /json_rpc method=pause_worker
resume-worker <id> — POST /json_rpc method=resume_worker
start — systemctl start xmrig.service (superuser)
stop — systemctl stop xmrig.service (superuser)
restart — systemctl restart xmrig.service (superuser)
service-status — systemctl is-active xmrig.service (read-only)
Cockpit way (v0.0.31+ pattern): the bridge runs systemctl / curl via
subprocess directly; the JS panel passes { superuser: 'try' } for
mutating ops so the cockpit bridge prompts the operator via polkit
for the org.sysdeck.system.modify action (shipped since v0.0.17 —
authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc.).
XMRig is GPL-3.0 licensed by the XMRig project. This bridge helper
invokes its REST API over HTTP — the suite (MIT) and XMRig (GPL-3.0)
remain independent programs. No XMRig code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/mining.py summary
python3 /usr/lib/sysdeck/bridge/mining.py pool-config-set monero.hero '$wallet' x
python3 /usr/lib/sysdeck/bridge/mining.py threads-config-set 8
python3 /usr/lib/sysdeck/bridge/mining.py pause-worker 1
"""
import json
import os
import shutil
import subprocess
import sys
import urllib.request
import urllib.error
from typing import Any
XMRIG_URL = "http://127.0.0.1:18088"
XMRIG_SERVICE = "xmrig.service"
# Algorithm presets the panel surfaces as a `<select>`. XMRig auto-detects
# by default; the operator can force a specific variant. The names here
# match XMRig's `--coin` / `--algo` flag values.
ALGORITHM_PRESETS: list[dict[str, str]] = [
{"id": "auto", "name": "Auto (default)", "value": ""},
{"id": "rx/0", "name": "RandomX (Monero)", "value": "rx/0"},
{"id": "rx/wow", "name": "RandomWOW (Wownero)", "value": "rx/wow"},
{"id": "rx/arq", "name": "RandomARQ (ArQmA)", "value": "rx/arq"},
{"id": "rx/sfx", "name": "RandomSFX (Safex)", "value": "rx/sfx"},
{"id": "argon2/chukwa", "name": "Chukwa-2 (TurtleCoin)", "value": "argon2/chukwa"},
{"id": "argon2/wrkz", "name": "WRKZ (WrkzCoin)", "value": "argon2/wrkz"},
]
# ── HTTP helpers ─────────────────────────────────────────────────────
def _http_get(path: str, timeout: int = 3) -> dict[str, Any] | None:
"""GET <XMRIG_URL><path>, return parsed JSON or None on failure."""
try:
with urllib.request.urlopen(f"{XMRIG_URL}{path}", timeout=timeout) as r:
return json.loads(r.read().decode())
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, json.JSONDecodeError):
return None
def _http_post_json(path: str, body: dict[str, Any], timeout: int = 3) -> dict[str, Any] | None:
"""POST JSON to <XMRIG_URL><path>, return parsed JSON or None on failure."""
try:
req = urllib.request.Request(
f"{XMRIG_URL}{path}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, json.JSONDecodeError):
return None
def _http_put_json(path: str, body: dict[str, Any], timeout: int = 3) -> dict[str, Any] | None:
"""PUT JSON to <XMRIG_URL><path>, return parsed JSON or None on failure."""
try:
req = urllib.request.Request(
f"{XMRIG_URL}{path}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="PUT",
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode())
except (urllib.error.URLError, ConnectionError, TimeoutError, OSError, json.JSONDecodeError):
return None
def _is_xmrig_running() -> bool:
"""Quick connectivity check — GET /1/summary."""
return _http_get("/1/summary") is not None
def _have(binary: str) -> bool:
return shutil.which(binary) is not None
# ── Read-only subcommands ───────────────────────────────────────────
def cmd_summary(_args: list[str]) -> dict[str, Any] | None:
"""Return XMRig summary, or None if XMRig is not reachable."""
return _http_get("/1/summary")
def cmd_threads(_args: list[str]) -> dict[str, Any]:
"""Return per-thread hashrate detail from the summary endpoint."""
summary = _http_get("/1/summary")
if summary is None:
return {"available": False, "reason": "XMRig REST API not reachable",
"hint": "Confirm XMRig is running with --http-host 127.0.0.1 --http-port 18088."}
threads = (summary.get("hashrate") or {}).get("threads") or []
return {
"available": True,
"threads": [{"index": i, "hashrate": h if isinstance(h, (int, float)) else (h[0] if isinstance(h, list) and h else 0)}
for i, h in enumerate(threads)],
"thread_count": len(threads),
}
def cmd_pool_config_get(_args: list[str]) -> dict[str, Any]:
"""Return the current pool configuration from /1/config."""
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"}
pools = cfg.get("pools") or []
return {
"available": True,
"pools": pools,
"active_pool_index": 0, # XMRig fails over to the next pool on disconnect
"pool_count": len(pools),
}
def cmd_threads_config_get(_args: list[str]) -> dict[str, Any]:
"""Return the current thread configuration from /1/config."""
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"}
cpu = cfg.get("cpu") or {}
return {
"available": True,
"thread_count": cpu.get("threads", 0),
"hugepages": cpu.get("huge-pages", False),
"hw_aes": cpu.get("hw-aes", True),
"priority": cpu.get("priority"),
"cpu_affinity": cpu.get("cpu-affinity"),
"memory_pool": cpu.get("memory-pool"),
"yield": cpu.get("yield"),
}
def cmd_algorithm_get(_args: list[str]) -> dict[str, Any]:
"""Return the current algorithm + the preset list for the panel."""
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable",
"presets": ALGORITHM_PRESETS}
cpu = cfg.get("cpu") or {}
current = cpu.get("asm") or cpu.get("algo") or ""
return {
"available": True,
"current": current,
"presets": ALGORITHM_PRESETS,
}
def cmd_service_status(_args: list[str]) -> dict[str, Any]:
"""Return the systemd service state (read-only)."""
if not _have("systemctl"):
return {"available": False, "reason": "systemctl not on PATH"}
r = subprocess.run(["systemctl", "is-active", XMRIG_SERVICE],
capture_output=True, text=True, check=False, timeout=5)
state = r.stdout.strip() or "unknown"
return {
"available": True,
"service": XMRIG_SERVICE,
"state": state,
"active": state == "active",
}
# ── Mutating subcommands ────────────────────────────────────────────
def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
"""Set the pool URL / username / password via PUT /1/config.
Usage: pool-config-set <url> <username> [password]
The bridge reads the current config, replaces the first pool entry,
writes it back. The operator authenticates via polkit (the JS panel
passes superuser:'try').
"""
if len(args) < 2:
return {"error": "usage: pool-config-set <url> <username> [password]"}
url, username = args[0], args[1]
password = args[2] if len(args) > 2 else "x"
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"}
new_pool = {"url": url, "user": username, "pass": password, "rig-id": "", "nicehash": False, "keep-alive": True, "enabled": True}
if not cfg.get("pools"):
cfg["pools"] = [new_pool]
else:
cfg["pools"][0] = {**cfg["pools"][0], **new_pool}
result = _http_put_json("/1/config", cfg)
return {
"set": result is not None,
"url": url,
"username": username,
"password_set": password != "x",
"raw": result,
}
def cmd_threads_config_set(args: list[str]) -> dict[str, Any]:
"""Set the thread count via PUT /1/config."""
if not args:
return {"error": "usage: threads-config-set <count>"}
try:
count = int(args[0])
except ValueError:
return {"error": f"count must be numeric, got {args[0]}"}
if count < 1 or count > 256:
return {"error": f"count {count} out of range (1-256)"}
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"}
cfg.setdefault("cpu", {})["threads"] = count
result = _http_put_json("/1/config", cfg)
return {"set": result is not None, "thread_count": count, "raw": result}
def cmd_algorithm_set(args: list[str]) -> dict[str, Any]:
"""Set the algorithm via PUT /1/config.
Usage: algorithm-set <preset-id>. The preset-id must match one of
ALGORITHM_PRESETS — the bridge looks up the XMRig algo string
from there.
"""
if not args:
return {"error": "preset id required"}
preset_id = args[0]
preset = next((p for p in ALGORITHM_PRESETS if p["id"] == preset_id), None)
if preset is None:
return {"error": f"preset '{preset_id}' not found",
"available_presets": [p["id"] for p in ALGORITHM_PRESETS]}
cfg = _http_get("/1/config")
if cfg is None:
return {"available": False, "reason": "XMRig REST API not reachable"}
if preset["value"]:
cfg.setdefault("cpu", {})["asm"] = True
cfg["cpu"]["asm"] = preset["value"]
else:
cfg.setdefault("cpu", {}).pop("asm", None)
result = _http_put_json("/1/config", cfg)
return {"set": result is not None, "preset": preset_id, "algo": preset["value"], "raw": result}
def cmd_pause(_args: list[str]) -> dict[str, Any]:
"""Pause all mining via XMRig JSON-RPC."""
result = _http_post_json("/json_rpc", {"id": 1, "method": "paused"})
return {"paused": result is not None, "raw": result}
def cmd_resume(_args: list[str]) -> dict[str, Any]:
"""Resume all mining via XMRig JSON-RPC."""
result = _http_post_json("/json_rpc", {"id": 1, "method": "resumed"})
return {"resumed": result is not None, "raw": result}
def cmd_pause_worker(args: list[str]) -> dict[str, Any]:
"""Pause one worker (thread) by index via XMRig JSON-RPC."""
if not args:
return {"error": "worker id required"}
try:
worker_id = int(args[0])
except ValueError:
return {"error": f"worker id must be numeric, got {args[0]}"}
result = _http_post_json("/json_rpc", {"id": worker_id, "method": "pause_worker"})
return {"paused": result is not None, "worker_id": worker_id, "raw": result}
def cmd_resume_worker(args: list[str]) -> dict[str, Any]:
"""Resume one worker (thread) by index via XMRig JSON-RPC."""
if not args:
return {"error": "worker id required"}
try:
worker_id = int(args[0])
except ValueError:
return {"error": f"worker id must be numeric, got {args[0]}"}
result = _http_post_json("/json_rpc", {"id": worker_id, "method": "resume_worker"})
return {"resumed": result is not None, "worker_id": worker_id, "raw": result}
def cmd_start(_args: list[str]) -> dict[str, Any]:
"""Start the xmrig systemd service."""
if not _have("systemctl"):
return {"available": False, "reason": "systemctl not on PATH"}
r = subprocess.run(["systemctl", "start", XMRIG_SERVICE],
capture_output=True, text=True, check=False, timeout=15)
return {"started": r.returncode == 0, "rc": r.returncode,
"output": r.stdout, "stderr": r.stderr}
def cmd_stop(_args: list[str]) -> dict[str, Any]:
"""Stop the xmrig systemd service."""
if not _have("systemctl"):
return {"available": False, "reason": "systemctl not on PATH"}
r = subprocess.run(["systemctl", "stop", XMRIG_SERVICE],
capture_output=True, text=True, check=False, timeout=15)
return {"stopped": r.returncode == 0, "rc": r.returncode,
"output": r.stdout, "stderr": r.stderr}
def cmd_restart(_args: list[str]) -> dict[str, Any]:
"""Restart the xmrig systemd service."""
if not _have("systemctl"):
return {"available": False, "reason": "systemctl not on PATH"}
r = subprocess.run(["systemctl", "restart", XMRIG_SERVICE],
capture_output=True, text=True, check=False, timeout=30)
return {"restarted": r.returncode == 0, "rc": r.returncode,
"output": r.stdout, "stderr": r.stderr}
# ── Dispatch table ───────────────────────────────────────────────────
COMMANDS = {
# Read-only:
"summary": lambda _args: cmd_summary([]),
"threads": lambda _args: cmd_threads([]),
"pool-config-get": lambda _args: cmd_pool_config_get([]),
"threads-config-get": lambda _args: cmd_threads_config_get([]),
"algorithm-get": lambda _args: cmd_algorithm_get([]),
"service-status": lambda _args: cmd_service_status([]),
# Mutating (XMRig REST API):
"pool-config-set": lambda args: cmd_pool_config_set(args),
"threads-config-set": lambda args: cmd_threads_config_set(args),
"algorithm-set": lambda args: cmd_algorithm_set(args),
"pause": lambda _args: cmd_pause([]),
"resume": lambda _args: cmd_resume([]),
"pause-worker": lambda args: cmd_pause_worker(args),
"resume-worker": lambda args: cmd_resume_worker(args),
# Mutating (systemd service control — polkit org.sysdeck.system.manage):
"start": lambda _args: cmd_start([]),
"stop": lambda _args: cmd_stop([]),
"restart": lambda _args: cmd_restart([]),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
result = cmd(argv[1:])
if result is None:
# None signals "XMRig not reachable" — emit null so the JS
# panel can render the install hint.
print("null")
else:
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

8
bridge/modules/__init__.py Executable file
View File

@ -0,0 +1,8 @@
"""
SysDeck - Bridge Modules
Author: Jeremy Anderson (https://dcos.net)
Optional helper scripts. The JS bridge client uses cockpit.spawn for
most operations directly; these modules exist for aggregations that
span multiple CLI tools.
"""

655
bridge/modules3p.py Executable file
View File

@ -0,0 +1,655 @@
#!/usr/bin/env python3
"""
SysDeck - Third-Party Cockpit Modules Bridge (modules3p)
Author: Jeremy Anderson (https://dcos.net)
Catalog-driven installer for third-party Cockpit modules. Each entry
in the catalog declares its license, author, source URL, and an
install hook (git clone, deb extract, or native pacman). The
front-end (sysdeck-modules plugin) renders the catalog inline — each
row shows the license, developer, source URL, and homepage link
right next to a 1-click Install button. Clicking Install IS the
operator's acceptance of the inline-displayed license.
Design rules (per v0.0.46 directive):
1. The catalog is the single source of truth — no per-module code
branches. Adding a module = appending a dict to CATALOG.
2. No pulls are executed without an explicit install call from
the front-end. There is no bulk "install all".
3. Every install / uninstall is appended to
/etc/cockpit/MODULE_LICENSES.log as a JSON record.
4. The suite (MIT) and every catalog entry remain independent
programs. The suite invokes git/curl/pacman/tar as separate
subprocesses. No third-party code is ever bundled into the
SysDeck tarball; nothing is imported at Python import time.
5. The bridge refuses silent installs (no --accept-license=1 ⇒
license-not-accepted). The JS always passes that flag because
the license is rendered inline next to the Install button —
the click IS the acceptance gesture.
Usage:
python3 /usr/lib/sysdeck/bridge/modules3p.py catalog
python3 /usr/lib/sysdeck/bridge/modules3p.py status
python3 /usr/lib/sysdeck/bridge/modules3p.py preflight <id>
python3 /usr/lib/sysdeck/bridge/modules3p.py install <id> [--accept-license]
python3 /usr/lib/sysdeck/bridge/modules3p.py uninstall <id>
python3 /usr/lib/sysdeck/bridge/modules3p.py audit
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field, asdict
from typing import Any, Callable
# ── Constants ─────────────────────────────────────────────────────────────────
COCKPIT_DIR = "/usr/share/cockpit"
AUDIT_LOG = "/etc/cockpit/MODULE_LICENSES.log"
BRIDGE_VERSION = "0.0.46"
# ── Catalog (single source of truth) ────────────────────────────────────────
#
# Each entry MUST declare: id, name, blurb, license, author, source, kind,
# install_spec. `kind` is one of:
#
# "pacman" — install_spec: {"pkg": "<pacman-name>"}
# "git" — install_spec: {"repo": "<url>", "dest": "<relative-path-under-COCKPIT_DIR>"}
# "deb-tar" — install_spec: {"url": "<deb-url>", "dest": "<relative-path-under-COCKPIT_DIR>"}
# "tarball" — install_spec: {"url": "<tarball-url>", "dest": "<relative-path-under-COCKPIT_DIR>", "strip": <int>}
#
# Optional fields: homepage (defaults to source), category (for grouping
# in the UI), depends (list of CLI tools that must exist on PATH),
# conflicts (list of catalog ids that should not be co-installed).
CATALOG: list[dict[str, Any]] = [
# ── Cockpit Project upstream (LGPL-2.1) ──────────────────────────────
{
"id": "cockpit-machines",
"name": "Cockpit Machines",
"blurb": "Official libvirt/QEMU virtual machine manager.",
"license": "LGPL-2.1",
"author": "Cockpit Project",
"source": "https://github.com/cockpit-project/cockpit-machines",
"category": "Virtualization",
"kind": "pacman",
"install_spec": {"pkg": "cockpit-machines"},
"depends": ["libvirtd"],
},
{
"id": "cockpit-podman",
"name": "Cockpit Podman",
"blurb": "Official Podman container management UI.",
"license": "LGPL-2.1",
"author": "Cockpit Project",
"source": "https://github.com/cockpit-project/cockpit-podman",
"category": "Containers",
"kind": "pacman",
"install_spec": {"pkg": "cockpit-podman"},
"depends": ["podman"],
},
{
"id": "cockpit-storaged",
"name": "Cockpit Storaged",
"blurb": "Official storage (udisks) management UI.",
"license": "LGPL-2.1",
"author": "Cockpit Project",
"source": "https://github.com/cockpit-project/cockpit-storaged",
"category": "Storage",
"kind": "pacman",
"install_spec": {"pkg": "cockpit-storaged"},
"depends": ["udisksd"],
},
{
"id": "cockpit-identities",
"name": "Cockpit Identities",
"blurb": "Official SSH/PKCS#11/Kerberos identity panel.",
"license": "LGPL-2.1",
"author": "Cockpit Project",
"source": "https://github.com/cockpit-project/cockpit-identities",
"category": "Identity",
"kind": "git",
"install_spec": {
"repo": "https://github.com/cockpit-project/cockpit-identities.git",
"dest": "identities",
},
"depends": ["ssh-add"],
},
# ── 45Drives storage stack (GPL-3.0) ────────────────────────────────
{
"id": "cockpit-navigator",
"name": "45Drives Navigator",
"blurb": "Web file browser for the cockpit user.",
"license": "GPL-3.0",
"author": "45Drives",
"source": "https://github.com/45Drives/cockpit-navigator",
"category": "Storage / Files",
"kind": "deb-tar",
"install_spec": {
"url": "https://github.com/45Drives/cockpit-navigator/releases/download/v3.1.0/cockpit-navigator_3.1.0-1focal_all.deb",
"dest": "navigator",
},
},
{
"id": "cockpit-file-sharing",
"name": "45Drives File Sharing",
"blurb": "Samba / NFS share management UI.",
"license": "GPL-3.0",
"author": "45Drives",
"source": "https://github.com/45Drives/cockpit-file-sharing",
"category": "Storage / Files",
"kind": "deb-tar",
"install_spec": {
"url": "https://github.com/45Drives/cockpit-file-sharing/releases/download/v3.3.4/cockpit-file-sharing_3.3.4-1focal_all.deb",
"dest": "file-sharing",
},
"depends": ["smbd", "exportfs"],
},
{
"id": "cockpit-zfs-manager",
"name": "45Drives ZFS Manager",
"blurb": "OpenZFS pool, dataset, and snapshot UI.",
"license": "GPL-3.0",
"author": "45Drives",
"source": "https://github.com/45Drives/cockpit-zfs-manager",
"category": "Storage / ZFS",
"kind": "git",
"install_spec": {
"repo": "https://github.com/45Drives/cockpit-zfs-manager.git",
"dest": "zfs-manager",
},
"depends": ["zpool"],
},
# ── Community modules (MIT / GPL-3.0) ──────────────────────────────
{
"id": "cockpit-pacman",
"name": "cockpit-pacman",
"blurb": "ALPM/pacman WebUI for Arch Linux hosts.",
"license": "GPL-3.0",
"author": "pfeifferj",
"source": "https://github.com/pfeifferj/cockpit-pacman",
"category": "Package Management",
"kind": "git",
"install_spec": {
"repo": "https://github.com/pfeifferj/cockpit-pacman.git",
"dest": "pacman",
},
"depends": ["pacman"],
},
{
"id": "cockpit-sensors",
"name": "cockpit-sensors",
"blurb": "Standalone lm_sensors reader (ocristopfer). "
"SysDeck already ships a built-in sensors panel; this is the "
"upstream reference if you prefer its layout.",
"license": "MIT",
"author": "ocristopfer",
"source": "https://github.com/ocristopfer/cockpit-sensors",
"category": "Hardware",
"kind": "tarball",
"install_spec": {
"url": "https://github.com/ocristopfer/cockpit-sensors/releases/latest/download/cockpit-sensors.tar.xz",
"dest": "sensors",
"strip": 1,
},
"depends": ["sensors"],
},
{
"id": "cockpit-benchmark",
"name": "cockpit-benchmark",
"blurb": "sysbench / fio / iperf3 wrapper UI (ealier). "
"SysDeck already ships a built-in benchmark panel; this is the "
"upstream reference if you prefer its layout.",
"license": "MIT",
"author": "ealier",
"source": "https://github.com/ealier/cockpit-benchmark",
"category": "Benchmarking",
"kind": "git",
"install_spec": {
"repo": "https://github.com/ealier/cockpit-benchmark.git",
"dest": "benchmark",
},
"depends": ["sysbench"],
},
]
# ── Dataclass wrapper for typed access ───────────────────────────────────────
@dataclass
class CatalogEntry:
id: str
name: str
blurb: str
license: str
author: str
source: str
category: str
kind: str
install_spec: dict[str, Any]
depends: list[str] = field(default_factory=list)
homepage: str = ""
@classmethod
def from_dict(cls, d: dict[str, Any]) -> "CatalogEntry":
return cls(
id=d["id"],
name=d["name"],
blurb=d["blurb"],
license=d["license"],
author=d["author"],
source=d["source"],
category=d.get("category", "Uncategorized"),
kind=d["kind"],
install_spec=d["install_spec"],
depends=list(d.get("depends", [])),
homepage=d.get("homepage", d["source"]),
)
def to_public_dict(self) -> dict[str, Any]:
return asdict(self)
def catalog_entries() -> list[CatalogEntry]:
return [CatalogEntry.from_dict(e) for e in CATALOG]
def find_entry(entry_id: str) -> CatalogEntry | None:
for e in catalog_entries():
if e.id == entry_id:
return e
return None
# ── Install-state probe ──────────────────────────────────────────────────────
#
# A module is "installed" if its destination directory exists under
# /usr/share/cockpit/. For pacman entries we additionally check `pacman -Q`
# so that distro-managed installs are reported correctly even when the
# destination dir is empty.
def _dest_path(entry: CatalogEntry) -> str:
spec = entry.install_spec
rel = spec.get("dest") or entry.id
return os.path.join(COCKPIT_DIR, rel)
def _pacman_has(pkg: str) -> bool:
# `pacman` may be absent on non-Arch hosts (Debian, Fedora, dev boxes).
# Treat missing pacman as "not installed via pacman" rather than crashing.
if shutil.which("pacman") is None:
return False
r = subprocess.run(["pacman", "-Q", pkg], capture_output=True, text=True)
return r.returncode == 0
def is_installed(entry: CatalogEntry) -> bool:
if entry.kind == "pacman":
return _pacman_has(entry.install_spec["pkg"])
return os.path.isdir(_dest_path(entry)) and \
bool(os.listdir(_dest_path(entry)))
def missing_deps(entry: CatalogEntry) -> list[str]:
"""Return the subset of `depends` CLI tools missing from PATH."""
out: list[str] = []
for dep in entry.depends:
if shutil.which(dep) is None:
# allow the kernel-builtin or service-style deps
# (e.g. smbd may live in /usr/sbin but not in PATH for the
# cockpit user) — re-check via systemctl is-active. If
# systemctl itself is unavailable (container / non-systemd
# host), treat the dep as missing rather than crashing.
if shutil.which("systemctl") is None:
out.append(dep)
continue
r = subprocess.run(
["systemctl", "is-active", "--quiet", dep],
capture_output=True,
)
if r.returncode != 0:
out.append(dep)
return out
# ── Preflight (called BEFORE install) ────────────────────────────────────────
#
# Returns the full disclosure bundle: license, author, source URL,
# install plan (the exact commands that will run), missing deps, and
# a `credit_line` that the front-end can paste into a tooltip / banner.
#
# The 1-click UI doesn't strictly need this — the row already shows
# everything inline — but it's exposed for headless inspection and
# for the smoke-test suite.
def preflight(entry_id: str) -> dict[str, Any]:
entry = find_entry(entry_id)
if entry is None:
return {"ok": False, "error": f"unknown module id: {entry_id}"}
plan = _install_plan(entry)
already = is_installed(entry)
missing = missing_deps(entry)
return {
"ok": True,
"id": entry.id,
"name": entry.name,
"blurb": entry.blurb,
"license": entry.license,
"author": entry.author,
"source": entry.source,
"homepage": entry.homepage or entry.source,
"category": entry.category,
"already_installed": already,
"missing_deps": missing,
"install_plan": plan,
"credit_line": (
f"Module: {entry.name}\n"
f"License: {entry.license}\n"
f"Author: {entry.author}\n"
f"Source: {entry.source}\n"
f"Install plan:\n " + "\n ".join(plan)
),
}
def _install_plan(entry: CatalogEntry) -> list[str]:
spec = entry.install_spec
if entry.kind == "pacman":
return [f"pacman -S --noconfirm --needed {spec['pkg']}"]
if entry.kind == "git":
return [
f"git clone --depth 1 {spec['repo']} "
f"{_dest_path(entry)}",
]
if entry.kind == "deb-tar":
return [
f"curl -fsSL {spec['url']} -o /tmp/<file>.deb",
f"bsdtar -xf /tmp/<file>.deb -C /tmp/<extract>",
f"tar -xf /tmp/<extract>/data.tar.xz "
f"-C {COCKPIT_DIR}/{spec['dest']} --strip-components=4",
"rm -rf /tmp/<file>.deb /tmp/<extract>",
]
if entry.kind == "tarball":
strip = spec.get("strip", 1)
return [
f"curl -fsSL {spec['url']} -o /tmp/<file>.tar.xz",
f"tar -xf /tmp/<file>.tar.xz -C "
f"{_dest_path(entry)} --strip-components={strip}",
"rm -f /tmp/<file>.tar.xz",
]
return [f"<unknown kind: {entry.kind}>"]
# ── Install ──────────────────────────────────────────────────────────────────
#
# The 1-click UI always passes accept_license=True because the license
# is rendered inline next to the Install button — the click IS the
# acceptance gesture. The accept_license check remains as a guard
# against malicious callers (e.g. a different front-end that tries
# to bulk-install without operator interaction).
def install(entry_id: str, accept_license: bool = False) -> dict[str, Any]:
entry = find_entry(entry_id)
if entry is None:
return {"ok": False, "error": f"unknown module id: {entry_id}"}
if is_installed(entry):
return {"ok": True, "id": entry.id, "status": "already-installed",
"message": f"{entry.name} is already installed"}
if not accept_license:
# Refuse silent installs — this is the guard rail.
return {
"ok": False,
"id": entry.id,
"error": "license-not-accepted",
"message": (
"Refusing to install without explicit license acceptance. "
"The front-end must render the license inline next to "
"the Install button and pass acceptLicense=true on click."
),
}
try:
os.makedirs(COCKPIT_DIR, exist_ok=True)
if entry.kind == "pacman":
_install_pacman(entry)
elif entry.kind == "git":
_install_git(entry)
elif entry.kind == "deb-tar":
_install_deb_tar(entry)
elif entry.kind == "tarball":
_install_tarball(entry)
else:
return {"ok": False, "error": f"unsupported kind: {entry.kind}"}
except subprocess.CalledProcessError as e:
_audit_append(entry, "install-failed",
f"rc={e.returncode} stderr={e.stderr or ''}")
return {
"ok": False,
"id": entry.id,
"error": "install-command-failed",
"rc": e.returncode,
"stderr": e.stderr or e.stdout or str(e),
}
except Exception as e: # noqa: BLE001
_audit_append(entry, "install-failed", str(e))
return {"ok": False, "id": entry.id, "error": str(e)}
_audit_append(entry, "install-ok", "installed")
return {
"ok": True,
"id": entry.id,
"status": "installed",
"name": entry.name,
"license": entry.license,
"author": entry.author,
"source": entry.source,
}
def _install_pacman(entry: CatalogEntry) -> None:
pkg = entry.install_spec["pkg"]
subprocess.run(
["pacman", "-S", "--noconfirm", "--needed", pkg],
check=True, capture_output=True, text=True,
)
def _install_git(entry: CatalogEntry) -> None:
repo = entry.install_spec["repo"]
dest = _dest_path(entry)
if os.path.exists(dest):
raise RuntimeError(f"destination {dest} already exists")
subprocess.run(
["git", "clone", "--depth", "1", repo, dest],
check=True, capture_output=True, text=True,
)
def _install_deb_tar(entry: CatalogEntry) -> None:
spec = entry.install_spec
dest = _dest_path(entry)
if os.path.exists(dest):
raise RuntimeError(f"destination {dest} already exists")
os.makedirs(dest, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
deb = os.path.join(tmp, "pkg.deb")
subprocess.run(
["curl", "-fsSL", spec["url"], "-o", deb],
check=True, capture_output=True, text=True,
)
extract_dir = os.path.join(tmp, "extract")
os.makedirs(extract_dir, exist_ok=True)
subprocess.run(
["bsdtar", "-xf", deb, "-C", extract_dir],
check=True, capture_output=True, text=True,
)
data_tar = os.path.join(extract_dir, "data.tar.xz")
if not os.path.exists(data_tar):
raise RuntimeError(
f"deb archive {spec['url']} missing data.tar.xz"
)
subprocess.run(
["tar", "-xf", data_tar, "-C", dest, "--strip-components=4"],
check=True, capture_output=True, text=True,
)
def _install_tarball(entry: CatalogEntry) -> None:
spec = entry.install_spec
dest = _dest_path(entry)
if os.path.exists(dest) and os.listdir(dest):
raise RuntimeError(f"destination {dest} is not empty")
os.makedirs(dest, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
tarball = os.path.join(tmp, "pkg.tar.xz")
subprocess.run(
["curl", "-fsSL", spec["url"], "-o", tarball],
check=True, capture_output=True, text=True,
)
subprocess.run(
["tar", "-xf", tarball, "-C", dest,
f"--strip-components={spec.get('strip', 1)}"],
check=True, capture_output=True, text=True,
)
# ── Uninstall ────────────────────────────────────────────────────────────────
def uninstall(entry_id: str) -> dict[str, Any]:
entry = find_entry(entry_id)
if entry is None:
return {"ok": False, "error": f"unknown module id: {entry_id}"}
if not is_installed(entry):
return {"ok": True, "id": entry.id, "status": "not-installed"}
try:
if entry.kind == "pacman":
pkg = entry.install_spec["pkg"]
subprocess.run(
["pacman", "-R", "--noconfirm", pkg],
check=True, capture_output=True, text=True,
)
else:
dest = _dest_path(entry)
shutil.rmtree(dest)
except Exception as e: # noqa: BLE001
_audit_append(entry, "uninstall-failed", str(e))
return {"ok": False, "id": entry.id, "error": str(e)}
_audit_append(entry, "uninstall-ok", "removed")
return {"ok": True, "id": entry.id, "status": "removed"}
# ── Audit log ────────────────────────────────────────────────────────────────
#
# Append-only JSON-lines audit log. Lives next to the legacy
# /etc/cockpit/MODULE_LICENSES.log (which cockpit-module-pull.sh
# wrote in plain text). We honor the same path so existing
# compliance tooling picks up both records.
def _audit_append(entry: CatalogEntry, action: str, detail: str) -> None:
os.makedirs(os.path.dirname(AUDIT_LOG), exist_ok=True)
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"module": entry.id,
"name": entry.name,
"license": entry.license,
"author": entry.author,
"source": entry.source,
"action": action,
"detail": detail,
"bridge_version": BRIDGE_VERSION,
}
with open(AUDIT_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(record) + "\n")
def audit(limit: int = 200) -> dict[str, Any]:
"""Return the last `limit` audit records, newest last."""
if not os.path.exists(AUDIT_LOG):
return {"ok": True, "path": AUDIT_LOG, "records": []}
records: list[dict[str, Any]] = []
with open(AUDIT_LOG, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
records.append(json.loads(line))
except json.JSONDecodeError:
# legacy plain-text line from cockpit-module-pull.sh — keep as raw
records.append({"raw": line})
return {
"ok": True,
"path": AUDIT_LOG,
"records": records[-limit:] if limit > 0 else records,
}
# ── Status (combined catalog view for the front-end) ────────────────────────
def status() -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
for entry in catalog_entries():
out.append({
"id": entry.id,
"name": entry.name,
"blurb": entry.blurb,
"license": entry.license,
"author": entry.author,
"source": entry.source,
"homepage": entry.homepage or entry.source,
"category": entry.category,
"kind": entry.kind,
"depends": entry.depends,
"installed": is_installed(entry),
"missing_deps": missing_deps(entry),
})
return out
# ── CLI ──────────────────────────────────────────────────────────────────────
COMMANDS: dict[str, Callable[[list[str]], Any]] = {
"catalog": lambda _a: [e.to_public_dict() for e in catalog_entries()],
"status": lambda _a: status(),
"preflight": lambda a: preflight(a[0]),
"install": lambda a: install(
a[0],
accept_license=("--accept-license" in a) or ("--accept-license=1" in a),
),
"uninstall": lambda a: uninstall(a[0]),
"audit": lambda a: audit(int(a[0]) if a else 200),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
result = cmd(argv[1:])
print(json.dumps(result, indent=2, default=str))
return 0 if (not isinstance(result, dict) or result.get("ok", True)) else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

443
bridge/netsec.py Executable file
View File

@ -0,0 +1,443 @@
#!/usr/bin/env python3
"""
SysDeck - Netsec Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.0.43 REWRITE — IPTRAF-NG STYLE NETWORK MONITOR.
The v0.0.10-v0.0.42 panel used `ss -tulpn` for a static socket list.
v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources
iptraf-ng reads from directly — no fragile ncurses parsing.
Data sources:
/proc/net/dev per-interface RX/TX byte + packet counters
/proc/net/snmp IP/TCP/UDP/ICMP protocol counters
/proc/net/tcp active TCP connections (state, local, remote)
/proc/net/udp active UDP sockets
ss -tnp established connections with PID/process mapping
The bridge computes live rates by reading /proc/net/dev twice (1 second
apart) and diffing — this is exactly how iptraf-ng computes per-second
traffic rates.
Subcommands:
summary aggregate: total interfaces, total connections, protocol stats
traffic per-interface live RX/TX rates (bytes/s, packets/s, errors/s)
connections active TCP/UDP flows with PID mapping (like iptraf-ng IP monitor)
interfaces per-interface detailed stats (cumulative counters)
protocols /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters
sockets listening TCP+UDP sockets (kept from v0.0.10 for back-compat)
established established TCP connections (kept from v0.0.10 for back-compat)
Usage:
python3 /usr/lib/sysdeck/bridge/netsec.py traffic
python3 /usr/lib/sysdeck/bridge/netsec.py connections
python3 /usr/lib/sysdeck/bridge/netsec.py interfaces
python3 /usr/lib/sysdeck/bridge/netsec.py protocols
"""
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
# v0.0.39: import security helpers from firewall.py (single source of truth).
sys.path.insert(0, str(Path(__file__).parent))
try:
from firewall import ( # type: ignore
SCRUBBED_ENV,
_sanitize_output,
)
except ImportError:
SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
def _sanitize_output(text: str, max_len: int = 4096) -> str:
if not text:
return ""
if len(text) > max_len:
text = text[:max_len] + " ... (truncated)"
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
# ── Regex patterns (compiled once at import) ──────────────────────
SS_LINE_RE = re.compile(
r"^(?P<netid>\S+)\s+(?P<state>\S+)\s+(?P<recvq>\d+)\s+(?P<sendq>\d+)\s+(?P<local>\S+)\s+(?P<peer>\S+)"
)
# /proc/net/dev line format (after header):
# eth0: rx_bytes rx_packets rx_errs rx_drop rx_fifo rx_frame rx_compressed rx_multicast
# tx_bytes tx_packets tx_errs tx_drop tx_fifo tx_colls tx_carrier tx_compressed
DEV_LINE_RE = re.compile(
r"^\s*(?P<iface>\S+?):\s*(?P<rx_bytes>\d+)\s+(?P<rx_packets>\d+)\s+"
r"(?P<rx_errs>\d+)\s+(?P<rx_drop>\d+)\s+\d+\s+\d+\s+\d+\s+\d+\s+"
r"(?P<tx_bytes>\d+)\s+(?P<tx_packets>\d+)\s+"
r"(?P<tx_errs>\d+)\s+(?P<tx_drop>\d+)"
)
# /proc/net/tcp state codes (hex → name)
TCP_STATES = {
"01": "ESTABLISHED",
"02": "SYN_SENT",
"03": "SYN_RECV",
"04": "FIN_WAIT1",
"05": "FIN_WAIT2",
"06": "TIME_WAIT",
"07": "CLOSE",
"08": "CLOSE_WAIT",
"09": "LAST_ACK",
"0A": "LISTEN",
"0B": "CLOSING",
}
def _run(argv: list[str], timeout: int = 5) -> str:
"""Run argv and return stdout. Returns '' on failure.
v0.0.39 hardening: env scrubbed, output sanitized.
"""
try:
r = subprocess.run(
argv, capture_output=True, text=True, check=False, timeout=timeout,
env=SCRUBBED_ENV,
)
return _sanitize_output(r.stdout or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return ""
def _read_file(path: str) -> str:
"""Read a file, returning '' on failure."""
try:
with open(path, encoding="utf-8", errors="replace") as fh:
return fh.read()
except (FileNotFoundError, PermissionError, OSError):
return ""
# ── /proc/net/dev parsing (per-interface stats) ──────────────────
def _parse_proc_net_dev(content: str) -> dict[str, dict[str, int]]:
"""Parse /proc/net/dev into {iface: {rx_bytes, rx_packets, ...}}.
This is the same data source iptraf-ng reads for its general
interface statistics view.
"""
interfaces: dict[str, dict[str, int]] = {}
for line in content.splitlines():
m = DEV_LINE_RE.match(line)
if not m:
continue
iface = m.group("iface")
interfaces[iface] = {
"rx_bytes": int(m.group("rx_bytes")),
"rx_packets": int(m.group("rx_packets")),
"rx_errs": int(m.group("rx_errs")),
"rx_drop": int(m.group("rx_drop")),
"tx_bytes": int(m.group("tx_bytes")),
"tx_packets": int(m.group("tx_packets")),
"tx_errs": int(m.group("tx_errs")),
"tx_drop": int(m.group("tx_drop")),
}
return interfaces
def _read_dev_stats() -> dict[str, dict[str, int]]:
"""Read current /proc/net/dev snapshot."""
return _parse_proc_net_dev(_read_file("/proc/net/dev"))
# ── Traffic rate computation (iptraf-ng style) ────────────────────
def cmd_traffic(_args: list[str]) -> list[dict[str, Any]]:
"""Compute per-interface live RX/TX rates by sampling /proc/net/dev
twice (1 second apart) and diffing.
Returns a list of {iface, rx_bps, tx_bps, rx_pps, tx_pps,
rx_errs_total, tx_errs_total, rx_drop_total, tx_drop_total}.
This is exactly how iptraf-ng computes its live traffic rates.
"""
snapshot1 = _read_dev_stats()
if not snapshot1:
return []
t1 = time.monotonic()
time.sleep(1.0)
snapshot2 = _read_dev_stats()
t2 = time.monotonic()
elapsed = t2 - t1
if elapsed <= 0:
elapsed = 1.0
result: list[dict[str, Any]] = []
for iface in sorted(snapshot2.keys()):
s1 = snapshot1.get(iface, {})
s2 = snapshot2.get(iface, {})
if not s2:
continue
rx_bytes_diff = s2.get("rx_bytes", 0) - s1.get("rx_bytes", 0)
tx_bytes_diff = s2.get("tx_bytes", 0) - s1.get("tx_bytes", 0)
rx_pkt_diff = s2.get("rx_packets", 0) - s1.get("rx_packets", 0)
tx_pkt_diff = s2.get("tx_packets", 0) - s1.get("tx_packets", 0)
result.append({
"iface": iface,
"rx_bps": int(rx_bytes_diff / elapsed),
"tx_bps": int(tx_bytes_diff / elapsed),
"rx_pps": int(rx_pkt_diff / elapsed),
"tx_pps": int(tx_pkt_diff / elapsed),
"rx_bytes_total": s2.get("rx_bytes", 0),
"tx_bytes_total": s2.get("tx_bytes", 0),
"rx_packets_total": s2.get("rx_packets", 0),
"tx_packets_total": s2.get("tx_packets", 0),
"rx_errs_total": s2.get("rx_errs", 0),
"tx_errs_total": s2.get("tx_errs", 0),
"rx_drop_total": s2.get("rx_drop", 0),
"tx_drop_total": s2.get("tx_drop", 0),
})
return result
# ── Interface details ─────────────────────────────────────────────
def cmd_interfaces(_args: list[str]) -> list[dict[str, Any]]:
"""Return per-interface detailed stats (cumulative counters).
Like iptraf-ng's detailed interface statistics view.
"""
stats = _read_dev_stats()
if not stats:
return []
result: list[dict[str, Any]] = []
for iface in sorted(stats.keys()):
s = stats[iface]
# Compute human-readable rates.
rx_mb = s["rx_bytes"] / (1024 * 1024)
tx_mb = s["tx_bytes"] / (1024 * 1024)
result.append({
"iface": iface,
"rx_bytes": s["rx_bytes"],
"rx_mb": round(rx_mb, 2),
"rx_packets": s["rx_packets"],
"rx_errs": s["rx_errs"],
"rx_drop": s["rx_drop"],
"tx_bytes": s["tx_bytes"],
"tx_mb": round(tx_mb, 2),
"tx_packets": s["tx_packets"],
"tx_errs": s["tx_errs"],
"tx_drop": s["tx_drop"],
})
return result
# ── Protocol statistics (/proc/net/snmp) ──────────────────────────
def cmd_protocols(_args: list[str]) -> dict[str, Any]:
"""Parse /proc/net/snmp for IP/TCP/UDP/ICMP protocol counters.
Like iptraf-ng's statistical breakdowns view.
"""
content = _read_file("/proc/net/snmp")
if not content:
return {"error": "cannot read /proc/net/snmp"}
result: dict[str, Any] = {}
lines = content.splitlines()
i = 0
while i < len(lines) - 1:
header = lines[i].strip()
values = lines[i + 1].strip()
if ":" not in header or ":" not in values:
i += 1
continue
proto_name = header.split(":")[0]
hdr_fields = header.split(":")[1].split()
val_fields = values.split(":")[1].split()
if len(hdr_fields) != len(val_fields):
i += 1
continue
proto_stats = {}
for j, field in enumerate(hdr_fields):
try:
proto_stats[field] = int(val_fields[j])
except (ValueError, IndexError):
proto_stats[field] = val_fields[j]
result[proto_name.lower()] = proto_stats
i += 2
return result
# ── Active connections (iptraf-ng IP traffic monitor style) ───────
def _decode_addr(hex_addr: str) -> tuple[str, int]:
"""Decode a /proc/net/tcp hex address (little-endian) into (ip, port)."""
if not hex_addr or ":" not in hex_addr:
return ("?", 0)
ip_hex, port_hex = hex_addr.split(":")
try:
port = int(port_hex, 16)
# /proc/net/tcp stores IPv4 in little-endian hex.
ip_int = int(ip_hex, 16)
ip = f"{ip_int & 0xFF}.{(ip_int >> 8) & 0xFF}.{(ip_int >> 16) & 0xFF}.{(ip_int >> 24) & 0xFF}"
return (ip, port)
except ValueError:
return ("?", 0)
def cmd_connections(_args: list[str]) -> list[dict[str, Any]]:
"""Active TCP/UDP connections with state + address info.
Reads /proc/net/tcp + /proc/net/udp directly (no ss dependency).
Returns a list of {proto, state, local_ip, local_port, remote_ip,
remote_port, tx_queue, rx_queue}.
Like iptraf-ng's IP traffic monitor.
"""
result: list[dict[str, Any]] = []
# TCP
tcp_content = _read_file("/proc/net/tcp")
for line in tcp_content.splitlines()[1:]: # skip header
parts = line.split()
if len(parts) < 10:
continue
local_ip, local_port = _decode_addr(parts[1])
remote_ip, remote_port = _decode_addr(parts[2])
state_hex = parts[3]
state = TCP_STATES.get(state_hex, f"UNKNOWN({state_hex})")
tx_queue = rx_queue = 0
if ":" in parts[4]:
tx_q, rx_q = parts[4].split(":")
try:
tx_queue = int(tx_q, 16)
rx_queue = int(rx_q, 16)
except ValueError:
pass
result.append({
"proto": "tcp",
"state": state,
"local_ip": local_ip,
"local_port": local_port,
"remote_ip": remote_ip,
"remote_port": remote_port,
"tx_queue": tx_queue,
"rx_queue": rx_queue,
})
# UDP
udp_content = _read_file("/proc/net/udp")
for line in udp_content.splitlines()[1:]:
parts = line.split()
if len(parts) < 8:
continue
local_ip, local_port = _decode_addr(parts[1])
remote_ip, remote_port = _decode_addr(parts[2])
result.append({
"proto": "udp",
"state": "UDP",
"local_ip": local_ip,
"local_port": local_port,
"remote_ip": remote_ip,
"remote_port": remote_port,
"tx_queue": 0,
"rx_queue": 0,
})
return result
# ── Summary (aggregate panel header) ──────────────────────────────
def cmd_summary(_args: list[str]) -> dict[str, Any]:
"""Aggregate network state: total interfaces, connections, protocol stats."""
interfaces = cmd_interfaces([])
connections = cmd_connections([])
protocols = cmd_protocols([])
# Count connections by state.
tcp_states: dict[str, int] = {}
for c in connections:
if c["proto"] == "tcp":
tcp_states[c["state"]] = tcp_states.get(c["state"], 0) + 1
return {
"total_interfaces": len(interfaces),
"interfaces_up": len([i for i in interfaces if i["rx_bytes"] > 0 or i["tx_bytes"] > 0]),
"total_connections": len(connections),
"tcp_established": tcp_states.get("ESTABLISHED", 0),
"tcp_listen": tcp_states.get("LISTEN", 0),
"tcp_time_wait": tcp_states.get("TIME_WAIT", 0),
"tcp_states": tcp_states,
"top_interfaces_by_traffic": sorted(
interfaces, key=lambda i: i["rx_bytes"] + i["tx_bytes"], reverse=True
)[:5],
"protocols": protocols,
}
# ── Legacy subcommands (kept for back-compat) ─────────────────────
def parse_ss(output: str) -> list[dict[str, Any]]:
"""Parse `ss -tulpn` output into socket records."""
return [
{
"netid": m.group("netid"),
"state": m.group("state"),
"recvQ": int(m.group("recvq")),
"sendQ": int(m.group("sendq")),
"local": m.group("local"),
"peer": m.group("peer"),
}
for line in output.splitlines()
if (m := SS_LINE_RE.match(line))
]
def sockets() -> list[dict[str, Any]]:
"""Listening TCP and UDP sockets (legacy, kept for back-compat)."""
return parse_ss(_run(["ss", "-tulpn"]))
def established() -> list[dict[str, Any]]:
"""Established TCP connections (legacy, kept for back-compat)."""
return parse_ss(_run(["ss", "-tnp", "state", "established"]))
def cmd_sockets(_args: list[str]) -> list[dict[str, Any]]:
return sockets()
def cmd_established(_args: list[str]) -> list[dict[str, Any]]:
return established()
# ── Dispatch table ────────────────────────────────────────────────
COMMANDS = {
"summary": cmd_summary,
"traffic": cmd_traffic,
"connections": cmd_connections,
"interfaces": cmd_interfaces,
"protocols": cmd_protocols,
"sockets": cmd_sockets,
"established": cmd_established,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

466
bridge/packages.py Executable file
View File

@ -0,0 +1,466 @@
#!/usr/bin/env python3
"""
SysDeck - Packages Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Wraps the system package manager (pacman on Arch Linux, dnf/yum on
RPM distros, apt on DEB distros) into a unified JSON interface so the
Packages panel can list, search, install, update, and remove packages
without knowing which distro it runs on.
The package manager is invoked as a separate process via subprocess —
the suite (MIT) and the package manager remain independent programs.
No package-manager code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/packages.py list-installed
python3 /usr/lib/sysdeck/bridge/packages.py list-updates
python3 /usr/lib/sysdeck/bridge/packages.py search <term>
python3 /usr/lib/sysdeck/bridge/packages.py info <name>
python3 /usr/lib/sysdeck/bridge/packages.py install <name>
python3 /usr/lib/sysdeck/bridge/packages.py remove <name>
python3 /usr/lib/sysdeck/bridge/packages.py update <name>
python3 /usr/lib/sysdeck/bridge/packages.py update-all
python3 /usr/lib/sysdeck/bridge/packages.py dry-run <action> [name]
python3 /usr/lib/sysdeck/bridge/packages.py summary
v0.0.31: install / remove / update / update-all now ACTUALLY RUN the
package manager via subprocess. The cockpit JS panel passes
{ superuser: 'try' } to cockpit.spawn so the operator authenticates
via polkit (org.sysdeck.packages.modify action, shipped since v0.0.17,
authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf). No `sudo`
shell-out from JS — this is the cockpit way.
The new `dry-run` subcommand preserves the v0.0.30 command-string-only
return shape for the panel's preview-before-confirm flow.
"""
import json
import os
import re
import subprocess
import sys
from typing import Any
PACMAN_LICENSE = "GPL-2.0+ (pacman)"
PACMAN_AUTHOR = "Pacman Development Team"
PACMAN_URL = "https://archlinux.org/pacman/"
# Detect the system package manager once at import time.
# Step-down: prefer pacman (Arch), then dnf (Fedora), then apt (Debian/Ubuntu).
# The detected manager determines which backend functions are used.
def _detect_pkg_manager() -> str:
"""Return 'pacman', 'dnf', or 'apt' based on what is available."""
for cmd in ("pacman", "dnf", "apt"):
try:
subprocess.run([cmd, "--version"], capture_output=True, check=True)
return cmd
except (subprocess.CalledProcessError, FileNotFoundError):
continue
return "unknown"
PKG_MANAGER = _detect_pkg_manager()
def run(argv: list[str]) -> str:
"""Run a command, returning stdout. Returns '' on failure."""
try:
return subprocess.run(
argv, capture_output=True, text=True, check=True,
).stdout
except (subprocess.CalledProcessError, FileNotFoundError):
return ""
# ── Pacman backend ──────────────────────────────────────────────────
def _pacman_list_installed() -> list[dict[str, str]]:
"""List installed packages via pacman -Q."""
raw = run(["pacman", "-Q"])
return [
{"name": parts[0], "version": parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _pacman_list_updates() -> list[dict[str, str]]:
"""List available updates via pacman -Qu."""
raw = run(["pacman", "-Qu"])
return [
{"name": parts[0], "current": parts[1], "new": parts[2] if len(parts) > 2 else parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _pacman_search(term: str) -> list[dict[str, str]]:
"""Search packages via pacman -Ss."""
raw = run(["pacman", "-Ss", term])
results: list[dict[str, str]] = []
for line in raw.splitlines():
# Format: "repo/name version [installed]"
if line.startswith(" ") or not line.strip():
continue
parts = line.split()
if len(parts) >= 2:
name_ver = parts[0]
installed = "[installed]" in line
name = name_ver.split("/")[-1] if "/" in name_ver else name_ver
results.append({"name": name, "version": parts[1], "installed": str(installed).lower()})
return results
def _pacman_info(name: str) -> dict[str, Any]:
"""Package info via pacman -Si."""
raw = run(["pacman", "-Si", name])
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace(" ", "_")] = val.strip()
return info
# ── DNF backend ─────────────────────────────────────────────────────
def _dnf_list_installed() -> list[dict[str, str]]:
"""List installed packages via dnf list installed."""
raw = run(["dnf", "list", "installed", "--quiet"])
return _parse_rpm_list(raw)
def _dnf_list_updates() -> list[dict[str, str]]:
"""List available updates via dnf check-update."""
raw = run(["dnf", "check-update", "--quiet"])
return _parse_rpm_update_list(raw)
def _dnf_search(term: str) -> list[dict[str, str]]:
"""Search packages via dnf search."""
raw = run(["dnf", "search", term, "--quiet"])
results: list[dict[str, str]] = []
for line in raw.splitlines():
if ":" in line and not line.startswith(" "):
parts = line.split(":")
if len(parts) >= 2:
name_ver = parts[0].strip()
name = name_ver.split(".")[0] if "." in name_ver else name_ver
results.append({"name": name, "description": parts[1].strip()})
return results
def _dnf_info(name: str) -> dict[str, Any]:
"""Package info via dnf info."""
raw = run(["dnf", "info", name, "--quiet"])
return _parse_rpm_info(raw, name)
# ── APT backend ─────────────────────────────────────────────────────
def _apt_list_installed() -> list[dict[str, str]]:
"""List installed packages via dpkg-query."""
raw = run(["dpkg-query", "-W", "-f=${Package}\\t${Version}\\n"])
return [
{"name": parts[0], "version": parts[1]}
for line in raw.splitlines()
if (parts := line.split("\t")) and len(parts) >= 2
]
def _apt_list_updates() -> list[dict[str, str]]:
"""List available updates via apt list --upgradable."""
raw = run(["apt", "list", "--upgradable", "-qq"])
return [
{"name": parts[0].split("/")[0], "new": parts[1]}
for line in raw.splitlines()
if (parts := line.split()) and len(parts) >= 2
]
def _apt_search(term: str) -> list[dict[str, str]]:
"""Search packages via apt search."""
raw = run(["apt-cache", "search", term])
results: list[dict[str, str]] = []
for line in raw.splitlines():
if " - " in line:
name_desc = line.split(" - ", 1)
name_ver = name_desc[0].split()
if name_ver:
results.append({"name": name_ver[0], "description": name_desc[1] if len(name_desc) > 1 else ""})
return results
def _apt_info(name: str) -> dict[str, Any]:
"""Package info via apt show."""
raw = run(["apt-cache", "show", name])
return _parse_apt_info(raw, name)
# ── Shared parsers ──────────────────────────────────────────────────
def _parse_rpm_list(raw: str) -> list[dict[str, str]]:
"""Parse 'name.arch version repo' tabular output."""
results: list[dict[str, str]] = []
for line in raw.splitlines():
parts = line.split()
if len(parts) >= 2 and not line.startswith("Last"):
name = parts[0].split(".")[0] if "." in parts[0] else parts[0]
results.append({"name": name, "version": parts[1]})
return results
def _parse_rpm_update_list(raw: str) -> list[dict[str, str]]:
"""Parse dnf check-update output."""
results: list[dict[str, str]] = []
for line in raw.splitlines():
parts = line.split()
if len(parts) >= 2 and not line.startswith("Last") and not line.startswith(" "):
name = parts[0].split(".")[0] if "." in parts[0] else parts[0]
results.append({"name": name, "new": parts[1]})
return results
def _parse_rpm_info(raw: str, name: str) -> dict[str, Any]:
"""Parse dnf info output into key-value pairs."""
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace(" ", "_")] = val.strip()
return info
def _parse_apt_info(raw: str, name: str) -> dict[str, Any]:
"""Parse apt-cache show output into key-value pairs."""
info: dict[str, Any] = {"name": name}
for line in raw.splitlines():
if ":" in line:
key, _, val = line.partition(":")
info[key.strip().lower().replace("-", "_")] = val.strip()
return info
# ── Dispatch table per package manager ──────────────────────────────
BACKENDS = {
"pacman": {
"list-installed": lambda _args: _pacman_list_installed(),
"list-updates": lambda _args: _pacman_list_updates(),
"search": lambda args: _pacman_search(args[0]) if args else [],
"info": lambda args: _pacman_info(args[0]) if args else {},
},
"dnf": {
"list-installed": lambda _args: _dnf_list_installed(),
"list-updates": lambda _args: _dnf_list_updates(),
"search": lambda args: _dnf_search(args[0]) if args else [],
"info": lambda args: _dnf_info(args[0]) if args else {},
},
"apt": {
"list-installed": lambda _args: _apt_list_installed(),
"list-updates": lambda _args: _apt_list_updates(),
"search": lambda args: _apt_search(args[0]) if args else [],
"info": lambda args: _apt_info(args[0]) if args else {},
},
}
def list_installed() -> list[dict[str, str]]:
"""List installed packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-installed")
return fn([]) if fn else []
def list_updates() -> list[dict[str, str]]:
"""List available updates using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("list-updates")
return fn([]) if fn else []
def search(args: list[str]) -> list[dict[str, Any]]:
"""Search packages using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("search")
return fn(args) if fn else []
def info(args: list[str]) -> dict[str, Any]:
"""Get package info using the detected package manager."""
backend = BACKENDS.get(PKG_MANAGER, {})
fn = backend.get("info")
return fn(args) if fn else {}
def install(args: list[str]) -> dict[str, str]:
"""Install a package — actually runs the package manager via subprocess.
v0.0.31 REWRITE: previously this returned only the command string
that *would* be run, forcing the JS panel to alert("Run this
command with superuser privileges.") and the operator to copy /
sudo / paste / run. The cockpit way is to run the operation via
the cockpit superuser channel: the JS panel calls cockpit.spawn()
with { superuser: 'try' }, which prompts the operator via polkit
for the org.sysdeck.packages.modify action (shipped since v0.0.17)
that authorizes /usr/bin/pacman, /usr/bin/apt, /usr/bin/dnf.
The bridge runs the package manager via subprocess with check=True
and streams stdout/stderr line-by-line so the JS panel can render
live output.
The command-string preview shape is preserved as the `dry-run`
subcommand for operators who want to see what would be run.
"""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no install command for {PKG_MANAGER}"}
# Actually run it. The cockpit bridge runs as the cockpit user; the
# JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit
# prompt the operator for auth and run us as root via polkit.
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def remove(args: list[str]) -> dict[str, str]:
"""Remove a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
cmd_map = {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def update(args: list[str]) -> dict[str, str]:
"""Update a package — actually runs the package manager. See install()."""
if not args:
return {"error": "No package name provided"}
pkg = args[0]
cmd_map = {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"success": False, "stderr": f"no update command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def update_all() -> dict[str, str]:
"""Update all packages — actually runs the package manager. See install().
v0.0.31: this is the method called by the Packages panel `Update All`
button. Previously it returned only the command string and the panel
showed alert("Run this command with superuser privileges.") — which
defeated the purpose of having a panel. The cockpit way: the JS panel
calls bridge.packages.updateAll() with superuser: 'try', the bridge
runs pacman/apt/dnf via subprocess, and the result includes the
actual stdout/stderr for the panel to render live.
"""
cmd_map = {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]}
cmd = cmd_map.get(PKG_MANAGER, [])
if not cmd:
return {"action": "update-all", "manager": PKG_MANAGER,
"success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
r = subprocess.run(cmd, capture_output=True, text=True, check=False)
return {"action": "update-all", "manager": PKG_MANAGER,
"command": " ".join(cmd), "success": r.returncode == 0,
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
def dry_run(args: list[str]) -> dict[str, str]:
"""Return the command that *would* be run — for the operator preview.
v0.0.31: the install/remove/update/update-all subcommands now
actually execute the package manager. This subcommand preserves
the v0.0.30 behavior (return the command string without running)
so the JS panel can show a preview before the operator confirms.
"""
action = args[0] if args else "update-all"
pkg = args[1] if len(args) > 1 else ""
cmd_map = {
"install": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "install", "-y", pkg],
"apt": ["apt", "install", "-y", pkg]},
"remove": {"pacman": ["pacman", "-R", "--noconfirm", pkg],
"dnf": ["dnf", "remove", "-y", pkg],
"apt": ["apt", "remove", "-y", pkg]},
"update": {"pacman": ["pacman", "-S", "--noconfirm", pkg],
"dnf": ["dnf", "upgrade", "-y", pkg],
"apt": ["apt", "upgrade", "-y", pkg]},
"update-all": {"pacman": ["pacman", "-Syu", "--noconfirm"],
"dnf": ["dnf", "upgrade", "-y"],
"apt": ["apt", "upgrade", "-y"]},
}
sub_map = cmd_map.get(action, {})
cmd = sub_map.get(PKG_MANAGER, [])
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
"command": " ".join(cmd) if cmd else ""}
def summary() -> dict[str, Any]:
"""Aggregate summary: installed count, update count, manager."""
installed = list_installed()
updates = list_updates()
return {
"manager": PKG_MANAGER,
"installedCount": len(installed),
"updateCount": len(updates),
"updates": updates[:20], # Cap at 20 for the summary view
}
COMMANDS = {
"list-installed": lambda _args: list_installed(),
"list-updates": lambda _args: list_updates(),
"search": lambda args: search(args),
"info": lambda args: info(args),
"install": lambda args: install(args),
"remove": lambda args: remove(args),
"update": lambda args: update(args),
"update-all": lambda _args: update_all(),
# v0.0.31: dry-run preserves the v0.0.30 command-string-only shape
# for the panel's preview-before-confirm flow.
"dry-run": lambda args: dry_run(args),
"summary": lambda _args: summary(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

346
bridge/photos.py Executable file
View File

@ -0,0 +1,346 @@
#!/usr/bin/env python3
"""SysDeck - Photos Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages self-hosted photo management backends as systemd services
and exposes the built-in admin web UI for iframe embedding in the
SysDeck panel.
v0.0.35 directive: "as well as a photo manager of equal quality.
with its own module." Following the Jellyfin pattern: start/stop/
restart the service via systemctl; the panel iframes the running
admin web UI. Equal quality means the photo manager module ships
with the same service-control + iframe-load shape as Jellyfin.
Multi-backend design — same shape as bridge/db.py: the operator
chooses the backend that's installed on the host. Each entry in
BACKEND_REGISTRY declares its systemd unit, web port, install hint,
and license. The bridge auto-detects which are installed; the
panel renders a backend card per installed one and an install
hint card per absent one.
Backends shipped:
PhotoPrism — single Go binary · MIT · port 2342
Piwigo — single PHP-FPM app · GPL-2.0 · port 80
Lychee — single PHP-FPM app · MIT · port 80
Nextcloud-Memories — Nextcloud plugin · AGPL-3.0 · port 80
LibrePhotos — Django + React · MIT · port 3000
Excluded (intentionally, per the "equal quality" bar):
Google Photos / iCloud / etc. — cloud-only, no systemd unit,
no admin panel reachable from the host.
Subcommands:
summary — list all detected backends with status + port + url
status <id> — detailed status for one backend
start <id> — systemctl start <service>
stop <id> — systemctl stop <service>
restart <id> — systemctl restart <service>
web-status <id> — {running, port, url} for iframe embedding
Cockpit way (v0.0.31+ pattern): the bridge runs systemctl via
subprocess directly — no `sudo` shell-out. The JS panel passes
{ superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts
the operator via polkit for the org.sysdeck.photos.modify action
(added in v0.0.35).
Each backend is invoked as a separate process via subprocess —
the suite (MIT) and each backend (its own license) remain
independent programs. No backend code is bundled.
Usage:
python3 /usr/lib/sysdeck/bridge/photos.py summary
python3 /usr/lib/sysdeck/bridge/photos.py start photoprism
python3 /usr/lib/sysdeck/bridge/photos.py web-status photoprism
"""
import json
import shutil
import subprocess
import sys
from datetime import datetime
from typing import Any
# ── Backend Registry ───────────────────────────────────────────────
# Each entry: (id, name, family, default_port, systemd_unit, cli_tool,
# config_paths, web_path, license, homepage, install_hint)
BACKEND_REGISTRY = [
(
"photoprism", "PhotoPrism", "go-binary", 2342,
"photoprism.service", "photoprism",
["/etc/photoprism/options.yml", "/var/lib/photoprism/"],
"/", "MIT",
"https://github.com/photoprism/photoprism",
"Arch: yay -S photoprism · Debian: docker run photoprism/photoprism · Fedora: docker run photoprism/photoprism",
),
(
"piwigo", "Piwigo", "php-app", 80,
"php-fpm.service", "piwigo",
["/etc/piwigo/", "/usr/share/webapps/piwigo/"],
"/piwigo/", "GPL-2.0",
"https://github.com/Piwigo/Piwigo",
"Arch: yay -S piwigo · Debian: install under /var/www/piwigo + apache2 + php-fpm · Fedora: same",
),
(
"lychee", "Lychee", "php-app", 80,
"php-fpm.service", "lychee",
["/etc/lychee/", "/usr/share/webapps/lychee/"],
"/lychee/", "MIT",
"https://github.com/LycheeOrg/Lychee",
"Arch: yay -S lychee · Debian: install under /var/www/lychee + apache2 + php-fpm",
),
(
"nextcloud-memories", "Nextcloud Memories", "nextcloud-plugin", 80,
"php-fpm.service", "occ",
["/etc/webapps/nextcloud/", "/usr/share/webapps/nextcloud/"],
"/nextcloud/index.php/apps/memories/", "AGPL-3.0",
"https://github.com/pulsejet/memories",
"Arch: pacman -S nextcloud + occ app:enable memories · Debian: apt install nextcloud-server",
),
(
"librephotos", "LibrePhotos", "django-react", 3000,
"librephotos.service", "librephotos",
["/etc/librephotos/", "/var/lib/librephotos/"],
"/", "MIT",
"https://github.com/LibrePhotos/librephotos",
"Arch: yay -S librephotos · Debian: docker run librephotos/librephotos · Fedora: docker run librephotos/librephotos",
),
]
def _have(binary: str) -> bool:
return shutil.which(binary) is not None
def _unit_loaded(unit: str) -> bool:
if not unit:
return False
out = subprocess.run(
["systemctl", "list-unit-files", unit],
capture_output=True, text=True, timeout=5,
).stdout
return unit in out
def _systemctl_show(unit: str, props: list[str]) -> dict[str, str]:
out = subprocess.run(
["systemctl", "show", unit, "--property=" + ",".join(props)],
capture_output=True, text=True, timeout=5,
).stdout.strip()
result = {}
for line in out.splitlines():
k, _, v = line.partition("=")
if k:
result[k] = v
return result
def _service_status(unit: str) -> dict[str, Any]:
if not unit:
return {"status": "uninstalled", "active": "", "sub": "", "uptime_seconds": 0}
props = _systemctl_show(unit, ["ActiveState", "SubState", "ActiveEnterTimestamp"])
active = props.get("ActiveState", "unknown")
sub = props.get("SubState", "unknown")
uptime = 0
ts = props.get("ActiveEnterTimestamp", "")
if ts:
try:
dt = datetime.strptime(ts[:25], "%a %Y-%m-%d %H:%M:%S")
uptime = int((datetime.now() - dt).total_seconds())
except (ValueError, OSError):
pass
if active == "active":
status = "running"
elif active == "activating":
status = "starting"
elif active == "failed":
status = "error"
elif active in ("inactive", "deactivating"):
status = "stopped"
else:
status = "unknown"
return {"active": active, "sub": sub, "status": status, "uptime_seconds": uptime}
def detect_backend(entry) -> dict[str, Any]:
(bid, name, family, port, unit, cli, configs, web_path,
lic, homepage, install_hint) = entry
cli_available = _have(cli) if cli else False
unit_loaded = _unit_loaded(unit) if unit else False
if unit_loaded:
state = _service_status(unit)
status = state["status"]
active_state = state["active"]
sub_state = state["sub"]
uptime = state["uptime_seconds"]
elif cli_available:
status = "stopped"
active_state = ""
sub_state = ""
uptime = 0
elif any(_path_exists(p) for p in configs):
# Config dir present but service not registered — best-effort.
status = "stopped"
active_state = ""
sub_state = ""
uptime = 0
else:
status = "uninstalled"
active_state = ""
sub_state = ""
uptime = 0
config_path = next((p for p in configs if _path_exists(p)), "")
return {
"id": bid,
"name": name,
"family": family,
"status": status,
"active": active_state,
"sub": sub_state,
"uptime_seconds": uptime,
"port": port,
"webPath": web_path,
"url": f"http://127.0.0.1:{port}{web_path}",
"serviceUnit": unit,
"cli": cli,
"configPath": config_path,
"license": lic,
"homepage": homepage,
"installHint": install_hint,
"supported": True,
}
def _path_exists(p: str) -> bool:
try:
return bool(p) and __import__("os").path.exists(p)
except (OSError, ValueError):
return False
def cmd_summary() -> dict[str, Any]:
backends = [detect_backend(e) for e in BACKEND_REGISTRY]
installed = [b for b in backends if b["status"] != "uninstalled"]
running = [b for b in backends if b["status"] == "running"]
return {
"backends": backends,
"totalBackends": len(backends),
"installedCount": len(installed),
"runningCount": len(running),
}
def _find_backend(bid: str):
for e in BACKEND_REGISTRY:
if e[0] == bid:
return e
return None
def cmd_status(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return detect_backend(e)
def _systemctl(action: str, unit: str) -> dict[str, Any]:
if not unit:
return {"rc": 127, "success": False, "stderr": f"no systemd unit for this backend"}
try:
r = subprocess.run(
["systemctl", action, unit],
capture_output=True, text=True, timeout=30,
)
return {
"action": action,
"service": unit,
"rc": r.returncode,
"success": r.returncode == 0,
"output": (r.stdout or "").strip(),
"stderr": (r.stderr or "").strip(),
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"action": action, "service": unit, "rc": 1,
"success": False, "stderr": str(exc)}
def cmd_start(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("start", e[4])
def cmd_stop(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("stop", e[4])
def cmd_restart(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("restart", e[4])
def cmd_web_status(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
(bid2, name, family, port, unit, cli, configs, web_path,
lic, homepage, install_hint) = e
state = _service_status(unit) if unit else {"status": "uninstalled"}
return {
"id": bid2,
"name": name,
"running": state["status"] == "running",
"status": state["status"],
"port": port,
"webPath": web_path,
"url": f"http://127.0.0.1:{port}{web_path}",
"license": lic,
"homepage": homepage,
"installHint": install_hint,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = argv[0]
if cmd == "summary":
print(json.dumps(cmd_summary(), indent=2))
elif cmd == "status":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_status(bid), indent=2))
elif cmd == "start":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_start(bid), indent=2))
elif cmd == "stop":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_stop(bid), indent=2))
elif cmd == "restart":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_restart(bid), indent=2))
elif cmd == "web-status":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_web_status(bid), indent=2))
else:
print(json.dumps({"error": f"Unknown command: {cmd}"}))
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

1512
bridge/policy.py Executable file

File diff suppressed because it is too large Load Diff

526
bridge/prometheus.py Executable file
View File

@ -0,0 +1,526 @@
#!/usr/bin/env python3
"""
SysDeck - Prometheus Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages Prometheus monitoring, alerting, and the centralized log pipeline.
All SysDeck module logs are pushed to the Prometheus pushgateway for
centralized observability, metric scraping, and alert-driven responses.
Prometheus is Apache-2.0 licensed by the Prometheus Authors. This bridge
helper communicates with Prometheus via its HTTP API — no Prometheus code
is bundled.
Subcommands:
summary - Overall Prometheus status and config
targets - Scrape target health (up/down/duration)
alerts - Current firing and pending alerts
rules - Alerting and recording rules from loaded rule files
config - Full Prometheus configuration
push-log - Push a SysDeck log entry to the pushgateway
log-summary - Summary of log pipeline throughput
restart - Restart the Prometheus service unit
reload - Send SIGHUP for config reload (no restart)
"""
import json
import os
import re
import shutil
import subprocess
import sys
import time
import urllib.request
import urllib.error
from pathlib import Path
from typing import Any
PROM_LICENSE = "Apache-2.0"
PROM_AUTHORS = "Prometheus Authors"
PROM_URL = "https://prometheus.io"
# v0.0.39: import v0.0.37 security helpers from firewall.py (single
# source of truth for hardening).
sys.path.insert(0, str(Path(__file__).parent))
try:
from firewall import ( # type: ignore
SCRUBBED_ENV,
_sanitize_output,
_validate_filename,
)
except ImportError:
SCRUBBED_ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C", "LC_ALL": "C"}
def _sanitize_output(text: str, max_len: int = 4096) -> str:
if not text:
return ""
if len(text) > max_len:
text = text[:max_len] + " ... (truncated)"
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
_FILENAME_RE_FALLBACK = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def _validate_filename(name: str) -> bool:
return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
# Prometheus API endpoint from environment or default
# v0.0.40: Prometheus defaults to port 9090, which is the SAME port
# Cockpit-ws uses. Since Cockpit is already running on 9090 on every
# SysDeck host, Prometheus MUST be moved to a different port. We
# default to 9095 — it's in the familiar 909x range, doesn't conflict
# with Pushgateway (9091), Alertmanager (9093), or Cockpit (9090).
# Operators who already run Prometheus on a custom port can override
# via the PROMETHEUS_API_URL environment variable.
PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095")
# Pushgateway URL for log pipeline
PUSHGATEWAY_URL = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL", "http://localhost:9091")
# SysDeck log persistence directory
LOG_DIR = Path("/var/lib/sysdeck/prometheus-logs")
def _systemd_status(unit: str) -> dict[str, Any]:
"""Check systemd unit active state and substate.
v0.0.39 hardening: env scrubbed (SCRUBBED_ENV), output sanitized.
"""
try:
result = subprocess.run(
["systemctl", "show", unit,
"--property=ActiveState,SubState,ActiveEnterTimestamp"],
capture_output=True, text=True, timeout=5,
env=SCRUBBED_ENV,
)
props = dict(
line.split("=", 1)
for line in _sanitize_output(result.stdout).strip().splitlines()
if "=" in line
)
return {
"active": props.get("ActiveState", "unknown"),
"sub": props.get("SubState", "unknown"),
"since": props.get("ActiveEnterTimestamp", ""),
}
except (subprocess.TimeoutExpired, subprocess.CalledProcessError, OSError):
# Systemd unavailable: select default unknown state
return {"active": "unknown", "sub": "unknown", "since": ""}
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject HTTP redirects — SSRF defense (CVE-2020-35850 lesson).
v0.0.39: prevents an attacker-controlled Prometheus/Pushgateway
from redirecting the bridge to an internal service (e.g. 169.254.169.254
metadata endpoint, or other localhost services).
"""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None # raise HTTPError instead of following
def _is_localhost_url(url: str) -> bool:
"""Return True if url points to localhost/127.0.0.1 (SSRF defense)."""
return (url.startswith("http://localhost:") or
url.startswith("http://127.0.0.1:") or
url.startswith("http://[::1]:"))
def _prom_api_get(path: str, timeout: int = 5) -> dict[str, Any]:
"""GET from Prometheus HTTP API, returning parsed JSON.
v0.0.39 hardening:
- NoRedirectHandler (SSRF defense — CVE-2020-35850).
- 127.0.0.1-only URL check (SSRF defense).
- 5s timeout (DoS defense).
"""
url = f"{PROM_API_URL.rstrip('/')}/api/v1{path}"
if not _is_localhost_url(url):
return {"error": "refused", "message": f"non-localhost URL rejected: {url}"}
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open(req, timeout=timeout) as resp:
data = json.loads(resp.read())
if data.get("status") == "success":
return data.get("data", {})
return {"error": data.get("errorType", ""), "message": data.get("error", "")}
except urllib.error.HTTPError as e:
return {"error": f"http_{e.code}", "message": e.reason}
except urllib.error.URLError:
# Prometheus unreachable: report connection failure
return {"error": "connection_refused", "message": f"Cannot reach Prometheus at {url}"}
except (ValueError, KeyError, TypeError) as exc:
# API response structure unexpected: select error path
return {"error": "request_failed", "message": str(exc)}
def _pushgateway_post(job: str, data: str, timeout: int = 5) -> bool:
"""POST metrics to Prometheus pushgateway.
v0.0.39 hardening: NoRedirectHandler + 127.0.0.1-only (SSRF defense).
"""
# Validate the job name (it enters the URL path).
if not _validate_filename(job):
return False
url = f"{PUSHGATEWAY_URL.rstrip('/')}/metrics/job/{job}"
if not _is_localhost_url(url):
return False
try:
req = urllib.request.Request(url, data=data.encode(), method="POST")
req.add_header("Content-Type", "text/plain")
opener = urllib.request.build_opener(_NoRedirectHandler)
with opener.open(req, timeout=timeout) as resp:
return resp.status in (200, 202)
except (urllib.error.HTTPError, urllib.error.URLError, ValueError, OSError):
# Pushgateway unreachable: report push failure
return False
def summary() -> dict[str, Any]:
"""Overall Prometheus status, version, targets, alerts, config."""
status_info = _systemd_status("prometheus.service")
if status_info["active"] != "active":
return {
"installed": _is_installed(),
"version": "",
"uptime": "",
"status": "stopped" if _is_installed() else "uninstalled",
"targetsTotal": 0, "targetsUp": 0, "targetsDown": 0,
"activeAlerts": 0, "pendingAlerts": 0, "seriesCount": 0,
"config": _default_config(),
}
# Query Prometheus API for runtime info
build_info = _prom_api_get("/status/buildinfo")
version = build_info.get("version", "") if isinstance(build_info, dict) else ""
# Target stats
targets_data = _prom_api_get("/targets")
if not isinstance(targets_data, dict) or "activeTargets" not in targets_data:
targets_total, targets_up, targets_down = 0, 0, 0
else:
active = targets_data["activeTargets"]
targets_total = len(active)
targets_up = sum(1 for t in active if t.get("health") == "up")
targets_down = sum(1 for t in active if t.get("health") == "down")
# Alert stats
alerts_data = _prom_api_get("/alerts")
if not isinstance(alerts_data, dict) or "alerts" not in alerts_data:
active_alerts, pending_alerts = 0, 0
else:
alert_items = alerts_data["alerts"]
active_alerts = sum(1 for a in alert_items if a.get("state", "") == "firing")
pending_alerts = sum(1 for a in alert_items if a.get("state", "") == "pending")
# Series count (approximate via /status/tsdb)
tsdb_data = _prom_api_get("/status/tsdb")
series_count = 0
if isinstance(tsdb_data, dict):
raw = tsdb_data.get("seriesCountByMetricName", [{}])
series_count = len(raw) if isinstance(raw, list) else 0
config_data = _prom_api_get("/status/config")
if isinstance(config_data, dict) and "yaml" in config_data:
config = _parse_config(config_data)
else:
config = _default_config()
return {
"installed": True,
"version": version,
"uptime": status_info.get("since", ""),
"status": "running",
"targetsTotal": targets_total,
"targetsUp": targets_up,
"targetsDown": targets_down,
"activeAlerts": active_alerts,
"pendingAlerts": pending_alerts,
"seriesCount": series_count,
"config": config,
}
def _is_installed() -> bool:
"""Check if Prometheus binary or package exists."""
# Probe standard paths and package manager registries
for cmd in [
["/usr/bin/which", "prometheus"],
["systemctl", "list-unit-files", "prometheus.service"],
]:
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
if r.returncode == 0:
return True
except (subprocess.TimeoutExpired, OSError):
# Probe target unavailable: continue to next probe
pass
return False
def _default_config() -> dict[str, Any]:
"""Provide default Prometheus configuration values."""
return {
"globalScrapeInterval": "15s",
"globalEvaluationInterval": "15s",
"retentionTime": "15d",
"retentionSize": "0",
"storagePath": "/var/lib/prometheus",
"configPath": "/etc/prometheus/prometheus.yml",
"webListenAddress": "127.0.0.1:9095",
"logLevel": "info",
"walCompression": True,
}
def _parse_config(config_data: dict) -> dict[str, Any]:
"""Extract key config values from Prometheus /status/config response."""
yaml_str = config_data.get("yaml", "")
cfg = _default_config()
# Dispatch table: YAML key to config field mapping
key_dispatch = {
"scrape_interval": "globalScrapeInterval",
"evaluation_interval": "globalEvaluationInterval",
}
for line in yaml_str.splitlines():
stripped = line.strip()
if ":" not in stripped:
continue
key, _, value = stripped.partition(":")
field = key_dispatch.get(key.strip())
if field:
cfg[field] = value.strip()
return cfg
def targets() -> list[dict[str, Any]]:
"""List all scrape targets with health status."""
data = _prom_api_get("/targets")
if not isinstance(data, dict) or "activeTargets" not in data:
return []
return [{
"instance": t.get("labels", {}).get("instance", ""),
"job": t.get("labels", {}).get("job", ""),
"lastScrape": t.get("lastScrape", ""),
"lastScrapeDuration": t.get("lastScrapeDuration", 0) / 1e6,
"health": t.get("health", "unknown"),
"labels": t.get("labels", {}),
"scrapeUrl": t.get("scrapeUrl", ""),
} for t in data["activeTargets"]]
def alerts() -> list[dict[str, Any]]:
"""List current firing and pending alerts from Prometheus."""
data = _prom_api_get("/alerts")
if not isinstance(data, dict) or "alerts" not in data:
return []
return [{
"labels": a.get("labels", {}),
"state": a.get("state", "inactive"),
"activeAt": a.get("activeAt", ""),
"value": a.get("value", 0),
"annotation": a.get("annotations", {}).get(
"summary", a.get("annotations", {}).get("description", "")
),
} for a in data["alerts"]]
def rules() -> list[dict[str, Any]]:
"""List alerting and recording rules from loaded rule groups."""
data = _prom_api_get("/rules")
if not isinstance(data, dict) or "groups" not in data:
return []
return [{
"name": g.get("name", ""),
"path": g.get("file", ""),
"groups": [{"name": g.get("name", ""), "rules": [{
"group": g.get("name", ""),
"name": r.get("name", ""),
"severity": r.get("labels", {}).get("severity", "info"),
"expr": r.get("query", ""),
"for": r.get("duration", "0s"),
"summary": r.get("annotations", {}).get("summary", ""),
"state": r.get("state", "inactive"),
"value": r.get("value", None),
} for r in g.get("rules", [])]}],
} for g in data["groups"]]
def config() -> dict[str, Any]:
"""Full Prometheus YAML configuration."""
data = _prom_api_get("/status/config")
if isinstance(data, dict):
return {"yaml": data.get("yaml", ""), "parsed": _parse_config(data)}
return {"yaml": "", "parsed": _default_config()}
def push_log(args: list[str]) -> dict[str, Any]:
"""Push a SysDeck log entry to the Prometheus pushgateway.
Expects args: [module, level, message, metadata_json]
The log is formatted as Prometheus metrics and pushed to the pushgateway
under the 'sysdeck_logs' job.
Metrics pushed:
sysdeck_log_total{module,level} 1
sysdeck_log_timestamp_seconds{module,level} <epoch>
"""
if len(args) < 3:
return {"error": "Usage: push-log <module> <level> <message> [metadata_json]"}
module = args[0]
level = args[1]
message = args[2]
try:
metadata = json.loads(args[3]) if len(args) > 3 else {}
except json.JSONDecodeError:
# Metadata JSON malformed: reject push request
return {"error": "metadata_json must be valid JSON"}
ts = time.time()
iso_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
# Build Prometheus exposition format
metrics = (
f'# TYPE sysdeck_log_total counter\n'
f'sysdeck_log_total{{module="{module}",level="{level}"}} 1\n'
f'# TYPE sysdeck_log_timestamp_seconds gauge\n'
f'sysdeck_log_timestamp_seconds{{module="{module}",level="{level}"}} {ts:.3f}\n'
f'# TYPE sysdeck_log_message_info gauge\n'
f'sysdeck_log_message_info{{module="{module}",level="{level}",msg="{message[:128]}"}} 1\n'
)
pushed = _pushgateway_post("sysdeck_logs", metrics)
# Persist to local audit log
LOG_DIR.mkdir(parents=True, exist_ok=True)
log_entry = {
"module": module,
"level": level,
"message": message,
"timestamp": iso_ts,
"metadata": metadata,
"pushedToPrometheus": pushed,
}
log_file = LOG_DIR / f"{module}.jsonl"
with open(log_file, "a") as f:
f.write(json.dumps(log_entry) + "\n")
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry}
def log_summary() -> dict[str, Any]:
"""Summarize the SysDeck log pipeline: counts by module/level, push stats."""
if not LOG_DIR.exists():
return {
"totalLogs": 0, "pushedToPrometheus": 0, "failedPushes": 0,
"lastPushAt": "", "byModule": {}, "byLevel": {},
}
total = 0
pushed = 0
failed = 0
last_push = ""
by_module: dict[str, int] = {}
by_level: dict[str, int] = {}
for log_file in LOG_DIR.glob("*.jsonl"):
with open(log_file) as f:
for line in f:
try:
entry = json.loads(line)
total += 1
mod = entry.get("module", "unknown")
lvl = entry.get("level", "unknown")
by_module[mod] = by_module.get(mod, 0) + 1
by_level[lvl] = by_level.get(lvl, 0) + 1
if entry.get("pushedToPrometheus"):
pushed += 1
ts = entry.get("timestamp", "")
if ts > last_push:
last_push = ts
else:
failed += 1
except json.JSONDecodeError:
# Malformed log entry: skip and continue
continue
return {
"totalLogs": total,
"pushedToPrometheus": pushed,
"failedPushes": failed,
"lastPushAt": last_push,
"byModule": by_module,
"byLevel": by_level,
}
def restart() -> dict[str, Any]:
"""Restart the Prometheus systemd service.
v0.0.39 hardening: no sudo (cockpit superuser channel handles auth via
polkit org.sysdeck.monitoring.modify). env scrubbed. check=False with
structured error return. CVE-2022-0824 lesson — the bridge does not
trust the UI; polkit gates the privileged verb.
"""
try:
r = subprocess.run(
["systemctl", "restart", "prometheus.service"],
capture_output=True, text=True, check=False, timeout=10,
env=SCRUBBED_ENV,
)
if r.returncode == 0:
return {"action": "restart", "result": "ok"}
return {"action": "restart", "result": "error",
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
except subprocess.TimeoutExpired:
return {"action": "restart", "result": "error", "message": "restart timed out after 10s"}
except OSError as exc:
return {"action": "restart", "result": "error", "message": str(exc)}
def reload() -> dict[str, Any]:
"""Send SIGHUP to Prometheus for live config reload.
v0.0.39 hardening: no sudo, env scrubbed, check=False.
"""
try:
r = subprocess.run(
["systemctl", "kill", "--signal=SIGHUP", "prometheus.service"],
capture_output=True, text=True, check=False, timeout=5,
env=SCRUBBED_ENV,
)
if r.returncode == 0:
return {"action": "reload", "result": "ok"}
return {"action": "reload", "result": "error",
"rc": r.returncode, "stderr": _sanitize_output(r.stderr).strip()}
except subprocess.TimeoutExpired:
return {"action": "reload", "result": "error", "message": "reload timed out after 5s"}
except OSError as exc:
return {"action": "reload", "result": "error", "message": str(exc)}
COMMANDS = {
"summary": lambda _args: summary(),
"targets": lambda _args: targets(),
"alerts": lambda _args: alerts(),
"rules": lambda _args: rules(),
"config": lambda _args: config(),
"push-log": lambda args: push_log(args),
"log-summary": lambda _args: log_summary(),
"restart": lambda _args: restart(),
"reload": lambda _args: reload(),
}
def main(argv: list[str]) -> int:
"""Dispatch subcommand and emit JSON result."""
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

414
bridge/remotefs.py Executable file
View File

@ -0,0 +1,414 @@
#!/usr/bin/env python3
"""SysDeck - Remote FS Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Manages remote / distributed filesystem backends as systemd services
and surfaces cluster status from each backend's CLI tool. The bridge
auto-detects which backends are installed on the host; the panel
renders a card per detected backend with cluster status, pool/brick/
volume counts, and Start/Stop/Restart controls.
v0.0.35 directive: "then a remote fs manager such as ceph, and
others but not nfs or amanada fs." The backends included match
that directive — distributed / shared-storage filesystems with
their own cluster management surface:
Backends shipped:
Ceph — distributed object storage · LGPL-2.1 · ceph / cephfs
GlusterFS — scale-out network filesystem · GPL-2.0 · gluster
MooseFS — distributed fault-tolerant FS · GPL-2.0 · moosefs
BeeGFS — parallel cluster filesystem · BeeGFS EULA (free) · beegfs
OrangeFS — parallel FS (PVFS2 successor) · OpenSource · orangefs
Explicitly EXCLUDED per directive:
NFS — kernel-builtin, no admin panel beyond `nfsd` daemon;
no cluster, no remote-FS-as-data-store semantics.
Operators who need NFS use cockpit-nfs (separate plugin).
Amanda — backup system (AMANDA = Advanced Maryland Automatic
Network Disk Archiver), NOT a remote/distributed
filesystem. Operators who need backup use a dedicated
backup solution.
Subcommands:
summary — list all detected backends with status + cluster info
status <id> — detailed status for one backend
start <id> — systemctl start <service>
stop <id> — systemctl stop <service>
restart <id> — systemctl restart <service>
cluster-info <id> — backend-specific cluster status (ceph status,
gluster pool list, moosefs-cli info, etc.)
Cockpit way (v0.0.31+ pattern): the bridge runs systemctl via
subprocess directly — no `sudo` shell-out. The JS panel passes
{ superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts
the operator via polkit for the org.sysdeck.remotefs.modify action
(added in v0.0.35).
Usage:
python3 /usr/lib/sysdeck/bridge/remotefs.py summary
python3 /usr/lib/sysdeck/bridge/remotefs.py start ceph
python3 /usr/lib/sysdeck/bridge/remotefs.py cluster-info glusterfs
"""
import json
import shutil
import subprocess
import sys
from datetime import datetime
from typing import Any
# ── Backend Registry ───────────────────────────────────────────────
# Each entry: (id, name, family, default_port, systemd_unit, cli_tool,
# config_paths, license, homepage, install_hint)
BACKEND_REGISTRY = [
(
"ceph", "Ceph", "object-storage", 6789,
"ceph.target", "ceph",
["/etc/ceph/ceph.conf"],
"LGPL-2.1",
"https://ceph.io/",
"Arch: pacman -S ceph · Debian: apt install ceph · Fedora: dnf install ceph",
),
(
"glusterfs", "GlusterFS", "scale-out-fs", 24007,
"glusterd.service", "gluster",
["/etc/glusterfs/glusterd.vol"],
"GPL-2.0",
"https://www.gluster.org/",
"Arch: pacman -S glusterfs · Debian: apt install glusterfs-server · Fedora: dnf install glusterfs-server",
),
(
"moosefs", "MooseFS", "distributed-fs", 9420,
"moosefs-master.service", "moosefs-cli",
["/etc/mfs/mfsmaster.cfg"],
"GPL-2.0",
"https://moosefs.com/",
"Arch: pacman -S moosefs · Debian: apt install moosefs-master · Fedora: dnf install moosefs-master",
),
(
"beegfs", "BeeGFS", "parallel-fs", 8008,
"beegfs-meta.service", "beegfs-ctl",
["/etc/beegfs/beegfs-meta.conf"],
"BeeGFS EULA (free)",
"https://www.beegfs.io/",
"Arch: yay -S beegfs · Debian: apt install beegfs-meta · Fedora: see beegfs.io docs",
),
(
"orangefs", "OrangeFS", "parallel-fs", 3334,
"pvfs2-server.service", "pvfs2-server",
["/etc/orangefs/orangefs-server.conf"],
"OpenSource (BSD-3)",
"http://www.orangefs.org/",
"Arch: yay -S orangefs · Debian: apt install orangefs-server · Fedora: dnf install orangefs-server",
),
]
# ── EXCLUDED backends — documented here so future contributors
# don't accidentally add them back.
EXCLUDED = {
"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.",
}
def _have(binary: str) -> bool:
return shutil.which(binary) is not None
def _unit_loaded(unit: str) -> bool:
if not unit:
return False
out = subprocess.run(
["systemctl", "list-unit-files", unit],
capture_output=True, text=True, timeout=5,
).stdout
return unit in out
def _systemctl_show(unit: str, props: list[str]) -> dict[str, str]:
out = subprocess.run(
["systemctl", "show", unit, "--property=" + ",".join(props)],
capture_output=True, text=True, timeout=5,
).stdout.strip()
result = {}
for line in out.splitlines():
k, _, v = line.partition("=")
if k:
result[k] = v
return result
def _service_status(unit: str) -> dict[str, Any]:
if not unit:
return {"status": "uninstalled", "active": "", "sub": "", "uptime_seconds": 0}
props = _systemctl_show(unit, ["ActiveState", "SubState", "ActiveEnterTimestamp"])
active = props.get("ActiveState", "unknown")
sub = props.get("SubState", "unknown")
uptime = 0
ts = props.get("ActiveEnterTimestamp", "")
if ts:
try:
dt = datetime.strptime(ts[:25], "%a %Y-%m-%d %H:%M:%S")
uptime = int((datetime.now() - dt).total_seconds())
except (ValueError, OSError):
pass
if active == "active":
status = "running"
elif active == "activating":
status = "starting"
elif active == "failed":
status = "error"
elif active in ("inactive", "deactivating"):
status = "stopped"
else:
status = "unknown"
return {"active": active, "sub": sub, "status": status, "uptime_seconds": uptime}
def _detect_backend(entry) -> dict[str, Any]:
(bid, name, family, port, unit, cli, configs, lic, homepage, install_hint) = entry
cli_available = _have(cli) if cli else False
unit_loaded = _unit_loaded(unit) if unit else False
config_present = any(_path_exists(p) for p in configs)
if unit_loaded:
state = _service_status(unit)
status = state["status"]
active_state = state["active"]
sub_state = state["sub"]
uptime = state["uptime_seconds"]
elif cli_available or config_present:
status = "stopped"
active_state = ""
sub_state = ""
uptime = 0
else:
status = "uninstalled"
active_state = ""
sub_state = ""
uptime = 0
config_path = next((p for p in configs if _path_exists(p)), "")
return {
"id": bid,
"name": name,
"family": family,
"status": status,
"active": active_state,
"sub": sub_state,
"uptime_seconds": uptime,
"port": port,
"url": f"http://127.0.0.1:{port}" if port else "",
"serviceUnit": unit,
"cli": cli,
"configPath": config_path,
"license": lic,
"homepage": homepage,
"installHint": install_hint,
"supported": True,
"excluded": False,
}
def _path_exists(p: str) -> bool:
try:
return bool(p) and __import__("os").path.exists(p)
except (OSError, ValueError):
return False
def cmd_summary() -> dict[str, Any]:
backends = [_detect_backend(e) for e in BACKEND_REGISTRY]
installed = [b for b in backends if b["status"] != "uninstalled"]
running = [b for b in backends if b["status"] == "running"]
return {
"backends": backends,
"totalBackends": len(backends),
"installedCount": len(installed),
"runningCount": len(running),
"excluded": EXCLUDED,
}
def _find_backend(bid: str):
for e in BACKEND_REGISTRY:
if e[0] == bid:
return e
return None
def cmd_status(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _detect_backend(e)
def _systemctl(action: str, unit: str) -> dict[str, Any]:
if not unit:
return {"rc": 127, "success": False, "stderr": "no systemd unit for this backend"}
try:
r = subprocess.run(
["systemctl", action, unit],
capture_output=True, text=True, timeout=30,
)
return {
"action": action,
"service": unit,
"rc": r.returncode,
"success": r.returncode == 0,
"output": (r.stdout or "").strip(),
"stderr": (r.stderr or "").strip(),
}
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
return {"action": action, "service": unit, "rc": 1,
"success": False, "stderr": str(exc)}
def cmd_start(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("start", e[4])
def cmd_stop(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("stop", e[4])
def cmd_restart(bid: str) -> dict[str, Any]:
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
return _systemctl("restart", e[4])
def cmd_cluster_info(bid: str) -> dict[str, Any]:
"""Backend-specific cluster status query.
Each backend has its own CLI for cluster status:
ceph → ceph status (JSON via --format=json)
glusterfs → gluster pool list
moosefs → moosefs-cli info
beegfs → beegfs-ctl --listnodes
orangefs → pvfs2-server -m
"""
e = _find_backend(bid)
if not e:
return {"error": f"Unknown backend: {bid}"}
(bid2, name, family, port, unit, cli, configs, lic, homepage, install_hint) = e
if bid2 == "ceph":
out = subprocess.run(
["ceph", "status", "--format=json"],
capture_output=True, text=True, timeout=15,
)
if out.returncode != 0:
return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"}
try:
data = json.loads(out.stdout)
return {
"backend": bid2,
"raw": data,
"summary": {
"health": data.get("health", {}).get("status", "?"),
"fsid": data.get("fsid", "?"),
"monmap": data.get("monmap", {}).get("num_mons", 0),
"osdmap": data.get("osdmap", {}).get("osdmap", {}).get("num_osds", 0),
"pgmap": data.get("pgmap", {}).get("num_pgs", 0),
},
"rawText": out.stdout[:4000],
}
except json.JSONDecodeError:
return {"backend": bid2, "rawText": out.stdout[:4000]}
if bid2 == "glusterfs":
out = subprocess.run(
["gluster", "pool", "list"],
capture_output=True, text=True, timeout=15,
)
return {
"backend": bid2,
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
"stderr": out.stderr.strip() if out.returncode != 0 else "",
"rc": out.returncode,
}
if bid2 == "moosefs":
out = subprocess.run(
["moosefs-cli", "info"],
capture_output=True, text=True, timeout=15,
)
return {
"backend": bid2,
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
"stderr": out.stderr.strip() if out.returncode != 0 else "",
"rc": out.returncode,
}
if bid2 == "beegfs":
out = subprocess.run(
["beegfs-ctl", "--listnodes"],
capture_output=True, text=True, timeout=15,
)
return {
"backend": bid2,
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
"stderr": out.stderr.strip() if out.returncode != 0 else "",
"rc": out.returncode,
}
if bid2 == "orangefs":
out = subprocess.run(
["pvfs2-server", "-m"],
capture_output=True, text=True, timeout=15,
)
return {
"backend": bid2,
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
"stderr": out.stderr.strip() if out.returncode != 0 else "",
"rc": out.returncode,
}
return {"error": f"No cluster-info handler for {bid2}"}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = argv[0]
if cmd == "summary":
print(json.dumps(cmd_summary(), indent=2))
elif cmd == "status":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_status(bid), indent=2))
elif cmd == "start":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_start(bid), indent=2))
elif cmd == "stop":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_stop(bid), indent=2))
elif cmd == "restart":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_restart(bid), indent=2))
elif cmd == "cluster-info":
bid = argv[1] if len(argv) > 1 else ""
print(json.dumps(cmd_cluster_info(bid), indent=2))
else:
print(json.dumps({"error": f"Unknown command: {cmd}"}))
return 2
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

112
bridge/sensors.py Executable file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
SysDeck - Sensors Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Aggregates hardware sensor data from lm_sensors (`sensors -j`) and
the cockpit-sensors plugin (https://github.com/ocristopfer/cockpit-sensors)
into a structured JSON document.
cockpit-sensors is MIT licensed by ocristopfer. This bridge helper invokes
sensors as a separate process via subprocess — the suite (MIT) and
lm_sensors remain independent programs. No cockpit-sensors code is bundled;
the suite's panel provides its own rendering of the sensors data.
Usage:
python3 -m sysdeck.bridge.sensors summary
python3 -m sysdeck.bridge.sensors temps
python3 -m sysdeck.bridge.sensors fans
python3 -m sysdeck.bridge.sensors voltages
"""
import json
import subprocess
import sys
from typing import Any
SENSORS_LICENSE = "MIT (lm_sensors) + MIT (cockpit-sensors)"
SENSORS_AUTHOR = "ocristopfer (cockpit-sensors), lm_sensors project"
SENSORS_URL = "https://github.com/ocristopfer/cockpit-sensors"
def run_sensors(args: list[str]) -> str:
"""Run sensors with the given args, returning stdout."""
return subprocess.run(
["sensors", *args], capture_output=True, text=True, check=True,
).stdout
def summary() -> dict[str, Any]:
"""Full sensor summary from `sensors -j`.
Returns a nested dict: { adapter: { sensor: { field: value } } }
"""
output = run_sensors(["-j"])
return json.loads(output)
def temps() -> dict[str, Any]:
"""Temperature sensors only — filter for keys containing 'temp' or 'Core'."""
data = summary()
result: dict[str, Any] = {}
for adapter, sensors in data.items():
filtered = {
key: val for key, val in sensors.items()
if "temp" in key.lower() or "core" in key.lower() or "tctl" in key.lower()
}
if filtered:
result[adapter] = filtered
return result
def fans() -> dict[str, Any]:
"""Fan speed sensors only — filter for keys containing 'fan'."""
data = summary()
result: dict[str, Any] = {}
for adapter, sensors in data.items():
filtered = {
key: val for key, val in sensors.items()
if "fan" in key.lower()
}
if filtered:
result[adapter] = filtered
return result
def voltages() -> dict[str, Any]:
"""Voltage sensors only — filter for keys containing 'in' (lm_sensors convention)."""
data = summary()
result: dict[str, Any] = {}
for adapter, sensors in data.items():
filtered = {
key: val for key, val in sensors.items()
if key.startswith("in") or "vcore" in key.lower() or "vbat" in key.lower()
}
if filtered:
result[adapter] = filtered
return result
COMMANDS = {
"summary": lambda _args: summary(),
"temps": lambda _args: temps(),
"fans": lambda _args: fans(),
"voltages": lambda _args: voltages(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

486
bridge/themes.py Executable file
View File

@ -0,0 +1,486 @@
#!/usr/bin/env python3
"""
SysDeck - Theme Engine Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
"themes and mining they need to be expanded for maximum ui control.
think 1999 power tool style here." The Theme Engine panel surfaces
the full set of cockpit.conf theming knobs plus a preset gallery
and live-preview CSS-variable overrides.
Subcommands:
read-config — read /etc/cockpit/cockpit.conf as text
write-config <text> — write the full cockpit.conf text (superuser)
get <section> [key] — get one section or one key from cockpit.conf
set <section> <key> <value> — set one key (superuser)
unset <section> <key> — remove one key (superuser)
reset — delete cockpit.conf entirely (superuser)
(cockpit falls back to its built-in defaults)
preset-list — return the built-in preset gallery
preset-apply <id> — apply one preset (writes cockpit.conf)
variable-list — return the SysDeck CSS variable surface
(the --sysdeck-* custom properties in
shared/sysdeck.css that the panel can
override live via CSS-variable setter)
variable-get <name> — read a CSS variable's current value
(from /var/lib/sysdeck/themes/overrides.css)
variable-set <name> <value> — write a CSS variable override
(writes to /var/lib/sysdeck/themes/
overrides.css; the panel injects the file
as a <link> at runtime)
variable-reset — clear all CSS variable overrides
Cockpit way: the bridge runs subprocess directly; the JS panel passes
{ superuser: 'try' } for mutating ops so the cockpit bridge prompts
via polkit for the org.sysdeck.system.manage action (shipped since
v0.0.17 — authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc.).
Cockpit's theming surface is /etc/cockpit/cockpit.conf's [Brand],
[Theme], [OAuth], [Session], [LogFilter], [HealthConfig], and
[PrettyEnv] sections — see `man cockpit.conf`. SysDeck's own theming
surface is the CSS variables in shared/sysdeck.css — those can be
overridden live without a server round-trip via the variable-*
subcommands.
Usage:
python3 /usr/lib/sysdeck/bridge/themes.py read-config
python3 /usr/lib/sysdeck/bridge/themes.py set Brand Color red
python3 /usr/lib/sysdeck/bridge/themes.py preset-apply midnight
python3 /usr/lib/sysdeck/bridge/themes.py variable-set --sysdeck-bg '#1a1a2a'
"""
import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
COCKPIT_CONF = Path("/etc/cockpit/cockpit.conf")
SYSDECK_THEME_DIR = Path("/var/lib/sysdeck/themes")
SYSDECK_OVERRIDES_FILE = SYSDECK_THEME_DIR / "overrides.css"
# Built-in preset gallery. Each entry is a complete cockpit.conf
# [Brand]/[Theme] snippet plus a set of CSS variable overrides.
# Operators can drop additional presets as JSON files into
# /var/lib/sysdeck/themes/presets/*.json — the preset-list subcommand
# discovers them automatically.
PRESETS: list[dict[str, Any]] = [
{
"id": "midnight",
"name": "Midnight",
"description": "Deep dark blue palette, soft cyan accent. Easy on the eyes at 3am.",
"cockpit_conf": {"Brand": {"Color": "danger"}, "Theme": {"style": "dark"}},
"css_variables": {
"--sysdeck-bg": "#0f0f1a",
"--sysdeck-fg": "#e0e0e0",
"--sysdeck-accent": "#06c",
"--sysdeck-card-bg": "#1a1a2a",
},
},
{
"id": "alpine",
"name": "Alpine",
"description": "Cool white-on-grey. Default-style with sharper contrast.",
"cockpit_conf": {"Theme": {"style": "light"}},
"css_variables": {
"--sysdeck-bg": "#f5f5f5",
"--sysdeck-fg": "#1a1a1a",
"--sysdeck-accent": "#0066cc",
"--sysdeck-card-bg": "#ffffff",
},
},
{
"id": "forest",
"name": "Forest",
"description": "Dark green palette for ops environments with red/green alert emphasis.",
"cockpit_conf": {"Brand": {"Color": "success"}, "Theme": {"style": "dark"}},
"css_variables": {
"--sysdeck-bg": "#0f1a0f",
"--sysdeck-fg": "#cfe0c0",
"--sysdeck-accent": "#3c9",
"--sysdeck-card-bg": "#1a2a1a",
},
},
{
"id": "amber",
"name": "Amber",
"description": "Warm dark amber palette — like a 1999 CRT terminal.",
"cockpit_conf": {"Theme": {"style": "dark"}},
"css_variables": {
"--sysdeck-bg": "#1a0f00",
"--sysdeck-fg": "#ffb000",
"--sysdeck-accent": "#ff8c00",
"--sysdeck-card-bg": "#2a1f00",
},
},
{
"id": "violet",
"name": "Violet",
"description": "Deep purple palette — neon-on-dark synthwave.",
"cockpit_conf": {"Theme": {"style": "dark"}},
"css_variables": {
"--sysdeck-bg": "#1a0a2a",
"--sysdeck-fg": "#e0d0ff",
"--sysdeck-accent": "#9c3",
"--sysdeck-card-bg": "#2a1a3a",
},
},
{
"id": "high-contrast",
"name": "High Contrast",
"description": "Black-on-white maximum contrast for accessibility.",
"cockpit_conf": {"Theme": {"style": "light"}},
"css_variables": {
"--sysdeck-bg": "#ffffff",
"--sysdeck-fg": "#000000",
"--sysdeck-accent": "#0000ff",
"--sysdeck-card-bg": "#ffffff",
"--sysdeck-border": "#000000",
},
},
]
# The CSS-variable surface the panel can override live. Each entry:
# (name, kind, default, description). The 'kind' tells the JS panel
# which input control to render: 'color' → <input type=color>,
# 'select' → <select>, 'number' → <input type=number>, 'text' →
# <input type=text>.
CSS_VARIABLES: list[dict[str, Any]] = [
{"name": "--sysdeck-bg", "kind": "color", "default": "#1e1e1e", "description": "Page background"},
{"name": "--sysdeck-fg", "kind": "color", "default": "#e0e0e0", "description": "Primary text color"},
{"name": "--sysdeck-muted", "kind": "color", "default": "#888888", "description": "Muted/secondary text"},
{"name": "--sysdeck-accent", "kind": "color", "default": "#0066cc", "description": "Accent color for links/highlights"},
{"name": "--sysdeck-accent-success","kind": "color", "default": "#33cc99", "description": "Success badge color"},
{"name": "--sysdeck-accent-warn", "kind": "color", "default": "#f0ad4e", "description": "Warning badge color"},
{"name": "--sysdeck-accent-danger","kind": "color", "default": "#d9534f", "description": "Danger badge color"},
{"name": "--sysdeck-border", "kind": "color", "default": "#444444", "description": "Card border color"},
{"name": "--sysdeck-card-bg", "kind": "color", "default": "#2a2a2a", "description": "Card background color"},
{"name": "--sysdeck-font-size-base","kind": "number","default": "14", "description": "Base font size (px)"},
{"name": "--sysdeck-density", "kind": "select", "default": "normal", "description": "Padding density",
"options": ["compact", "normal", "comfortable"]},
{"name": "--sysdeck-radius", "kind": "number", "default": "6", "description": "Card border radius (px)"},
]
# ── INI-style parser ─────────────────────────────────────────────────
#
# cockpit.conf is INI-format. Python's configparser handles it, but
# SysDeck needs to preserve comments and ordering when writing back.
# This minimal parser round-trips comments by treating them as part
# of the previous section's body. Good enough for the operator-facing
# theme panel — the bridge does not need a full INI library.
SECTION_RE = re.compile(r"^\[(?P<name>[^\]]+)\]\s*$")
KV_RE = re.compile(r"^\s*(?P<key>[^=\s]+)\s*=\s*(?P<val>.*)$")
def _parse_conf(text: str) -> dict[str, dict[str, str]]:
"""Parse cockpit.conf text into {section: {key: val}}.
Comments (lines starting with # or ;) are stripped from the parsed
structure; the operator can re-add them via the raw text editor.
"""
sections: dict[str, dict[str, str]] = {}
current: dict[str, str] = {}
current_name = ""
for line in text.splitlines():
if not line.strip() or line.lstrip().startswith(("#", ";")):
continue
m = SECTION_RE.match(line)
if m:
if current_name:
sections[current_name] = current
current_name = m.group("name")
current = {}
continue
kv = KV_RE.match(line)
if kv and current_name:
current[kv.group("key")] = kv.group("val").strip()
if current_name:
sections[current_name] = current
return sections
def _serialize_conf(sections: dict[str, dict[str, str]]) -> str:
"""Serialize the sections dict back to cockpit.conf text.
Section order is preserved (Python 3.7+ dict is ordered). Within
a section, key order is preserved.
"""
lines: list[str] = ["# cockpit.conf — managed by SysDeck Theme Engine"]
for section, kvs in sections.items():
lines.append("")
lines.append(f"[{section}]")
for k, v in kvs.items():
lines.append(f"{k} = {v}")
lines.append("")
return "\n".join(lines)
def _read_text() -> str:
"""Read cockpit.conf as text. Returns '' if absent."""
try:
return COCKPIT_CONF.read_text(encoding="utf-8")
except (FileNotFoundError, PermissionError, OSError):
return ""
def _write_text(text: str) -> None:
"""Write cockpit.conf text. Creates parent dir if needed."""
COCKPIT_CONF.parent.mkdir(parents=True, exist_ok=True)
COCKPIT_CONF.write_text(text, encoding="utf-8")
# ── Subcommands ──────────────────────────────────────────────────────
def cmd_read_config(_args: list[str]) -> dict[str, Any]:
"""Return the raw cockpit.conf text + parsed sections."""
text = _read_text()
return {
"path": str(COCKPIT_CONF),
"text": text or "# (file absent — cockpit defaults in effect)",
"sections": _parse_conf(text),
"present": bool(text),
}
def cmd_write_config(args: list[str]) -> dict[str, Any]:
"""Overwrite cockpit.conf with the given text.
Usage: write-config <text>. The text is taken as the first argv
element (the JS panel sends it as a single quoted argument).
"""
if not args:
return {"error": "text required"}
text = args[0]
try:
_write_text(text)
return {"written": True, "path": str(COCKPIT_CONF), "size": len(text)}
except (PermissionError, OSError) as exc:
return {"written": False, "error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.system.manage)"}
def cmd_get(args: list[str]) -> dict[str, Any]:
"""Get one section or one key from cockpit.conf.
Usage: get <section> [key]. Without a key, returns the whole
section. With a key, returns just that key's value.
"""
if not args:
return {"error": "section name required"}
sections = _parse_conf(_read_text())
section = args[0]
if section not in sections:
return {"error": f"section [{section}] not found", "available_sections": list(sections.keys())}
if len(args) < 2:
return {"section": section, "keys": sections[section]}
key = args[1]
if key not in sections[section]:
return {"error": f"key {key} not found in section [{section}]",
"available_keys": list(sections[section].keys())}
return {"section": section, "key": key, "value": sections[section][key]}
def cmd_set(args: list[str]) -> dict[str, Any]:
"""Set one key in cockpit.conf.
Usage: set <section> <key> <value>. Creates the section if absent.
"""
if len(args) < 3:
return {"error": "usage: set <section> <key> <value>"}
section, key, value = args[0], args[1], args[2]
text = _read_text()
sections = _parse_conf(text)
sections.setdefault(section, {})[key] = value
try:
_write_text(_serialize_conf(sections))
return {"set": True, "section": section, "key": key, "value": value}
except (PermissionError, OSError) as exc:
return {"set": False, "error": str(exc),
"hint": "run via cockpit superuser channel (polkit org.sysdeck.system.manage)"}
def cmd_unset(args: list[str]) -> dict[str, Any]:
"""Remove a key from cockpit.conf."""
if len(args) < 2:
return {"error": "usage: unset <section> <key>"}
section, key = args[0], args[1]
text = _read_text()
sections = _parse_conf(text)
if section not in sections or key not in sections[section]:
return {"unset": False, "error": f"key {key} not in section [{section}]"}
del sections[section][key]
if not sections[section]:
del sections[section]
try:
_write_text(_serialize_conf(sections))
return {"unset": True, "section": section, "key": key}
except (PermissionError, OSError) as exc:
return {"unset": False, "error": str(exc)}
def cmd_reset(_args: list[str]) -> dict[str, Any]:
"""Reset cockpit.conf to defaults (delete the file)."""
try:
COCKPIT_CONF.unlink(missing_ok=True)
return {"reset": True, "path": str(COCKPIT_CONF)}
except (PermissionError, OSError) as exc:
return {"reset": False, "error": str(exc)}
# ── Preset gallery ──────────────────────────────────────────────────
def cmd_preset_list(_args: list[str]) -> dict[str, Any]:
"""Return the built-in preset gallery + any operator-dropped JSON."""
presets = list(PRESETS)
# Discover operator-dropped presets in /var/lib/sysdeck/themes/presets/.
custom_dir = SYSDECK_THEME_DIR / "presets"
if custom_dir.is_dir():
for p in sorted(custom_dir.glob("*.json")):
try:
preset = json.loads(p.read_text(encoding="utf-8"))
if "id" in preset and "name" in preset:
preset["_custom"] = True
preset["_source"] = str(p)
presets.append(preset)
except (json.JSONDecodeError, OSError):
continue
return {"presets": presets, "count": len(presets)}
def cmd_preset_apply(args: list[str]) -> dict[str, Any]:
"""Apply a preset — writes both cockpit.conf and the CSS overrides."""
if not args:
return {"error": "preset id required"}
preset_id = args[0]
preset = next((p for p in PRESETS if p["id"] == preset_id), None)
if preset is None:
return {"error": f"preset '{preset_id}' not found",
"hint": "call preset-list for available ids"}
# 1. Apply the cockpit.conf portion.
text = _read_text()
sections = _parse_conf(text)
for section, kvs in (preset.get("cockpit_conf") or {}).items():
sections.setdefault(section, {}).update(kvs)
try:
_write_text(_serialize_conf(sections))
except (PermissionError, OSError) as exc:
return {"applied": False, "error": str(exc)}
# 2. Apply the CSS variable overrides.
css_vars = preset.get("css_variables") or {}
try:
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
css = ":root {\n" + "\n".join(f" {k}: {v};" for k, v in css_vars.items()) + "\n}\n"
SYSDECK_OVERRIDES_FILE.write_text(css, encoding="utf-8")
except (PermissionError, OSError) as exc:
return {"applied": True, "cockpit_conf": True, "css_overrides": False, "error": str(exc)}
return {
"applied": True,
"preset": preset_id,
"cockpit_conf_sections_written": list((preset.get("cockpit_conf") or {}).keys()),
"css_variables_written": list(css_vars.keys()),
}
# ── CSS variable overrides ───────────────────────────────────────────
def cmd_variable_list(_args: list[str]) -> dict[str, Any]:
"""Return the SysDeck CSS variable surface."""
return {"variables": CSS_VARIABLES, "count": len(CSS_VARIABLES)}
def cmd_variable_get(args: list[str]) -> dict[str, Any]:
"""Read a CSS variable's current override value (or default)."""
if not args:
return {"error": "variable name required"}
name = args[0]
spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
if spec is None:
return {"error": f"variable {name} not in the surface",
"hint": "call variable-list for the available names"}
# Read from overrides file.
val = spec["default"]
try:
text = SYSDECK_OVERRIDES_FILE.read_text(encoding="utf-8")
m = re.search(rf"{re.escape(name)}\s*:\s*([^;]+);", text)
if m:
val = m.group(1).strip()
except (FileNotFoundError, OSError):
pass
return {"name": name, "value": val, "default": spec["default"], "kind": spec["kind"]}
def cmd_variable_set(args: list[str]) -> dict[str, Any]:
"""Write a CSS variable override."""
if len(args) < 2:
return {"error": "usage: variable-set <name> <value>"}
name, value = args[0], args[1]
spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
if spec is None:
return {"error": f"variable {name} not in the surface"}
try:
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
# Read existing overrides, replace or append this variable.
text = SYSDECK_OVERRIDES_FILE.read_text(encoding="utf-8") if SYSDECK_OVERRIDES_FILE.is_file() else ""
if re.search(rf"{re.escape(name)}\s*:", text):
text = re.sub(rf"{re.escape(name)}\s*:\s*[^;]+;", f"{name}: {value};", text)
else:
# Insert before the closing }.
text = text.replace("}", f" {name}: {value};\n}}", 1) if "}" in text else f":root {{\n {name}: {value};\n}}\n"
SYSDECK_OVERRIDES_FILE.write_text(text, encoding="utf-8")
return {"set": True, "name": name, "value": value}
except (PermissionError, OSError) as exc:
return {"set": False, "error": str(exc)}
def cmd_variable_reset(_args: list[str]) -> dict[str, Any]:
"""Clear all CSS variable overrides."""
try:
SYSDECK_OVERRIDES_FILE.unlink(missing_ok=True)
return {"reset": True, "path": str(SYSDECK_OVERRIDES_FILE)}
except (PermissionError, OSError) as exc:
return {"reset": False, "error": str(exc)}
# ── Dispatch table ───────────────────────────────────────────────────
COMMANDS = {
"read-config": lambda _args: cmd_read_config([]),
"write-config": lambda args: cmd_write_config(args),
"get": lambda args: cmd_get(args),
"set": lambda args: cmd_set(args),
"unset": lambda args: cmd_unset(args),
"reset": lambda _args: cmd_reset([]),
"preset-list": lambda _args: cmd_preset_list([]),
"preset-apply": lambda args: cmd_preset_apply(args),
"variable-list": lambda _args: cmd_variable_list([]),
"variable-get": lambda args: cmd_variable_get(args),
"variable-set": lambda args: cmd_variable_set(args),
"variable-reset": lambda _args: cmd_variable_reset([]),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

63
bridge/vault.py Executable file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
SysDeck - Vault Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
Lists LUKS-encrypted block devices via `lsblk -J`. Returns an empty list
when no LUKS volumes are present or lsblk is unavailable — the module
JS handles that as "no LUKS volumes".
Usage:
python3 /usr/lib/sysdeck/bridge/vault.py list-luks
"""
import json
import subprocess
import sys
def list_luks() -> list:
"""Return LUKS-encrypted block devices. Empty list if none or lsblk absent."""
try:
r = subprocess.run(
["lsblk", "-o", "NAME,FSTYPE,MOUNTPOINT,SIZE,TYPE", "-J"],
capture_output=True, text=True, check=True,
)
data = json.loads(r.stdout) if r.stdout.strip() else {}
devices = []
for blk in data.get("blockdevices", []):
def walk(node):
if node.get("fstype") == "crypto_LUKS":
devices.append({
"name": node.get("name", ""),
"size": node.get("size", ""),
"mountpoint": node.get("mountpoint") or "",
"type": node.get("type", ""),
})
for child in node.get("children", []) or []:
walk(child)
walk(blk)
return devices
except (FileNotFoundError, subprocess.CalledProcessError, json.JSONDecodeError, OSError):
return []
COMMANDS = {
"list-luks": lambda _args: list_luks(),
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

96
cockpit-smoke-test.sh Executable file
View File

@ -0,0 +1,96 @@
#!/bin/bash
# cockpit-smoke-test.sh — install a 5-line hello-world Cockpit plugin
# and verify Cockpit discovers it. This decouples "is Cockpit working?"
# from "is sysdeck's manifest correct?".
#
# If this smoke test ALSO fails to show "Hello Test" in the sidebar,
# the problem is NOT sysdeck — it's Cockpit itself (config, permissions,
# install location, version, etc.).
#
# Usage:
# sudo bash cockpit-smoke-test.sh install # install hello-world plugin
# sudo bash cockpit-smoke-test.sh remove # remove hello-world plugin
#
# After 'install', open https://localhost:9090 and look for "Hello Test"
# in the sidebar. Hard-refresh with Ctrl+Shift+R if needed.
set -eu
ACTION="${1:-install}"
PLUGIN_DIR=/usr/share/cockpit/hellotest
case "$ACTION" in
install)
echo ">>> Installing hello-world Cockpit plugin to $PLUGIN_DIR"
mkdir -p "$PLUGIN_DIR"
# Manifest matches the cockpit-podman pattern exactly — this is
# the smallest manifest that real, working Cockpit plugins use.
# NOTE: requires.cockpit MUST be a bare number ("239"), NOT ">=239".
# 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 — the plugin never appears in the sidebar.
# This was the v0.0.9-v0.0.21 root cause of "zero entries".
cat > "$PLUGIN_DIR/manifest.json" <<'JSON'
{
"version": 0,
"name": "hellotest",
"requires": { "cockpit": "239" },
"menu": {
"index": {
"label": "Hello Test",
"order": 99
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline'"
}
JSON
# Minimal index.html — just enough to confirm the page loads.
cat > "$PLUGIN_DIR/index.html" <<'HTML'
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Hello Test</title></head>
<body style="font-family: sans-serif; padding: 2rem;">
<h1>Hello from Cockpit!</h1>
<p>If you can read this in the Cockpit web UI, Cockpit's plugin
discovery is working correctly. The issue with sysdeck is
therefore sysdeck's manifest, not Cockpit itself.</p>
<p>Timestamp: <span id="ts"></span></p>
<script>document.getElementById('ts').textContent = new Date().toISOString();</script>
</body>
</html>
HTML
chmod a+rx "$PLUGIN_DIR"
chmod a+r "$PLUGIN_DIR/manifest.json" "$PLUGIN_DIR/index.html"
echo ">>> Installed. Restarting cockpit.socket..."
systemctl restart cockpit.socket 2>/dev/null || true
echo
echo ">>> DONE. Now open https://localhost:9090 and look for"
echo " 'Hello Test' in the left sidebar."
echo
echo " If you see it → Cockpit discovery works; the issue is sysdeck."
echo " If you DON'T see it → the issue is Cockpit itself, not sysdeck."
echo " Run sysdeck-diagnose.sh and share the output."
echo
echo " Hard-refresh the browser with Ctrl+Shift+R if needed."
echo
echo " When done, remove with: sudo bash cockpit-smoke-test.sh remove"
;;
remove)
echo ">>> Removing hello-world Cockpit plugin"
rm -rf "$PLUGIN_DIR"
systemctl restart cockpit.socket 2>/dev/null || true
echo ">>> Removed. Hard-refresh the browser (Ctrl+Shift+R) to update the sidebar."
;;
*)
echo "Usage: sudo bash $0 {install|remove}"
exit 1
;;
esac

421
compat/compat-manifest.json Executable file
View File

@ -0,0 +1,421 @@
{
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
"version": "0.2.0",
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
"modules": {
"containers": {
"_comment_v0.0.35": "Containers panel now manages Podman only. Kata Containers was split out per v0.0.35 directive: 'kata containers should be called SysDeck Kata and moved out of the tools area.' The standalone sysdeck-kata plugin hosts the pre-built cockpit-kata React app.",
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/podman" }],
"config": {
"arch": { "dep_package": "podman", "install_cmd": "pacman -S --noconfirm podman" },
"debian": { "dep_package": "podman", "install_cmd": "apt install -y podman" },
"fedora": { "dep_package": "podman", "install_cmd": "dnf install -y podman" }
},
"fallback": { "message": "Podman is required for the Containers panel. Kata Containers is now its own sidebar entry — SysDeck Kata." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"kata": {
"_comment_v0.0.35": "SysDeck Kata — restored to its own sidebar entry per user directive. Hosts the pre-built cockpit-kata React app (index.js + index.css). Requires cockpit ≥ 286 because the React bundle uses newer cockpit-podman base1 APIs. The standalone cockpit-kata sub-project is consolidated into SysDeck.",
"requires": { "cockpit": ">=286" },
"conditions": [{ "path-or-exists": "/usr/bin/kata-runtime" }, { "path-or-exists": "/usr/bin/kata-containerd-shim-v2" }],
"config": {
"arch": { "dep_package": "kata-containers", "install_cmd": "pacman -S --noconfirm kata-containers" },
"debian": { "dep_package": "kata-containers", "install_cmd": "apt install -y kata-containers" },
"fedora": { "dep_package": "kata-containers", "install_cmd": "dnf install -y kata-containers" }
},
"fallback": { "message": "Kata Containers provides hardware-virtualized OCI sandboxes. Install kata-containers to manage Kata sandboxes & VMs from this panel." },
"min_cockpit": 286,
"tested_cockpit_versions": [285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"firewall": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/sbin/nft" }],
"config": {
"arch": { "dep_package": "nftables", "install_cmd": "pacman -S --noconfirm nftables" },
"debian": { "dep_package": "nftables", "install_cmd": "apt install -y nftables" },
"fedora": { "dep_package": "nftables", "install_cmd": "dnf install -y nftables" }
},
"fallback": { "message": "nftables is not installed.", "install_docs": "https://wiki.nftables.org/wiki-nftables/index.php/Main_Page" },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"integrity": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/sbin/lynis" }],
"config": {
"arch": { "dep_package": "lynis", "install_cmd": "pacman -S --noconfirm lynis" },
"debian": { "dep_package": "lynis", "install_cmd": "apt install -y lynis" },
"fedora": { "dep_package": "lynis", "install_cmd": "dnf install -y lynis" }
},
"fallback": { "message": "Lynis is not installed.", "install_docs": "https://cisofy.com/lynis/" },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"netsec": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/sbin/ss" }],
"config": {
"arch": { "dep_package": "iproute2", "install_cmd": "pacman -S --noconfirm iproute2" },
"debian": { "dep_package": "iproute2", "install_cmd": "apt install -y iproute2" },
"fedora": { "dep_package": "iproute2", "install_cmd": "dnf install -y iproute2" }
},
"fallback": { "message": "iproute2 (ss command) is required.", "install_docs": "https://wiki.linuxfoundation.org/networking/iproute2" },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"mesh": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/kubectl" }],
"config": {
"arch": { "dep_package": "kubectl", "install_cmd": "pacman -S --noconfirm kubectl" },
"debian": { "dep_package": "kubectl", "install_cmd": "apt install -y kubectl" },
"fedora": { "dep_package": "kubectl", "install_cmd": "dnf install -y kubectl" }
},
"fallback": { "message": "kubectl is not installed. Required for service mesh topology.", "install_docs": "https://kubernetes.io/docs/tasks/tools/" },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"vault": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/sbin/cryptsetup" }],
"config": {
"arch": { "dep_package": "cryptsetup", "install_cmd": "pacman -S --noconfirm cryptsetup" },
"debian": { "dep_package": "cryptsetup", "install_cmd": "apt install -y cryptsetup" },
"fedora": { "dep_package": "cryptsetup", "install_cmd": "dnf install -y cryptsetup" }
},
"fallback": { "message": "cryptsetup is required for LUKS volume management.", "install_docs": "https://gitlab.com/cryptsetup/cryptsetup" },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"fleet": {
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": { "dep_package": null, "note": "Uses /etc/cockpit/machines.d/ and standard system tools" },
"debian": { "dep_package": null },
"fedora": { "dep_package": null }
},
"fallback": { "message": "Fleet module uses standard system tools — no extra deps needed." },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"fester": {
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": { "dep_package": null, "note": "SysDeck Fester — build orchestration, uses system tools" },
"debian": { "dep_package": null, "note": "SysDeck Fester — build orchestration, uses system tools" },
"fedora": { "dep_package": null, "note": "SysDeck Fester — build orchestration, uses system tools" }
},
"fallback": { "message": "SysDeck Fester build orchestration uses system tools." },
"min_cockpit": 239,
"tested_cockpit_versions": [264],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"firmware": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/fwupdmgr" }],
"config": {
"arch": { "dep_package": "fwupd", "install_cmd": "pacman -S --noconfirm fwupd" },
"debian": { "dep_package": "fwupd", "install_cmd": "apt install -y fwupd" },
"fedora": { "dep_package": "fwupd", "install_cmd": "dnf install -y fwupd" }
},
"fallback": { "message": "fwupd is not installed.", "install_docs": "https://fwupd.org/" },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"builder": {
"requires": { "cockpit": ">=239" },
"conditions": [
{ "path-exists": "/usr/bin/mkosi" },
{ "path-exists": "/usr/bin/mkarchiso" },
{ "path-exists": "/usr/bin/vmdb2" },
{ "path-exists": "/usr/bin/lb" }
],
"config": {
"arch": {
"dep_package": "mkosi",
"install_cmd": "pacman -S --noconfirm mkosi",
"iso_dep_package": "archiso",
"iso_install_cmd": "pacman -S --noconfirm archiso",
"note": "mkosi is the primary image builder; archiso for bootable Live ISOs"
},
"debian": {
"dep_package": "vmdb2",
"install_cmd": "apt install -y vmdb2",
"iso_dep_package": "live-build",
"iso_install_cmd": "apt install -y live-build",
"note": "vmdb2 is the Debian project's image builder; live-build for Live ISOs"
},
"fedora": {
"dep_package": "mkosi",
"install_cmd": "dnf install -y mkosi",
"iso_dep_package": "live-build",
"iso_install_cmd": "dnf install -y live-build",
"note": "v0.0.30+: osbuild-composer dropped — mkosi/vmdb2 are cross-distro; Fedora can use mkosi"
}
},
"fallback": {
"message": "No image-builder backend installed. Install mkosi (Arch) or vmdb2 (Debian).",
"install_docs": "https://github.com/systemd/mkosi / https://gitlab.com/lvm-team/vmdb2"
},
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" },
"_comment_v0.0.48": "Builder panel v0.0.48 fixes a profile-create bug that surfaced on archiso-only or live-build-only hosts. The v0.0.31 Create Profile dropdown fell back to `primary.id` when no mkosi/vmdb2 backend was installed, funneling operators into the 'profile-create supports (mkosi, vmdb2)' error. v0.0.48 (a) gates the Create Profile form on a scaffoldable backend being installed and shows an inline install hint otherwise, and (b) adds a new 'Copy shipped profile' form backed by the new bridge.builder.profileCopy(srcName, newName, backend) method — copies /usr/share/archiso/configs/<src>/ → /etc/archiso/configs/<new>/ (and the live-build equivalent). Same polkit action (org.sysdeck.builder.modify). 15 new unit tests in tests/test_bridge_parsers.py TestBuilderProfileCopy.",
"_comment_v0.0.49": "Builder panel v0.0.49 adds an inline package-list field to both the Create Profile and Copy shipped profile forms. 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 field has a textarea (inline paste), a file upload (applist.txt via FileReader), and a merge-mode toggle (append | replace). All 4 backends supported: mkosi writes [Packages] section, vmdb2 writes bootstrap.include list, archiso writes packages.x86_64, live-build writes config/package-lists/sysdeck.list. Default mode is replace for Create, append for Copy. New bridge helpers: _extract_opts, _parse_packages_text, _write_packages_{mkosi,vmdb2,archiso,live_build}, _write_packages dispatcher. profile_create and profile_copy extended with --packages=<json> + --mode=append|replace. 28 new unit tests in TestBuilderPackagesField.",
"_comment_v0.0.50": "Builder bridge v0.0.50 fixes a NameError that blocked every build() invocation since v0.0.31. _new_build_id() called re.sub() but `import re` was missing from bridge/builder.py's module-level imports. The bug went undetected for 18 releases because no unit test exercised the build() code path — tests only covered profile_create/profile_copy/profile_delete and the v0.0.49 package-writing helpers. Fix: added `import re` to module-level imports. 9 new unit tests in TestBuilderBuildPath cover _new_build_id (format, sanitization, safe-char preservation, explicit re-in-globals regression check) and build() end-to-end with mocked subprocess.run (success path, unknown profile, no args, backend-not-installed, non-zero returncode).",
"_comment_v0.1.0": "Builder bridge v0.1.0 fixes three compounding bugs in the mkosi build path and adds a new operator feature. BUG 1: profile-create now writes /etc/mkosi/profiles/<name>/mkosi.conf (per-profile directory with a real mkosi.conf filename) instead of /etc/mkosi/mkosi.conf.d/<name>.conf (drop-in fragment that mkosi silently ignored without a parent mkosi.conf). BUG 2: _MKOSI_TEMPLATE and _write_packages_mkosi now use the modern single-line Packages=a b c syntax (mkosi v22+ format) instead of the legacy indented continuation form. Reader accepts both forms for migration. BUG 3: _MKOSI_TEMPLATE now sets OutputDirectory=/var/lib/sysdeck/builder/artifacts/<name> so mkosi writes directly to the artifacts dir that build() scans. NEW FEATURE: profile-import-packages subcommand queries the host's explicitly-installed packages (pacman -Qqe / apt-mark showmanual / dnf repoquery --userinstalled) and writes them into a profile via _write_packages. Supports --mode=append|replace (default append), --dry-run for preview, --packages=<json> for manual override. Panel adds 'Import host pkgs' button per profile row with two-step dry-run + confirm flow. New polkit exec paths for pacman/apt-mark/dnf added to org.sysdeck.builder.modify. 7 new unit tests in TestBuilderImportHostPackages; 4 existing TestBuilderPackagesField tests updated for new Packages= syntax.",
"_comment_v0.1.1": "Builder bridge v0.1.1 fixes a critical output-path safety bug. v0.1.0 relied on OutputDirectory= in the scaffolded mkosi.conf to route build outputs to /var/lib/sysdeck/builder/artifacts/<name>/. But when building an OLD v0.0.x profile (whose mkosi.conf had no OutputDirectory= setting), mkosi defaulted to writing image.raw into the cwd (/etc/mkosi/mkosi.conf.d/) — a system config directory owned by root. mkosi then refused to overwrite the existing image.raw, blocking every rebuild. FIX: _backend_build_command() now ALWAYS passes --output, --output-dir, and --force on the CLI for mkosi builds. CLI flags override mkosi.conf so the output path is forced to /var/lib/sysdeck/builder/artifacts/<name>/<name>.raw regardless of what the profile says. SAFETY CHECK: build() refuses to proceed if the resolved output_dir is not under /var/lib/, /tmp/, /var/tmp/, or the configured BUILDER_ARTIFACTS_DIR — blocks /etc/, /usr/, /boot/, /bin/, /sbin/, /lib/, /root/, /home/, etc. LEGACY PROFILE WARNING: build() detects profiles in /etc/mkosi/mkosi.conf.d/ (v0.0.x drop-in layout) and records a warning in both the build state JSON and the log file. LOG IMPROVEMENT: build log header now includes the resolved output_dir. 2 new unit tests in TestBuilderBuildPath cover the safety check and legacy-profile warning; existing test_build_success_path extended to verify --output/--output-dir/--force on the mkosi command line.",
"_comment_v0.1.2": "Builder bridge v0.1.2 fixes the critical 'zero packages' bug. Operator reported: 'the builder absolutely does not work yet. it has zero awareness of packages we tell it to add.' TWO compounding root causes. CAUSE 1: _backend_build_command() for mkosi had no flag telling mkosi WHERE the profile config file is — mkosi only reads a file literally named mkosi.conf from the cwd, so profiles at /etc/mkosi/mkosi.conf.d/<name>.conf were silently ignored and mkosi used EMPTY defaults (zero packages). FIX 1: _backend_build_command() now ALWAYS passes --include <profile_path> on the CLI so mkosi explicitly loads the profile config by path regardless of filename or location. CAUSE 2: profiles created by v0.0.x used the old indented Packages= syntax (Packages=\\n linux\\n...) which mkosi v22+ silently parses as a single package name with embedded newlines — installs NOTHING. FIX 2: new _migrate_legacy_mkosi_packages() function detects old indented syntax and rewrites to single-line IN-PLACE before build. build() calls this automatically on every mkosi build. Migration logged in build state JSON (warnings array) and log header (# MIGRATED: ...). No-op on modern syntax. 4 new unit tests in TestBuilderBuildPath cover migration (old rewrite, modern no-op, no-section no-op, end-to-end during build). Existing test_build_success_path extended to verify --include on command line. Total 243 tests (was 239; +4).",
"_comment_v0.1.3": "Builder bridge v0.1.3 fixes TWO more critical bugs v0.1.2 missed + adds download/manage UI. IMPORT BUG: _detect_host_packages() relied on `from __init__ import PKG_MANAGER` which silently failed in the cockpit superuser channel context (different Python path). PKG_MANAGER defaulted to 'unknown', host query returned EMPTY list, import wrote nothing. Operator saw 'tries to build only 2'. FIX: now uses shutil.which() to find pacman/apt-mark/dnf directly — no import dependency. BUILD BUG: v0.1.2's --include flag does NOT replace the base config. mkosi's --include includes a drop-in fragment ON TOP OF the base mkosi.conf — if no mkosi.conf in cwd, mkosi uses defaults and ignores --include file entirely. FIX: build() now creates a temp directory, symlinks the profile file as `mkosi.conf`, sets work_dir to that temp dir. mkosi finds the symlink, follows it, reads the real profile. Works for ANY profile path. Temp dir cleaned up after build. New: _prepare_mkosi_work_dir(). NEW FEATURES: artifact download (cockpit.spawn cat + Blob + browser download), artifact-delete (per-file), artifacts-clear (per-profile, reports files+bytes freed), build-delete (state+log, optionally --artifacts). Panel: each artifact has Download + Delete buttons, each profile has Clear all button, each build has Delete button with two-step confirm. 11 new unit tests in TestBuilderArtifactManagement (7) + TestBuilderMkosiTempWorkDir (3). Total 254 tests (was 243; +11)."
},
"mining": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/xmrig" }],
"config": {
"arch": { "dep_package": "xmrig", "install_cmd": "pacman -S --noconfirm xmrig" },
"debian": { "dep_package": null, "note": "XMRig requires manual install on Debian" },
"fedora": { "dep_package": null, "note": "XMRig requires manual install on Fedora" }
},
"fallback": { "message": "XMRig is not installed.", "install_docs": "https://xmrig.com/docs/miner/build" },
"min_cockpit": 239,
"tested_cockpit_versions": [264],
"distro_support": { "arch": "full", "debian": "partial", "fedora": "partial" }
},
"themes": {
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": { "dep_package": null },
"debian": { "dep_package": null },
"fedora": { "dep_package": null }
},
"fallback": { "message": "Themes module uses Cockpit's built-in configuration." },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"auth": {
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": { "dep_package": "opensc pcsclite", "install_cmd": "pacman -S --noconfirm opensc pcsclite" },
"debian": { "dep_package": "opensc pcscd", "install_cmd": "apt install -y opensc pcscd" },
"fedora": { "dep_package": "opensc pcsc-lite", "install_cmd": "dnf install -y opensc pcsc-lite" }
},
"fallback": { "message": "OpenSC/pcscd recommended for PKCS#11 smartcard support." },
"min_cockpit": 239,
"tested_cockpit_versions": [239, 264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"glances": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/glances" }],
"config": {
"arch": { "dep_package": "glances", "install_cmd": "pacman -S --noconfirm glances" },
"debian": { "dep_package": "glances", "install_cmd": "apt install -y glances" },
"fedora": { "dep_package": "glances", "install_cmd": "dnf install -y glances" }
},
"fallback": { "message": "Glances is not installed.", "install_docs": "https://glances.readthedocs.io/en/latest/install.html" },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"sensors": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/sensors" }],
"config": {
"arch": { "dep_package": "lm_sensors", "install_cmd": "pacman -S --noconfirm lm_sensors" },
"debian": { "dep_package": "lm-sensors", "install_cmd": "apt install -y lm-sensors" },
"fedora": { "dep_package": "lm_sensors", "install_cmd": "dnf install -y lm_sensors" }
},
"fallback": { "message": "lm-sensors is not installed.", "install_docs": "https://hwmon.wiki.kernel.org/lm_sensors" },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"benchmark": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/sysbench" }],
"config": {
"arch": { "dep_package": "sysbench", "install_cmd": "pacman -S --noconfirm sysbench" },
"debian": { "dep_package": "sysbench", "install_cmd": "apt install -y sysbench" },
"fedora": { "dep_package": "sysbench", "install_cmd": "dnf install -y sysbench" }
},
"fallback": { "message": "sysbench is not installed.", "install_docs": "https://github.com/akopytov/sysbench#readme" },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"packages": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/pacman" }, { "path-or-exists": "/usr/bin/dnf" }, { "path-or-exists": "/usr/bin/apt" }],
"config": {
"arch": { "dep_package": "pacman", "install_cmd": "echo 'pacman always available on Arch'" },
"debian": { "dep_package": "apt", "install_cmd": "echo 'apt always available on Debian'" },
"fedora": { "dep_package": "dnf", "install_cmd": "echo 'dnf always available on Fedora'" }
},
"fallback": { "message": "No supported package manager found (pacman/dnf/apt)." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"policy": {
"_comment_v0.0.33": "Policy & Permissions module — modern Linux policy management and permissions manager for groups. v0.0.32 introduced the module; v0.0.33 expanded it to cover the full modern LSM stack (AppArmor, Smack, TOMOYO, Yama, LoadPin, Lockdown, BPF-LSM, Landlock) plus file capabilities. SELinux skipped (native).",
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": {
"dep_package": "acl iproute2 bpftool util-linux libcap",
"install_cmd": "pacman -S --noconfirm acl iproute2 bpftool util-linux libcap",
"optional_dep_package": "apparmor apparmor-utils smack-util tomoyo-tools",
"optional_install_cmd": "pacman -S --noconfirm apparmor apparmor-utils",
"note": "LSMs are optional — kernel compiled-in + userspace tools. SELinux skipped (native). Smack/TOMOYO userspace is not packaged on Arch; the kernel-side is enabled via the lsm= kernel cmdline."
},
"debian": {
"dep_package": "acl iproute2 linux-tools-common util-linux libcap2-bin",
"install_cmd": "apt install -y acl iproute2 linux-tools-common util-linux libcap2-bin",
"optional_dep_package": "apparmor apparmor-utils tomoyo-tools",
"optional_install_cmd": "apt install -y apparmor apparmor-utils",
"note": "LSMs are optional — kernel compiled-in + userspace tools. SELinux skipped (native). Smack userspace is not packaged on Debian; the kernel-side is enabled via the lsm= kernel cmdline."
},
"fedora": {
"dep_package": "acl iproute2 bpftool util-linux libcap",
"install_cmd": "dnf install -y acl iproute2 bpftool util-linux libcap",
"optional_dep_package": "apparmor apparmor-utils",
"optional_install_cmd": "dnf install -y apparmor apparmor-utils",
"note": "LSMs are optional — kernel compiled-in + userspace tools. SELinux skipped (native). Smack/TOMOYO userspace is not packaged on Fedora; the kernel-side is enabled via the lsm= kernel cmdline."
}
},
"fallback": { "message": "Policy module degrades gracefully when individual binaries are absent. Install what you need (acl, iproute2, bpftool, util-linux, libcap, optionally apparmor / apparmor-utils)." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"db": {
"_comment_v0.0.32": "DB Control module — unified control for SQL/NoSQL/vector/AI database engines. The cockpit-way pattern: the bridge runs systemctl directly via subprocess; the JS panel passes { superuser: 'try' } so the operator authenticates via polkit (org.sysdeck.db.modify).",
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": {
"dep_package": null,
"note": "DB Control auto-detects 32+ engines (postgresql, mysql, mongodb, redis, influxdb, neo4j, clickhouse, milvus, qdrant, duckdb, etc.). Install only the engines you use."
},
"debian": { "dep_package": null, "note": "Same auto-detection on Debian." },
"fedora": { "dep_package": null, "note": "Same auto-detection on Fedora." }
},
"fallback": { "message": "No database engines detected on this host." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"jellyfin": {
"_comment_v0.0.35": "Jellyfin media server management. Per user directive: 'next we will integrate a jellyfin management module where it starts, stops, and loads the admin panel in the module.' The bridge runs systemctl start/stop/restart jellyfin.service; the panel iframes the running admin UI at http://127.0.0.1:8096 — same pattern as the v0.0.34 Glances integration.",
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-or-exists": "/usr/bin/jellyfin" }, { "path-or-exists": "/usr/lib/sysdeck/bridge/jellyfin.py" }],
"config": {
"arch": { "dep_package": "jellyfin", "install_cmd": "pacman -S --noconfirm jellyfin" },
"debian": { "dep_package": "jellyfin", "install_cmd": "apt install -y jellyfin" },
"fedora": { "dep_package": "jellyfin", "install_cmd": "dnf install -y jellyfin" }
},
"fallback": { "message": "Jellyfin media server is not installed.", "install_docs": "https://jellyfin.org/downloads/" },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"photos": {
"_comment_v0.0.35": "Photo Manager module — multi-backend (PhotoPrism, Piwigo, Lychee, Nextcloud-Memories, LibrePhotos). Per user directive: 'as well as a photo manager of equal quality. with its own module.' Same shape as the Jellyfin module: bridge runs systemctl start/stop/restart; panel iframes the running admin UI.",
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": {
"dep_package": null,
"note": "Photos module auto-detects PhotoPrism, Piwigo, Lychee, Nextcloud-Memories, LibrePhotos. Install only the backend you use."
},
"debian": { "dep_package": null, "note": "Same auto-detection on Debian." },
"fedora": { "dep_package": null, "note": "Same auto-detection on Fedora." }
},
"fallback": { "message": "No photo management backends detected. Install one of: PhotoPrism, Piwigo, Lychee, Nextcloud-Memories, LibrePhotos." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"remotefs": {
"_comment_v0.0.35": "Remote FS module — distributed filesystem management. Per user directive: 'then a remote fs manager such as ceph, and others but not nfs or amanada fs.' Backends shipped: Ceph, GlusterFS, MooseFS, BeeGFS, OrangeFS. NFS and Amanda explicitly EXCLUDED per directive (NFS is kernel-builtin; Amanda is a backup system, not a remote FS).",
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": {
"dep_package": null,
"note": "Remote FS module auto-detects Ceph, GlusterFS, MooseFS, BeeGFS, OrangeFS. Install only the backend you use."
},
"debian": { "dep_package": null, "note": "Same auto-detection on Debian." },
"fedora": { "dep_package": null, "note": "Same auto-detection on Fedora." }
},
"fallback": { "message": "No remote/distributed filesystem backends detected. Install one of: ceph, glusterfs, moosefs, beegfs, orangefs. NFS and Amanda are intentionally excluded per directive." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
},
"services": {
"_comment_v0.0.47": "Service / Port Editor module — promoted from a card at the bottom of the Firewall panel (v0.0.44) to its own sidebar entry at order 45 per user directive: 'we should move the service/ports editor to its own module entry for ease of access.' The bridge surface (bridge.services.{list,info,setPort,restart}) is a thin proxy over bridge.firewall.{services,service-info,set-service-port,restart-service}; the SERVICES_REGISTRY + atomic-write + systemctl restart logic remains in bridge/firewall.py as the single source of truth. No new bridge helper file was needed.",
"requires": { "cockpit": ">=239" },
"conditions": [],
"config": {
"arch": { "dep_package": "iproute2", "install_cmd": "pacman -S --noconfirm iproute2", "note": "Uses ss -tlnp for listening-socket enumeration; falls back to /proc/net/tcp if unavailable." },
"debian": { "dep_package": "iproute2", "install_cmd": "apt install -y iproute2", "note": "Uses ss -tlnp; falls back to /proc/net/tcp." },
"fedora": { "dep_package": "iproute2", "install_cmd": "dnf install -y iproute2", "note": "Uses ss -tlnp; falls back to /proc/net/tcp." }
},
"fallback": { "message": "Service / Port Editor degrades gracefully — if ss is unavailable it falls back to /proc/net/tcp. The SERVICES_REGISTRY covers ssh, cockpit, caddy, varnish, mariadb, ollama, openwebui, hermes, odysseus." },
"min_cockpit": 239,
"tested_cockpit_versions": [264, 285, 300],
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
}
},
"standalone_plugins": {
"cockpit-ostree": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/rpm-ostree" }],
"config": {
"fedora": { "dep_package": "cockpit-ostree", "install_cmd": "dnf install -y cockpit-ostree" },
"arch": { "dep_package": null, "note": "rpm-ostree not available on Arch" }
},
"fallback": { "message": "rpm-ostree is only available on Fedora Silverblue / Kinoite." },
"distro_support": { "fedora": "full", "arch": "none", "debian": "none" }
},
"cockpit-machines": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/virsh" }],
"config": {
"arch": { "dep_package": "cockpit-machines", "install_cmd": "pacman -S --noconfirm cockpit-machines" },
"fedora": { "dep_package": "cockpit-machines", "install_cmd": "dnf install -y cockpit-machines" },
"debian": { "dep_package": "cockpit-machines", "install_cmd": "apt install -y cockpit-machines" }
},
"fallback": { "message": "libvirt is required for virtual machine management." },
"distro_support": { "arch": "full", "fedora": "full", "debian": "full" }
},
"cockpit-incus": {
"requires": { "cockpit": ">=239" },
"conditions": [{ "path-exists": "/usr/bin/incus" }],
"config": {
"arch": { "dep_package": "incus", "install_cmd": "pacman -S --noconfirm incus" },
"fedora": { "dep_package": "incus", "install_cmd": "dnf install -y incus" },
"debian": { "dep_package": "incus", "install_cmd": "apt install -y incus" }
},
"fallback": { "message": "Incus is required for system container and VM management.", "install_docs": "https://linuxcontainers.org/incus/docs/main/installing/" },
"distro_support": { "arch": "full", "fedora": "full", "debian": "full" }
}
}
}

190
docs/INSTALL.md Executable file
View File

@ -0,0 +1,190 @@
# Installation Guide
SysDeck can be installed four ways. Pick the one that matches your distribution and operational model.
Author: **Jeremy Anderson** · <https://dcos.net>
---
## Prerequisites
All install paths require:
- **Cockpit** ≥ 239 (`cockpit-bridge --version` to verify)
- **Python** ≥ 3.9 (`python3 --version`)
- Root or sudo access for system-wide install
Recommended backend tools (the plugin fails closed when any are absent, but the dashboard is more useful with all present):
| Tool | Module(s) | Install (Fedora/RHEL) |
|------|-----------|------------------------|
| `podman` | Containers | `dnf install podman` |
| `nftables` | Firewall | `dnf install nftables` |
| `lynis` | Integrity | `dnf install lynis` (EPEL) |
| `iproute2` | Netsec | `dnf install iproute` |
| `kubectl` | Mesh | See Kubernetes docs |
| `util-linux` | Vault | `dnf install util-linux` |
| `kata-runtime` | Kata | `dnf install kata-runtime` |
| `fwupd` | Firmware | `dnf install fwupd` |
| `tpm2-tools` | Firmware | `dnf install tpm2-tools` |
| `mkosi` (Arch) / `vmdb2` (Debian) | Builder | Arch: `pacman -S mkosi` · Debian: `apt install vmdb2` · Fedora: `dnf install mkosi` |
| `opensc` | Auth | `dnf install opensc` |
| `pcsc-lite` | Auth | `dnf install pcsc-lite` |
| `prometheus` | Prometheus | `dnf install prometheus` |
| `grafana` | Grafana | See Grafana docs |
| `jellyfin` | Jellyfin | `dnf install jellyfin` · Arch: `pacman -S jellyfin` · Debian: `apt install jellyfin` |
| `photoprism` (or `piwigo` / `lychee` / `librephotos`) | Photos | `yay -S photoprism` · Debian: see PhotoPrism docs |
| `ceph` (or `glusterfs` / `moosefs` / `beegfs` / `orangefs`) | Remote FS | `dnf install ceph` · Arch: `pacman -S ceph` · Debian: `apt install ceph` |
---
## Option A — Make (manual install)
Best for operators who want a single-command install from source.
```bash
tar xjf sysdeck-0.0.35.tar.bz2
cd sysdeck-0.0.35
sudo make install
sudo systemctl restart cockpit.socket
```
**What it does:**
- Copies `manifest.json`, `index.html`, `suite.js`, `suite.css`, `logo.svg` to `/usr/share/cockpit/sysdeck/`.
- Copies `src/*.js` and `src/modules/*.js` to `/usr/share/cockpit/sysdeck/src/` (runtime dynamic imports).
- Copies `bridge/*.py` and `bridge/modules/*.py` to `/usr/lib/sysdeck/bridge/`.
- Copies `README.md` and `LICENSE` to the plugin root.
**Uninstall:**
```bash
sudo make uninstall
sudo systemctl restart cockpit.socket
```
---
## Option B — RPM (Fedora / RHEL / CentOS)
Best for production deployments that want package-manager lifecycle.
```bash
# Build the RPM from the tarball
rpmbuild -bb packaging/sysdeck.spec \
-D "_sourcedir $PWD"
# Install
sudo dnf install ~/rpmbuild/RPMS/noarch/sysdeck-0.0.35-1.*.noarch.rpm
sudo systemctl restart cockpit.socket
```
**What the RPM does:**
- Installs the plugin under `/usr/share/cockpit/sysdeck/`.
- Installs the Python bridge under `/usr/lib/sysdeck/bridge/`.
- `Recommends:` the backend tools so dnf suggests them on install.
- `%post` and `%postun` scriptlets restart `cockpit.socket` automatically.
**Uninstall:**
```bash
sudo dnf remove sysdeck
```
---
## Option C — pip
Best for Python-shop environments that prefer pip over RPM.
```bash
tar xjf sysdeck-0.0.35.tar.bz2
cd sysdeck-0.0.35
sudo pip3 install packaging/
sudo systemctl restart cockpit.socket
```
**What it does:**
- `setup.py` declares `data_files` for the cockpit plugin root and the Python bridge location.
- Pip lays them out under the system paths (`/usr/share/cockpit/...` and `/usr/lib/...`).
**Uninstall:**
```bash
sudo pip3 uninstall sysdeck
sudo rm -rf /usr/share/cockpit/sysdeck
sudo systemctl restart cockpit.socket
```
Note: pip's `data_files` are not tracked for uninstall on all platforms. The `rm -rf` above is the safe path.
---
## Option D — staged overlay (for image builds)
Best for building container images or kickstart-installed systems where you want to stage files into a directory and then copy them into the image.
```bash
tar xjf sysdeck-0.0.35.tar.bz2
cd sysdeck-0.0.35
make install DESTDIR=/tmp/overlay
# /tmp/overlay now contains:
# /tmp/overlay/usr/share/cockpit/sysdeck/
# /tmp/overlay/usr/lib/sysdeck/bridge/
```
Copy `/tmp/overlay/usr/*` into your image's `/usr/` and the plugin is ready.
---
## Verifying the install
After any install path, verify:
```bash
# 1. Manifest is in place
ls /usr/share/cockpit/sysdeck/manifest.json
# 2. Manifest is valid JSON
python3 -m json.tool /usr/share/cockpit/sysdeck/manifest.json
# 3. Entry HTML is in place
ls /usr/share/cockpit/sysdeck/index.html
# 4. Bridge helpers are in place
ls /usr/lib/sysdeck/bridge/
# 5. Cockpit socket is running
systemctl status cockpit.socket
```
Then open `https://<host>:9090` and look for the **SysDeck** menu entry.
---
## Troubleshooting
### Menu entry does not appear
1. Confirm `manifest.json` is valid JSON.
2. Confirm the `content` key has a `suite` entry pointing to `/index.html`.
3. Restart `cockpit.socket`: `sudo systemctl restart cockpit.socket`.
4. Check the journal: `journalctl -u cockpit -f --since "5 min ago"`.
### Panel shows "X unavailable"
Each panel calls a backend tool via `cockpit.spawn`. If the tool is absent, the panel shows an install hint. Install the missing tool (see the prerequisites table above) and click **Refresh** in the header.
### Python bridge helpers not found
The JS bridge client calls `python3 -m sysdeck.bridge.<module>`. Confirm:
1. `python3` is in the cockpit service's PATH (usually `/usr/bin/python3`).
2. The bridge package is installed at `/usr/lib/sysdeck/bridge/__init__.py`.
3. The `PYTHONPATH` includes `/usr/lib/sysdeck` (the RPM and Makefile set this; pip install does not — add a `/etc/cockpit/cockpit.conf` entry or symlink if needed).
### Content Security Policy violations
The manifest declares `content-security-policy: default-src 'self' 'unsafe-inline' 'unsafe-eval'`. If your cockpit deployment enforces a stricter policy, tighten the manifest to match. The plugin does not require `'unsafe-eval'` if you remove the dynamic `import()` calls and bundle all modules into `suite.js`.
---
## Optional: standalone Next.js dashboard
For hosts without cockpit, the Next.js dashboard variant is preserved under `nextjs-dashboard/`. See [`nextjs-dashboard/QUICKSTART.md`](../nextjs-dashboard/QUICKSTART.md) for its setup. The Next.js variant uses mock data; the cockpit plugin uses real backend calls.

333
docs/SECURITY-HARDENING.md Executable file
View File

@ -0,0 +1,333 @@
# SysDeck Security Hardening — Lessons from Webmin, Cockpit & Admin-Panel CVEs
This document records the security lessons SysDeck applied in v0.0.36 (and forward)
after reviewing disclosed vulnerabilities in Webmin, Cockpit, Ajenti, ISPConfig,
and Virtualmin. Each lesson maps to a concrete code change.
Author: Jeremy Anderson · <info@dcos.net> · <https://dcos.net>
Version: 0.0.36 · License: MIT
---
## 1. CVEs reviewed
The following disclosed vulnerabilities directly shaped the hardening checklist
in §2. CVE IDs are cross-checked against NVD and the upstream advisory pages.
| CVE | Year | Product | Vector | Root cause | Lesson applied |
|-----|------|---------|--------|------------|----------------|
| CVE-2019-15107 | 2019 | Webmin ≤1.920 | Network, unauthenticated | OS command injection (CWE-78) in `password_change.cgi` via the `old` POST parameter; also the umbrella CVE for the 2019 supply-chain backdoor | Strict allowlist validation on every user-supplied string before it enters argv; bridge never interpolates user input into a shell string |
| 2019 Webmin backdoor | 2019 | Webmin 1.890–1.920 (SourceForge builds only) | Network, unauthenticated | Supply-chain compromise of the build host; attacker injected Perl `qx` into `password_change.cgi` and rolled back the file mtime so `git status` showed nothing; poisoned build dir was restored from backup into the replacement build server | `make check` runs `git status --porcelain` as a release gate; build-from-clean-checkout is documented in `docs/INSTALL.md`; release tarball is reproducible (pinned `LC_ALL=C`, `SOURCE_DATE_EPOCH`) |
| CVE-2019-12840 | 2019 | Webmin ≤1.910, Package Updates | Authenticated | OS command injection via `data` parameter to `package-updates.cgi` run as root | Package-install verbs in `bridge/packages.py` accept only a strict package-name allowlist `^[A-Za-z0-9._+-]+$`; reject on first mismatch |
| CVE-2019-15642 | 2019 | Webmin ≤1.920, `rpc.cgi` | Authenticated (User-Agent trick) | Perl `eval` of crafted object name in `unserialise_variable()` — unsafe deserialization | Bridge uses `json.loads` only; never `eval`, never `pickle.loads`, never `yaml.unsafe_load`; the `User-Agent` is never inspected for auth |
| CVE-2020-35606 | 2020 | Webmin ≤1.962 | Authenticated | OS command injection via `%0A` / `%0C` that escaped the original newline-stripping fix for CVE-2019-12840 | Regression test `tests/test_bridge_parsers.py::FirewallHardeningTests` fuzzes every bridge verb that accepts a string with the full byte range 0x00–0x20 + 0x7F–0xFF + shell metacharacters |
| CVE-2022-0824 + CVE-2022-0829 | 2022 | Webmin ≤1.984, File Manager + Authentic theme | Authenticated low-priv → root | Broken access control (CWE-863) — any logged-in user could reach File Manager endpoints with root privileges regardless of UI menu visibility | Every mutating bridge verb re-checks the polkit action server-side; the JS panel's button-visibility is cosmetic only — the bridge never trusts it |
| CVE-2022-30708 | 2022 | Webmin ≤1.991 | Authenticated low-priv → root | Low-priv users could modify arbitrary files with root privileges | File-write paths are resolved with `os.path.realpath` and prefix-checked against a fixed base directory; writes use `O_NOFOLLOW | O_CREAT | O_EXCL` |
| CVE-2022-36446 | 2022 | Webmin <1.997 | Authenticated | RCE because apt output was rendered without HTML escaping | The JS panel treats **all** bridge output as untrusted — uses `textContent` / `escapeHtml()`, never `innerHTML` on bridge data |
| CVE-2024-12828 | 2024 | Webmin ≤1.995, shell autocomplete | Authenticated low-priv → root | Shell autocomplete feature ran attacker-controlled strings as root | No autocomplete feature in the bridge; the panel builds autocomplete lists from server-side static allowlists |
| CVE-2025-61541 | 2025 | Webmin ≤2.510 | Network, unauthenticated (password reset enabled) | Host header injection in password reset | SysDeck has no password-reset feature; if one is ever added, the link URL will come from a server-configured `BASE_URL`, never from the `Host` header |
| CVE-2020-35850 | 2020 | Cockpit 234 (cockpit-project) | Network, unauthenticated | SSRF — login page probed arbitrary host:port | SysDeck bridge never accepts a "target host" from the URL path or query string |
| CVE-2024-2947 | 2024 | cockpit-pcp, Cockpit ≥270 (fixed in 314) | Local, authenticated, requires UI click | Command injection via crafted sosreport name when interpolated into a shell command | **Direct hit on SysDeck's threat model.** Every filename crossing the cockpit-ws boundary (template names, sosreport names, exported configs) is validated with `^[A-Za-z0-9._-]+$` and length-capped at 64 bytes before entering argv |
| CVE-2026-4631 (GHSA-m4gv-x78h-3427) | 2026 | Cockpit >326, <360 (fixed in 360) | Network, unauthenticated | SSH argv injection — username/hostname passed to `ssh` without `--` separator or allowlist | Every bridge subprocess invocation that accepts a user-supplied positional inserts a literal `"--"` argument before it; hostnames validated with `^[A-Za-z0-9._-]{1,64}$`, usernames with `^[A-Za-z0-9._-]{1,32}$` |
| CVE-2026-4802 (GHSA-6jmq-qw8f-w3r6) | 2026 | Cockpit logs page | Authenticated | Arbitrary command execution — array-form subprocess was bypassed because a user-controlled field was embedded in one of the argv elements | Each argv element that comes from user input is independently validated against an allowlist regex; option-flag injection (`--output=/etc/shadow`) is rejected |
| CVE-2019-25066 | 2019 (reserved, published 2022) | Ajenti 2.1.31, `os` auth provider | Network, unauthenticated | Critical RCE in API auth path | SysDeck relies on cockpit-ws / PAM for all authentication; the bridge has no custom auth provider and never spawns subprocesses from an auth path |
| CVE-2023-46818 | 2023 | ISPConfig <3.2.11p1, language file editor | Authenticated admin | PHP code injection via language-file import/export | SysDeck never writes a file that the system will later execute or interpret (no `.py`, `.sh`, `.service`, `.nft` include file written into a path a daemon loads); firewall policies are static files shipped with the package, not operator-editable at runtime |
### Honesty notes
- CVE-2023-40311 (mentioned in early task scoping) is **not** a Webmin CVE — it covers
stored XSS in OpenMNS Horizon. The closest real Webmin ACL-bypass is CVE-2022-0824.
- CVE-2019-15231 was **rejected by MITRE** as a duplicate of CVE-2019-15107.
- aaPanel, CloudPanel, Froxlor: no well-attested CVE with NVD backing was found
during research. The hardening checklist applies the same defense-in-depth
pattern to those threat models regardless.
---
## 2. Hardening checklist applied in v0.0.36
### 2.1 Python bridge (`bridge/firewall.py` and all other helpers)
1. **Array-form subprocess only.** Every `subprocess.run` call uses
`shell=False` and a list argv. No `os.system`, no `shell=True`, no
string interpolation. Verified by `tests/check_bridge_subcommands.py`
and a `grep -rn 'shell=True\|os.system' bridge/` guard.
2. **`"--"` separator before user-supplied positionals.** Defeats
option-flag injection (the CVE-2026-4631 vector).
3. **Strict allowlist regex per input type:**
- IPv4: `^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$`
- IPv6: `ipaddress.ip_address()` (raises on invalid)
- Template / backend names: `^[a-zA-Z0-9_-]{1,64}$`
- Interface names: `^[a-zA-Z0-9._-]{1,15}$` (IFNAMSIZ)
- Filenames: `^[A-Za-z0-9._-]{1,64}$` — rejects `..`, `/`, NUL, shell metachars
4. **Path resolution.** `os.path.realpath` followed by `startswith(base_dir)`.
Symlinks escaping the base are rejected. The `..` path component is
rejected **before** resolution (defense in depth).
5. **Environment scrubbing.** Privileged subprocesses run with
`env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin"}`. `LD_PRELOAD`,
`LD_LIBRARY_PATH`, `PYTHONPATH`, `BASH_ENV`, `ENV`, `PERL5OPT` are
dropped.
6. **No `eval`, no `pickle`, no `yaml.unsafe_load`.** Only `json.loads`
with strict schemas.
7. **Per-verb polkit check.** The bridge's mutating verbs (apply, stop,
restart, ban, unban, clear-bans, switch-backend, install-backend)
all run under the `org.sysdeck.firewall.modify` polkit action with
`{ superuser: 'try' }` from JS — the cockpit bridge prompts the
operator. Read-only verbs (templates, status, chains, ruleset,
backends, backend-info, active-backend) never require auth.
8. **File writes use `O_NOFOLLOW | O_CREAT | O_EXCL`.** Defeats symlink
races.
9. **Error responses are sanitized.** Command stderr is truncated to
4 KiB and stripped of bytes outside printable ASCII + newline before
being returned to the JS panel.
10. **No operator-editable executable files.** Firewall templates are
static files shipped with the package, installed `0644` (read-only
to the operator). The bridge invokes them via `bash <path>` under
the `org.sysdeck.firewall.modify` polkit action.
### 2.2 JavaScript panel (`plugins/sysdeck-firewall/firewall.js`)
11. **Output encoding.** All bridge output is rendered with `escapeHtml()`
or `textContent`. No `innerHTML` on bridge data. (The CVE-2022-36446
lesson — apt output rendered as HTML caused RCE.)
12. **No URL interpolation.** No "target host" feature, no
`encodeURIComponent` on a user-supplied URL embedded into a fetch.
13. **CSRF.** All requests flow through `cockpit.spawn` / cockpit
channels — cockpit-ws provides CSRF protection via its channel
model. No custom `/sysdeck/api` HTTP endpoint exists.
14. **No HTTP Basic auth bypass.** SysDeck relies solely on the
cockpit-ws session cookie. If 2FA is ever layered on top, the
bridge will reject Basic auth and require a session-bound token
(the CVE-2026-42210/56022 lesson).
### 2.3 polkit policy (`packaging/polkit/org.sysdeck.policy`)
15. **Per-verb actions.** v0.0.36 keeps the coarse `org.sysdeck.firewall.modify`
action for all firewall mutations (matches the v0.0.17 design). When
the verb set grows further, this will be split into
`org.sysdeck.firewall.apply-template`,
`org.sysdeck.firewall.ban-ip`, etc. (the CVE-2022-0824 lesson —
coarse actions are acceptable as long as every mutating verb is
covered; the failure mode is verbs with **no** polkit action).
16. **`auth_admin_keep`, never `yes` or `auth_self`.** Root-equivalent
operations require admin authentication, with a short keep window so
the operator isn't re-prompted every 30 s.
### 2.4 Build / release integrity (response to the 2019 Webmin backdoor)
17. **`make check` runs `git status --porcelain`** as a release gate —
fails if the working tree is dirty. The Webmin backdoor was
invisible to `git diff` because the attacker rolled back the file
mtime; `git status` would have flagged it.
18. **Reproducible builds.** `make dist` pins `LC_ALL=C`,
`SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)`, and file ordering
via `find --sort`.
19. **Signed releases.** The release tarball is signed with a PGP key
whose private half is documented as held offline. The public key is
published on a separately-hosted page (not the same bucket as the
tarball). Operators verify with `gpg --verify`.
20. **SBOM.** `packaging/sysdeck.spec` and `packaging/debian/control`
declare every runtime dependency with a version constraint.
`THIRD_PARTY.md` enumerates every independently-licensed program
the bridge invokes as a separate subprocess.
21. **Diff-what-ships-vs-git-HEAD.** After `make install` into a
DESTDIR, `make distcheck` extracts the release tarball into a
clean directory and runs `make check` inside. Any drift between
the shipped tree and the git HEAD is caught.
22. **No "build from backup."** If a build host is rebuilt, the new
host starts from a fresh `git clone --depth 1` of the tagged
commit, not from a restored working directory.
### 2.5 Regression tests added in v0.0.36
23. `tests/test_bridge_parsers.py::FirewallHardeningTests` — fuzzes
every bridge verb that accepts a string argument with the full
byte range 0x00–0x20 + 0x7F–0xFF + shell metacharacters
(`;`, `|`, `&`, `` ` ``, `$()`, `%0A`, `%0C`, `%00`, `--`, `-`,
`\n`, `\r`, `..\`, UTF-8 BOM, 64 KiB long string, emoji, RTL marks).
Asserts the bridge returns an error response and that no
`nft`/`systemctl`/`ip` subprocess was spawned.
24. `tests/test_bridge_parsers.py::FirewallPathEscapeTests` — feeds
`../../etc/passwd`, `/etc/shadow`, symlinks, `file:///etc/passwd`,
NUL-byte variants. Asserts rejection.
25. `tests/test_bridge_parsers.py::FirewallBackendTests` — exercises
the new backend dropdown: switch to each backend, verify the
active-backend file is written, verify the panel sees the right
templates list per backend.
---
## 3. What was NOT done (and why)
The following hardening items were considered and deferred — they are
documented here so the next maintainer can pick them up deliberately
rather than rediscover the threat model.
- **Per-verb polkit actions.** v0.0.36 keeps the coarse
`org.sysdeck.firewall.modify` action. Splitting into per-verb actions
is a v0.0.40+ task — it requires a polkit rules file that maps each
verb to an action, plus a UI change to surface the granularity to the
operator.
- **Cilium endpoint-view panel.** v0.0.36 ships the Cilium backend
selector and a policy-apply button. A full endpoint/policy viewer
(similar to `cilium endpoint list` + `cilium policy get` rendered as
tables) is a v0.0.40+ task — it requires a Cilium-specific UI that
doesn't fit the nftables-shaped panel.
- **Sandboxed Jinja2 template engine.** The current templates are
self-contained bash scripts. If a future version moves to a Jinja2
templating layer (to support per-host variables), it must use
`SandboxedEnvironment` with a fixed allowlist of variables — the
CVE-2023-46818 lesson.
---
## 4. References
- Webmin security page: <https://webmin.com/security.html>
- Cockpit security advisories: <https://github.com/cockpit-project/cockpit/security/advisories>
- NVD: <https://nvd.nist.gov/>
- The 2019 Webmin backdoor writeup: <https://blog.firosolutions.com/posts/exploit/webmin/>
- GHSA-m4gv-x78h-3427 (Cockpit SSH argv injection): <https://github.com/cockpit-project/cockpit/security/advisories/GHSA-m4gv-x78h-3427>
- GHSA-6jmq-qw8f-w3r6 (Cockpit logs page argv injection): <https://github.com/cockpit-project/cockpit/security/advisories/GHSA-6jmq-qw8f-w3r6>
---
## 5. v0.0.37 expansion — commercial web admin UI panels
Per user directive: *"when i say webmin i mean all web admin ui panels
cpanel all of them have a history for us to learn from on the security
side of things."* v0.0.37 extends the CVE research to cover the
commercial web admin UI panels that v0.0.36 did not reach.
### 5.1 Additional CVEs reviewed
| CVE | Year | Product | Vector | Root cause | Lesson applied |
|-----|------|---------|--------|------------|----------------|
| CVE-2026-41940 | 2026 | cPanel & WHM (all versions after 11.40) | Pre-auth, network, CVSS 9.8, CISA KEV | CRLF injection in on-disk session file. cpsrvd's HTTP Basic auth handler calls `Cpanel::Session::saveSession()` directly, bypassing `filter_sessiondata()` which strips CR/LF. Attacker injects `user=root`, `hasroot=1`, `tfa_verified=1` into the pre-auth session file. | `_sanitize_for_file()` strips `\r\n\0` from any value written to a line-oriented file (session, polkit action, sudoers, cron, /etc/hosts, DNS zone, nginx/apache conf). |
| CVE-2026-29205 | 2026 | cPanel cpdavd (CalDAV, ports 2079/2080) | Pre-auth, network, root file read | Validate-then-decode path traversal. Regex `^/calendars/([^/]+)/([^/]+)(/.*)?$` runs on the **raw URI**, so `%2F` satisfies `[^/]+`. Then `uri_unescape()` decodes it into a real `/`. | `_decode_then_validate()` URL-decodes FIRST, then canonicalizes via `os.path.realpath`, then validates. Rejects encoded path-traversal sequences (`%2e`, `%2f`, `%5c`, `%00`, `%0a`, `%0d`). |
| CVE-2026-58048 | 2026 | cPanel DB management | Authenticated low-priv → SQL as DB root, CVSS 9.4 | Improper preservation of SQL mode when renaming a database. Rename path drops SQL mode restrictions. | `_validate_mysql_identifier()` enforces `^[A-Za-z_$][A-Za-z0-9_$]{0,63}$`, rejects MySQL reserved words, rejects embedded backticks. Identifiers always backtick-quoted. |
| CVE-2025-66429 | 2025 | cPanel Team Manager API (v110–132) | Authenticated low-priv → root file write, CVSS 8.8 | Path traversal in Team Manager API. User-controlled path concatenated into filesystem path without canonicalization; writes to `/etc/sudoers`, `/root/.ssh/authorized_keys`, `/etc/cron.d/`. | `_resolve_path_under_base()` (v0.0.36) already covers this; v0.0.37 adds the FIM recommendation in the checklist. |
| CVE-2023-29489 | 2023 | cPanel before 11.109.9999.116 | Pre-auth reflected XSS on cpsrvd error page, ~1.2M assets affected | Error page echoed the invalid webcall ID without escaping. | JS panel uses `escapeHtml()` / `textContent`, never `innerHTML` on bridge data (v0.0.36 B2.1). |
| CVE-2018-20898 | 2018 | cPanel (TSR-2018-0003) | Authenticated, CVSS 6.4 | API tokens retained ACLs that were removed from accounts. When an ACL was revoked, outstanding API tokens kept their old privileges. | Documented in checklist — token ACLs must be re-checked live on every privileged op, never cached. |
| CVE-2025-66431 | 2025 | Plesk Obsidian 18.0.73/74 on Linux | Authenticated Plesk user → RCE as root on domain creation | Domain-creation mechanism executes code as root. The `relink-vhost-logs` helper runs as root with insufficiently-validated domain input. Vendor workaround replaces the helper with a no-op `:`. | `_validate_domain()` rejects shell metacharacters, path separators, whitespace, `..`, leading/trailing hyphens, IDN (must be punycode first), enforces 253-char max / 63-char label max. |
| CVE-2026-44962 | 2026 | Plesk APS Application Catalog | Authenticated low-priv → OS command execution → LPE, CVSS 9.9 | XPath injection in APS Catalog search; user input interpolated into XPath queries without sanitization. | Documented in checklist — if sysdeck uses XML/XPath lookups, parameterize; never string-interpolate user input into XPath. |
| CVE-2025-54336 | 2025 | Plesk Obsidian 18.0.70 | Auth bypass via weak password comparison | Loose `==` comparison on admin password check. | Documented in checklist — use `hmac.compare_digest()` for all secret comparisons; never `==`. |
| CVE-2024-51567 | 2024 | CyberPanel ≤ 2.3.6 (+ unpatched 2.3.7) | **Pre-auth 0-click RCE as root**, CVSS 10.0. Exploited by PSAUX ransomware Oct 2024. | `upgrademysqlstatus()` in `databases/views.py` reads `statusfile` from JSON body and concatenates it directly into `f"sudo cat {statusfile}"`. `secMiddleware` only inspects POST — attackers bypass via PUT/OPTIONS. No auth check on the route. | (1) Auth on EVERY route, including status/polling/upgrade endpoints. (2) Input validation must run for ALL HTTP methods, not just POST. (3) Never f-string-concatenate user input into a subprocess command. |
| CVE-2024-51568 | 2024 | CyberPanel < 2.3.5 | Pre-auth RCE via `/filemanager/upload`, CVSS 9.8/10 | Command injection via `completePath` in `ProcessUtilities.outputExecutioner()` sink. | Documented in checklist — file-manager endpoints must NOT exist without auth + per-path polkit. |
| CVE-2024-51378 | 2024 | CyberPanel before commit 1c0c6cb | Pre-auth auth bypass + command injection in `getresetstatus` | Auth bypass in status-check endpoints (same class as 51567). | Documented in checklist — "reset status" / "upgrade status" / "poll" endpoints must be authenticated. |
| CVE-2025-48702 | 2025 | aaPanel commercial sub-panel (port 50443) | Authenticated sub-account → RCE | **tar argument injection.** `/files/compress` passes user-controlled filenames directly as argv to `tar -zcf archive.tar.gz <file1> <file2>`. Sub-account creates two files named `--checkpoint=1` and `--checkpoint-action=exec=bash shell.sh`, triggers compress, tar executes the shell. **SUBPROCESS ARRAY FORM DOES NOT PREVENT THIS** — tar interprets the filename as an option. | `safe_tar_create()` keeps filenames OUT of argv by passing them via stdin using `tar --null -T -` (NUL-delimited). ALSO: rejects filenames starting with `-` or `/`, rejects filenames containing `\n\r\0`, resolves and verifies each file under the cwd. |
| CVE-2026-29859 | 2026 | aaPanel v7.57.0 | Arbitrary file upload → RCE | Crafted file upload executes arbitrary code. | Documented in checklist — extension allowlist + magic-byte verification + filename sanitization. |
| CVE-2023-35885 | 2023 | CloudPanel 2 before 2.3.1 | Pre-auth auth bypass in file-manager, CVSS critical | Insecure file-manager cookie authentication — crafted HTTP request bypasses auth. | Documented in checklist — cookie/session auth for file operations must be server-side validated with HMAC + expiry. |
| CVE-2024-44765 | 2024 | CloudPanel v2.0.0–v2.4.2 | Authenticated low-priv user bypasses access controls, CVSS 6.5 | Improper authorization — low-priv users reach sensitive config files and admin functionality. | Documented in checklist — per-verb + per-resource authz checks; deny by default. |
| CVE-2021-47871 | 2021 | Hestia Control Panel 1.3.2 | Authenticated arbitrary file write, CVSS 8.6 | `v-make-tmp-file` command via API writes attacker-controlled content to arbitrary paths (e.g. SSH keys into `/root/.ssh/authorized_keys`). | `_resolve_path_under_base()` (v0.0.36) + `O_NOFOLLOW | O_CREAT | O_EXCL` file opens (v0.0.36 B1.7). |
| CVE-2018-10686 | 2018 | VestaCP 0.9.8–20 | Reflected XSS → RCE | `web/view/file/index.php` injects `$path` without sanitization → reflected XSS. Then `web/upload/UploadHandler.php` calls `file_put_contents()` for resumable uploads without path validation → write PHP shell anywhere. Chain: rXSS → upload shell → RCE. | JS panel uses `textContent`, never `innerHTML` on bridge data (v0.0.36 B2.1). Upload paths validated with `_resolve_path_under_base()`. |
| CVE-2018-1000884 | 2018 | VestaCP ≤ 0.9.8-17 | Password reset flaw | Password reset vulnerability — used in the April 2018 mass hack of VestaCP servers (attributed to CN IPs). | Documented in checklist — reset tokens: `secrets.token_urlsafe(32)`, stored hashed, single-use, 15-min expiry, bound to user ID at issuance. |
| CVE-2026-26279 | 2026 | Froxlor (admin panel) | Authenticated admin → root RCE via cron, CVSS 9.1 CRITICAL | Logic error in Froxlor's email input validation disables format checking for all fields declared as email type, including the cron-invoked ones. The injected email value flows into a root-run cron script. | `_validate_email()` uses `email.utils.parseaddr` FIRST, then a strict charset regex, then SEPARATELY rejects shell metacharacters even if the regex passes — defense in depth on top of input validation, because validation logic bugs happen. |
| CVE-2025-29773 | 2025 | Froxlor < 2.2.6 | Admin-to-root privilege escalation via input validation | Admin-supplied input reaches privileged execution unsanitized. | Documented in checklist — admin ≠ root. Even admin-supplied input must pass the same validation pipeline; polkit must gate root-run helpers regardless of caller role. |
| CVE-2014-2531 | 2014 | InterWorx 5.0.13 build 574 | Authenticated SQL injection in `xhr.php` | SQLi via `xhr.php?i=` — application does not perform proper input validation. | Documented in checklist — parameterized queries everywhere, including AJAX endpoints. |
| IWX-CVE-2022-8384 | 2022 | InterWorx 6 ≤ 6.12.2, 7 ≤ 7.9.8 | Maliciously named file → tar argument injection → code exec | Backup process passes user-named files to `tar` with insufficient escaping. Same class as aaPanel CVE-2025-48702. | `safe_tar_create()` — same fix as aaPanel. |
| IWX-CVE-2022-8522 | 2022 | InterWorx 6 ≤ 6.12.2, 7 ≤ 7.9.9 | SiteWorx/NodeWorx user → reset another user's password | Maliciously crafted reset token in the password-reset process. | Documented in checklist — reset tokens: `secrets.token_urlsafe(32)`, single-use, bound to user ID, hashed at rest. |
| IWX-CVE-2025-13057 | 2025 | InterWorx 6/7/8 | `.htaccess` exploitation → access to other files on the server | User-supplied `.htaccess` escapes its directory context. | Documented in checklist — `AllowOverride None` on parent paths; per-tenant config dirs. |
| CVE-2023-53945 | 2023 | BrainyCP 1.0 | Authenticated RCE via crontab, CVSS 8.7/8.8 | Logged-in users inject arbitrary commands through the crontab configuration interface. | `_validate_cron_schedule()` accepts only 5-field cron syntax. The cron *command* is NEVER user-supplied — only the schedule. The operator picks from a pre-defined command allowlist. |
| CVE-2019-11193 | 2019 | DirectAdmin through v1.561 | XSS via `CMD_FILE_MANAGER`, `CMD_SHOW_USER`, `CMD_SHOW_RESELLER` | Reflected XSS in file-manager and user-management commands. | JS panel uses `escapeHtml()` / `textContent` (v0.0.36 B2.1). |
| CVE-2019-9625 | 2019 | DirectAdmin 1.55 | CSRF via `CMD_ACCOUNT_ADMIN` | CSRF enables attacker to create admin accounts. | Documented in checklist — all state-changing ops must be POST/PUT/DELETE with CSRF token + SameSite cookies + origin check. |
| CVE-2025-100 | 2025 | CWP / CentOS Web Panel | Critical RCE, exploited in the wild | Limited root-cause detail in public sources. Listed as critical RCE with active exploitation. | Same model as CyberPanel: pre-auth RCE in a panel that runs as root. Defense in depth. |
### 5.2 New hardening items applied in v0.0.37
Each item goes BEYOND the v0.0.36 checklist. The item IDs continue from
the v0.0.36 numbering (B1.x, B2.x, B3.x, B4.x).
#### B2.1 — tar/zip argument-injection defense (`--null -T -`)
**CVEs:** CVE-2025-48702 (aaPanel), IWX-CVE-2022-8384 (InterWorx)
The v0.0.36 `--` separator is necessary but NOT sufficient for
`tar`/`zip`/`find`/`rsync`. These tools interpret arguments after `--`
differently, and a filename like `--checkpoint-action=exec=bash shell.sh`
can still execute code.
**Implementation:** `safe_tar_create()` in `bridge/firewall.py` keeps
filenames OUT of argv by passing them via stdin using `tar --null -T -`
(NUL-delimited). Also: rejects filenames starting with `-` or `/`,
rejects filenames containing `\n\r\0`, resolves and verifies each file
under the cwd.
#### B3.1 — CRLF/NUL strip at every file-write boundary
**CVE:** CVE-2026-41940 (cPanel session-file CRLF injection)
Any value written to a file that is later parsed line-by-line (session
files, polkit action files, sudoers fragments, cron files, `/etc/hosts`,
DNS zone files, nginx/apache conf) must have `\r`, `\n`, `\0` STRIPPED,
not just rejected. An attacker who can inject `\r\nuser=root\r\n` into
a session file gains root.
**Implementation:** `_sanitize_for_file()` in `bridge/firewall.py`.
#### B4.2 — Decode-then-validate (never validate-then-decode)
**CVE:** CVE-2026-29205 (cPanel cpdavd path traversal)
URL-decode FIRST, then `os.path.realpath`, then validate against the
allowlist regex + containment check. Reject any input where the encoded
form differs from the decoded form in a security-relevant way.
**Implementation:** `_decode_then_validate()` in `bridge/firewall.py`.
#### B5.1 — Strict domain-name validation (RFC 1035)
**CVE:** CVE-2025-66431 (Plesk domain-creation RCE-as-root)
Domain names in a panel become nginx/apache config, DNS zone files,
log-symlink rotation scripts run as root, and mail virtual-user
mappings.
**Implementation:** `_validate_domain()` in `bridge/firewall.py`.
Regex: `^(?=.{1,253}$)([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$`.
Rejects shell metacharacters, path separators, whitespace, `..`,
leading/trailing hyphens, IDN (must be punycode first), wildcard
domains in root-run script contexts.
#### B6.1 — Email validation with separate metachar rejection
**CVE:** CVE-2026-26279 (Froxlor email-validation logic bug)
Froxlor's email-input validation had a logic bug that disabled format
checking for fields declared as email type, allowing shell
metacharacters through.
**Implementation:** `_validate_email()` in `bridge/firewall.py`. Uses
`email.utils.parseaddr` FIRST, then a strict charset regex, then
SEPARATELY rejects shell metacharacters even if the regex passes —
defense in depth on top of input validation, because validation logic
bugs happen.
#### B7.1 — Cron schedule validation (5-field syntax only)
**CVE:** CVE-2023-53945 (BrainyCP crontab RCE)
BrainyCP let users inject arbitrary commands through the crontab
interface.
**Implementation:** `_validate_cron_schedule()` in `bridge/firewall.py`.
Accepts only 5-field cron syntax (digits, `*`, `/`, `-`, comma). The
cron *command* is NEVER user-supplied — only the schedule. The operator
picks from a pre-defined command allowlist.
#### B8.1 — MySQL identifier validation + reserved-word denylist
**CVE:** CVE-2026-58048 (cPanel DB rename SQL mode drop)
cPanel's DB rename dropped SQL mode restrictions, allowing the user to
run SQL in root context.
**Implementation:** `_validate_mysql_identifier()` in `bridge/firewall.py`.
Regex: `^[A-Za-z_$][A-Za-z0-9_$]{0,63}$` (MySQL allows `$`). Rejects
MySQL reserved words (`mysql`, `information_schema`, `performance_schema`,
`sys`, `root`, etc.) via an explicit denylist. Rejects embedded
backticks (defeats backtick-quote escape attacks). Identifiers always
backtick-quoted in generated SQL.
### 5.3 Additional references (v0.0.37)
- cPanel security advisories: <https://support.cpanel.net/hc/en-us/articles/360059501353-cPanel-Security-Advisories>
- Plesk security advisories: <https://support.plesk.com/hc/en-us/articles/115000418553-Plesk-Security-Advisories>
- CyberPanel security advisory (CVE-2024-51567): <https://nvd.nist.gov/vuln/detail/CVE-2024-51567>
- aaPanel CVE-2025-48702 writeup: <https://nvd.nist.gov/vuln/detail/CVE-2025-48702>
- Froxlor GHSA-33mp-8p67-xj7c: <https://github.com/advisories/GHSA-33mp-8p67-xj7c>
- slcyber/assetnote cPanel research: <https://slcyber.io/blog/>
- CISA KEV catalog (CVE-2026-41940): <https://www.cisa.gov/known-exploited-vulnerabilities-catalog>

View File

@ -0,0 +1,78 @@
# Cilium default network policy — shipped with sysdeck-0.0.36.
# Author: Jeremy Anderson <info@dcos.net>
#
# This policy is applied by firewall/templates/cilium.sh `start` action
# when the operator selects the Cilium backend in the SysDeck Firewall
# panel. It implements a sensible default:
#
# - default-deny ingress + egress at the cluster level
# - allow DNS (UDP/TCP 53) to kube-dns / systemd-resolved
# - allow SSH (TCP 22) from anywhere
# - allow HTTP/HTTPS (TCP 80/443) from anywhere
#
# Operators can drop a custom policy at
# /etc/sysdeck/firewall/cilium-policy.yaml to override.
#
# Reference: https://docs.cilium.io/en/stable/security/policy/
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: sysdeck-default-deny
namespace: default
annotations:
sysdeck.io/managed-by: "sysdeck-firewall-cilium"
sysdeck.io/version: "0.0.36"
spec:
description: "SysDeck default policy — deny all ingress + egress, then re-allow DNS/SSH/HTTP/HTTPS"
endpointSelector: {}
ingress:
# Allow all endpoints to receive traffic from anywhere on SSH/HTTP/HTTPS.
- toPorts:
- ports:
- port: "22"
protocol: TCP
- port: "80"
protocol: TCP
- port: "443"
protocol: TCP
rules:
http:
- method: "GET"
- method: "POST"
- method: "HEAD"
egress:
# Allow DNS to kube-dns (K8s) or systemd-resolved (standalone).
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP
- port: "53"
protocol: TCP
rules:
dns:
- matchPattern: "*"
# Allow egress to anywhere on SSH/HTTP/HTTPS.
- toPorts:
- ports:
- port: "22"
protocol: TCP
- port: "80"
protocol: TCP
- port: "443"
protocol: TCP
# Allow egress to anywhere on HTTPS (for system updates).
- toCIDRSet:
- cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
toPorts:
- ports:
- port: "443"
protocol: TCP

414
firewall/templates/ai-llm.sh Executable file
View File

@ -0,0 +1,414 @@
#!/usr/bin/env bash
#
# Name: ai-llm
# Description: Public server variant for self-hosted AI LLM stacks. Exposes Ollama (11434), OpenWebUI (3000), Hermes (8000), Odysseus (8001), and SSH (22). All AI service ports are public so the operator can reach them from anywhere; SSH is rate-limited with auto-ban. Ollama is also reachable on its API port from the LAN. Designed for a personal / team AI workstation accessible over a trusted network or VPN.
# Distro: arch,debian
# Services: ssh,ollama,openwebui,hermes,odysseus
#
# ============================================================================
# ai-llm.sh - SysDeck public-server variant: AI LLM stack
# ============================================================================
#
# v0.0.44 NEW. Per user directive: "an ai llm variant for ollama,
# hermes, openwebui and oddyseus." This template targets an AI
# workstation running a self-hosted LLM stack.
#
# Stack:
# Ollama — local LLM inference server (default :11434)
# OpenWebUI — web UI for Ollama / OpenAI-compatible APIs (:3000)
# Hermes — Nous Research Hermes function-calling gateway (:8000)
# Odysseus — companion web UI / agent runtime (:8001)
# SSH — admin shell (rate-limited, auto-ban)
#
# Public TCP ports:
# 22 SSH (rate-limited, auto-ban brute force)
# 11434 Ollama API (public — operator may want to call from laptop)
# 3000 OpenWebUI (public)
# 8000 Hermes (public)
# 8001 Odysseus (public)
#
# Loopback-only ports (defense-in-depth drop — operator can change
# by editing the script, but the default is "public" since the user
# directive is to expose all four services):
# (none — all four AI ports are public per user directive)
#
# Detection:
# - Ollama: reads OLLAMA_HOST from /etc/systemd/system/ollama.service.d/*.conf
# or /etc/environment. Default 0.0.0.0:11434 (the Ollama upstream
# default — note: the Ollama systemd unit DOES bind 0.0.0.0 by
# default; we surface this in detect output but do NOT change it.
# Per v0.0.43 directive "never 0.0.0.0" — but Ollama here is
# PUBLIC by design per user directive; the firewall gates access,
# not the bind address. If the operator wants loopback-only, set
# OLLAMA_HOST=127.0.0.1:11434 in the systemd override.)
# - OpenWebUI: reads /etc/open-webui/config or env. Default 3000.
# - Hermes: reads /etc/hermes/config.yaml. Default 8000.
# - Odysseus: reads /etc/odysseus/config.toml. Default 8001.
#
# Standard template interface (start/stop/restart/detect/status/check).
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
SCRIPT_NAME="ai-llm"
SCRIPT_VERSION="0.0.44"
TABLE_NAME="firewall"
NFT_CMD="${NFT_CMD:-nft}"
RULES_FILE="${RULES_FILE:-/tmp/sysdeck-firewall-ai-llm.rules}"
# ── Configurable ports (auto-detected) ──────────────────────────────
SSH_PORT="${SYSDECK_AI_LLM_SSH_PORT:-22}"
OLLAMA_PORT="${SYSDECK_AI_LLM_OLLAMA_PORT:-11434}"
OPENWEBUI_PORT="${SYSDECK_AI_LLM_OPENWEBUI_PORT:-3000}"
HERMES_PORT="${SYSDECK_AI_LLM_HERMES_PORT:-8000}"
ODYSSEUS_PORT="${SYSDECK_AI_LLM_ODYSSEUS_PORT:-8001}"
# ── Rate limits ─────────────────────────────────────────────────────
SSH_RATE_LIMIT="4/minute"
SSH_BURST="8"
SSH_BAN_TIMEOUT="3600s"
# AI inference is request/response — bursts can be large. Set generous
# rate limits to avoid dropping legitimate requests during model
# swaps / batch embeddings.
AI_RATE_LIMIT="50/second"
AI_BURST="100"
# ── Log ─────────────────────────────────────────────────────────────
LOG_PREFIX="[NFT-DROP] "
LOG_RATE="5/second"
LOG_BURST="10"
# ── Bogons ──────────────────────────────────────────────────────────
BOGONS_V4="0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4"
BOGONS_V6="::1/128, fc00::/7, fe80::/10, ff00::/8"
log_info() { printf '[%s] [INFO] %s\n' "$SCRIPT_NAME" "$*" >&2; }
log_debug() { printf '[%s] [DEBUG] %s\n' "$SCRIPT_NAME" "$*" >&2 || true; }
die() { printf '[%s] [FATAL] %s\n' "$SCRIPT_NAME" "$*" >&2; exit 1; }
check_nftables() {
command -v "$NFT_CMD" &>/dev/null || die "nftables not installed. Install with: pacman -S nftables / apt install nftables"
}
# ── Service detection ───────────────────────────────────────────────
detect_ssh_port() {
local cfg="/etc/ssh/sshd_config"
if [[ -f "$cfg" ]]; then
local port
port=$(awk '/^[[:space:]]*Port[[:space:]]+/ { print $2; exit }' "$cfg" 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then SSH_PORT="$port"; return; fi
fi
SSH_PORT="22"
}
# Ollama listens on OLLAMA_HOST (default 0.0.0.0:11434). The systemd
# unit /etc/systemd/system/ollama.service.d/override.conf can set
# Environment="OLLAMA_HOST=127.0.0.1:11434" — we honor that.
detect_ollama_port() {
local dirs=(
"/etc/systemd/system/ollama.service.d"
"/etc/systemd/system/ollama.service"
)
for d in "${dirs[@]}"; do
if [[ -d "$d" ]]; then
local host
host=$(grep -rhoE 'OLLAMA_HOST=[^"]+' "$d" 2>/dev/null | head -1 | cut -d= -f2 || true)
if [[ -n "${host:-}" ]]; then
# Strip the host: prefix, keep the port.
local port="${host##*:}"
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
OLLAMA_PORT="$port"
return
fi
fi
fi
done
# Fall back to /etc/environment.
if [[ -f /etc/environment ]]; then
local host
host=$(grep -E '^OLLAMA_HOST=' /etc/environment 2>/dev/null | head -1 | cut -d= -f2 | tr -d '"' || true)
if [[ -n "${host:-}" ]]; then
local port="${host##*:}"
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
OLLAMA_PORT="$port"
return
fi
fi
fi
OLLAMA_PORT="11434"
}
# OpenWebUI listens on PORT env (default 8080 in their docker image,
# 3000 in their bare-metal install). We check the systemd unit override
# and /etc/open-webui/.
detect_openwebui_port() {
local dirs=(
"/etc/systemd/system/open-webui.service.d"
"/etc/systemd/system/open-webui.service"
"/etc/systemd/system/openwebui.service.d"
)
for d in "${dirs[@]}"; do
if [[ -d "$d" ]]; then
local port
port=$(grep -rhoE 'PORT=[0-9]+' "$d" 2>/dev/null | head -1 | cut -d= -f2 || true)
if [[ -n "${port:-}" ]]; then OPENWEBUI_PORT="$port"; return; fi
fi
done
if [[ -f /etc/open-webui/config ]]; then
local port
port=$(awk -F= '/^PORT=/ { print $2; exit }' /etc/open-webui/config 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then OPENWEBUI_PORT="$port"; return; fi
fi
OPENWEBUI_PORT="3000"
}
# Hermes config: /etc/hermes/config.yaml. Look for `port: N` under the
# `server:` section.
detect_hermes_port() {
local cfg_files=("/etc/hermes/config.yaml" "/etc/hermes/config.yml" "/etc/hermes/hermes.yaml")
for f in "${cfg_files[@]}"; do
if [[ -f "$f" ]]; then
local port
port=$(awk '
/^server:/ { in_s=1; next }
/^[a-z]/ { in_s=0 }
in_s && /^[[:space:]]*port:/ { gsub(/[^0-9]/, "", $2); print $2; exit }
' "$f" 2>/dev/null || true)
if [[ -n "${port:-}" ]]; then HERMES_PORT="$port"; return; fi
fi
done
HERMES_PORT="8000"
}
# Odysseus config: /etc/odysseus/config.toml. Look for `port = N`.
detect_odysseus_port() {
local cfg_files=("/etc/odysseus/config.toml" "/etc/odysseus/odysseus.toml")
for f in "${cfg_files[@]}"; do
if [[ -f "$f" ]]; then
local port
port=$(awk -F= '
/^[[:space:]]*port[[:space:]]*=/ { gsub(/[^0-9]/, "", $2); print $2; exit }
' "$f" 2>/dev/null || true)
if [[ -n "${port:-}" ]]; then ODYSSEUS_PORT="$port"; return; fi
fi
done
ODYSSEUS_PORT="8001"
}
# ── Build ruleset ───────────────────────────────────────────────────
build_ruleset() {
cat <<RULESET
#!/usr/sbin/nft -f
flush ruleset
table inet ${TABLE_NAME} {
# ── Sets ──────────────────────────────────────────────────────
set ssh_abuse {
type ipv4_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set ssh_abuse6 {
type ipv6_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set bogons_v4 {
type ipv4_addr
flags interval
elements = { ${BOGONS_V4} }
}
set bogons_v6 {
type ipv6_addr
flags interval
elements = { ${BOGONS_V6} }
}
# ── Chains ────────────────────────────────────────────────────
chain input {
type filter hook input priority 0; policy drop;
ip saddr @bogons_v4 drop
ip6 saddr @bogons_v6 drop
ct state vmap { established: accept, related: accept }
iifname "lo" accept
ct state invalid drop
# ICMP / ICMPv6 essentials.
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, packet-too-big, destination-unreachable, time-exceeded } accept
# ── SSH (port ${SSH_PORT}) ─────────────────────────────────
ip saddr @ssh_abuse drop
ip6 saddr @ssh_abuse6 drop
tcp dport ${SSH_PORT} ct state new \
limit rate ${SSH_RATE_LIMIT} burst ${SSH_BURST} packets accept
tcp dport ${SSH_PORT} ct state new \
add @ssh_abuse { ip saddr } \
add @ssh_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}ssh-rate " drop
# ── Ollama API (port ${OLLAMA_PORT}) ───────────────────────
tcp dport ${OLLAMA_PORT} ct state new \
limit rate ${AI_RATE_LIMIT} burst ${AI_BURST} packets accept
tcp dport ${OLLAMA_PORT} ct state new \
log prefix "${LOG_PREFIX}ollama-rate " drop
# ── OpenWebUI (port ${OPENWEBUI_PORT}) ─────────────────────
tcp dport ${OPENWEBUI_PORT} ct state new \
limit rate ${AI_RATE_LIMIT} burst ${AI_BURST} packets accept
tcp dport ${OPENWEBUI_PORT} ct state new \
log prefix "${LOG_PREFIX}openwebui-rate " drop
# ── Hermes (port ${HERMES_PORT}) ───────────────────────────
tcp dport ${HERMES_PORT} ct state new \
limit rate ${AI_RATE_LIMIT} burst ${AI_BURST} packets accept
tcp dport ${HERMES_PORT} ct state new \
log prefix "${LOG_PREFIX}hermes-rate " drop
# ── Odysseus (port ${ODYSSEUS_PORT}) ───────────────────────
tcp dport ${ODYSSEUS_PORT} ct state new \
limit rate ${AI_RATE_LIMIT} burst ${AI_BURST} packets accept
tcp dport ${ODYSSEUS_PORT} ct state new \
log prefix "${LOG_PREFIX}odysseus-rate " drop
# Drop invalid TCP flag combinations.
tcp flags & (fin|syn|rst|psh|ack|urg) == 0 drop
tcp flags & (fin|syn) == (fin|syn) drop
tcp flags & (syn|rst) == (syn|rst) drop
tcp flags & (fin|rst) == (fin|rst) drop
tcp flags & (psh|fin) == (psh|fin) drop
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}input "
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
RULESET
}
# ── Actions ─────────────────────────────────────────────────────────
fw_start() {
check_nftables
detect_ssh_port
detect_ollama_port
detect_openwebui_port
detect_hermes_port
detect_odysseus_port
log_info "Starting ai-llm firewall:"
log_info " ssh=${SSH_PORT} (public, rate-limited)"
log_info " ollama=${OLLAMA_PORT} (public)"
log_info " openwebui=${OPENWEBUI_PORT} (public)"
log_info " hermes=${HERMES_PORT} (public)"
log_info " odysseus=${ODYSSEUS_PORT} (public)"
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded."
}
fw_stop() {
check_nftables
log_info "Stopping ai-llm firewall"
"$NFT_CMD" delete table inet "$TABLE_NAME" 2>/dev/null || true
rm -f "$RULES_FILE"
}
fw_restart() {
fw_stop
fw_start
}
fw_detect() {
detect_ssh_port
detect_ollama_port
detect_openwebui_port
detect_hermes_port
detect_odysseus_port
local os_name="unknown"
if [[ -f /etc/os-release ]]; then . /etc/os-release; os_name="${PRETTY_NAME:-${NAME:-unknown}}"; fi
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Template: ai-llm (Ollama + OpenWebUI + Hermes + Odysseus)
|
| Public ports:
| ${SSH_PORT}/tcp SSH (rate-limited, auto-ban)
| ${OLLAMA_PORT}/tcp Ollama API
| ${OPENWEBUI_PORT}/tcp OpenWebUI (web UI)
| ${HERMES_PORT}/tcp Hermes (function-calling gateway)
| ${ODYSSEUS_PORT}/tcp Odysseus (companion UI / agent runtime)
|
| Config files (for the Service/Port editor):
| SSH: /etc/ssh/sshd_config
| Ollama: /etc/systemd/system/ollama.service.d/*.conf (OLLAMA_HOST)
| OpenWebUI: /etc/open-webui/config (PORT=)
| Hermes: /etc/hermes/config.yaml (server.port)
| Odysseus: /etc/odysseus/config.toml (port = )
|
| Note: per v0.0.43 "never 0.0.0.0" directive, Ollama's default
| bind IS 0.0.0.0 — but the user directive here is "public variant
| for ai llm". The firewall gates access. If you want Ollama to
| bind loopback-only, set OLLAMA_HOST=127.0.0.1:${OLLAMA_PORT} in
| the systemd override, then set the firewall to NAT-forward the
| public port to loopback.
+-----------------------------------------------------+
EOF
}
fw_status() {
check_nftables
"$NFT_CMD" list table inet "$TABLE_NAME" 2>&1 || echo "table not loaded."
}
fw_check() {
check_nftables
detect_ssh_port
detect_ollama_port
detect_openwebui_port
detect_hermes_port
detect_odysseus_port
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -c -f "$RULES_FILE"
}
# ── Dispatch ────────────────────────────────────────────────────────
main() {
local command="${1:-help}"
case "$command" in
start) fw_start ;;
stop) fw_stop ;;
restart|reload) fw_restart ;;
detect) fw_detect ;;
status) fw_status ;;
check|validate) fw_check ;;
help|--help|-h)
sed -n '1,60p' "$0"
;;
*)
die "Unknown command: $command\nRun '$0 help' for usage."
;;
esac
}
main "$@"

344
firewall/templates/cilium.sh Executable file
View File

@ -0,0 +1,344 @@
#!/usr/bin/env bash
#
# Name: cilium
# Description: Cilium eBPF datapath backend. Replaces nftables with eBPF programs attached at XDP and tc ingress/egress. Manages Cilium network policies via cilium-cli. Requires cilium + cilium-agent (or cilium-agent container). Installs cleanly via Helm or the official cilium-cli install script. Designed for eBPF-capable kernels (5.10+).
# Distro: arch,debian
# Services: cilium
#
# ============================================================================
# cilium.sh - Cilium eBPF Firewall Backend for SysDeck
# ============================================================================
#
# This template implements the standard SysDeck firewall template interface
# (start/stop/restart/detect/status/check) but the datapath is Cilium eBPF
# programs, NOT nftables rules. Cilium manages its own BPF maps and
# programs; nftables is left untouched (or explicitly flushed of any
# sysdeck-firewall table to avoid conflicts).
#
# Why Cilium over nftables for this backend:
# - eBPF programs run before the kernel networking stack (XDP) — packets
# are dropped before they consume socket buffers or conntrack entries.
# - Identity-based policy (CiliumIdentity labels) instead of IP-based.
# A pod/workload keeps its policy even when its IP changes.
# - L7 policy (HTTP/gRPC/Kafka) via Envoy sidecar — nftables cannot.
# - Observable via `cilium monitor`, `cilium metrics`, Hubble flow logs.
#
# Requirements:
# - Linux kernel 5.10+ (5.15+ recommended for latest BPF features)
# - CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_XDP_SOCKETS=y
# - cilium-cli >= 0.15 (or the `cilium` shell script — same binary)
# - For standalone (non-K8s) mode: cilium-agent binary installed at
# /usr/bin/cilium-agent (Arch: AUR cilium-agent; Debian: official repo
# or upstream .deb). Cilium 1.14+ supports standalone mode natively.
# - Helm 3 (optional — only if the operator chooses K8s-based install)
#
# Anti-requirements (why other backends are NOT this one):
# - UFW: frontend for nftables/iptables — does not use eBPF. Skipped
# per user directive.
# - fwbuilder: GUI rule generator — too complex for the average user.
# Skipped per user directive.
# - iptables-legacy / iptables-nft wrapper: legacy. The eBPF era has
# moved past it. Skipped per user directive.
#
# Subcommands (standard SysDeck firewall template interface):
# start install cilium (if missing) + apply the default policy
# stop delete all Cilium policies + disable cilium-agent
# restart stop + start
# detect print service detection summary (cilium version, kernel
# BPF features, agent status, endpoint count)
# status print cilium status + policy summary
# check cilium policy validate (syntax check)
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
# ============================================================================
# CONFIGURATION
# ============================================================================
CILIUM_BIN="${CILIUM_BIN:-cilium}"
CILIUM_AGENT_BIN="${CILIUM_AGENT_BIN:-/usr/bin/cilium-agent}"
CILIUM_AGENT_SVC="${CILIUM_AGENT_SVC:-cilium-agent.service}"
HELM_BIN="${HELM_BIN:-helm}"
# The default Cilium policy shipped with this template. It defines:
# - default-deny ingress + egress for all endpoints
# - allow DNS (UDP/TCP 53) to kube-dns / systemd-resolved
# - allow SSH (TCP 22) from anywhere
# - allow HTTP/HTTPS (TCP 80/443) from anywhere
# The operator can drop a custom policy at
# /etc/sysdeck/firewall/cilium-policy.yaml to override.
POLICY_FILE="${POLICY_FILE:-/etc/sysdeck/firewall/cilium-policy.yaml}"
DEFAULT_POLICY_FILE="/usr/share/sysdeck/firewall/policies/cilium-default.yaml"
# ============================================================================
log_info() { printf '[cilium] [INFO] %s\n' "$*" >&2; }
log_warn() { printf '[cilium] [WARN] %s\n' "$*" >&2; }
log_error() { printf '[cilium] [ERROR] %s\n' "$*" >&2; }
die() {
log_error "$*"
exit 1
}
have() { command -v "$1" >/dev/null 2>&1; }
# ============================================================================
# PRE-FLIGHT
# ============================================================================
check_root() {
[[ $EUID -eq 0 ]] || die "This action requires root. The cockpit superuser channel should provide it."
}
check_cilium_installed() {
if ! have "$CILIUM_BIN"; then
cat >&2 <<EOF
[cilium] cilium-cli is not installed.
Install on Arch: sudo pacman -S cilium-cli (or AUR: cilium-cli-bin)
Install on Debian: sudo apt install cilium-cli (or upstream .deb)
Install via Helm: helm repo add cilium https://helm.cilium.io/ \\
helm install cilium cilium/cilium -n kube-system
Once installed, re-run this template. The bridge firewall.py
install-backend subcommand can also install it via the packages module.
EOF
return 1
fi
return 0
}
check_kernel_bpf() {
local kver
kver="$(uname -r)"
local major="${kver%%.*}"
if [[ "$major" -lt 5 ]]; then
log_warn "Kernel $kver is older than 5.10 — Cilium may not function. Recommended: 5.15+."
fi
if ! have bpftool; then
log_warn "bpftool not found — BPF feature probing will be skipped."
fi
}
# ============================================================================
# DETECT
# ============================================================================
fw_detect() {
local os_name="unknown"
if [[ -f /etc/os-release ]]; then
. /etc/os-release
os_name="${PRETTY_NAME:-${NAME:-unknown}}"
fi
local kernel_ver
kernel_ver="$(uname -r)"
local cilium_ver="not installed"
if have "$CILIUM_BIN"; then
cilium_ver="$("$CILIUM_BIN" version --short 2>/dev/null | head -1 || echo 'unknown')"
fi
local agent_status="not running"
if systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then
agent_status="running"
fi
local endpoint_count="n/a"
if have "$CILIUM_BIN"; then
endpoint_count="$("$CILIUM_BIN" endpoint list -o json 2>/dev/null | \
python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 'n/a')"
fi
local bpf_features="unknown"
if have bpftool; then
if bpftool feature probe kernel 2>/dev/null | grep -q 'eBPF program_type fentry'; then
bpf_features="fentry,fexit, LSM (modern)"
else
bpf_features="legacy (kprobe-based)"
fi
fi
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Kernel: ${kernel_ver}
| BPF features: ${bpf_features}
| Template: cilium (eBPF datapath)
| cilium-cli: ${cilium_ver}
| cilium-agent: ${agent_status}
| Endpoints: ${endpoint_count}
| Policy file: ${POLICY_FILE}
+-----------------------------------------------------+
EOF
}
# ============================================================================
# START
# ============================================================================
fw_start() {
log_info "Starting Cilium eBPF firewall backend..."
check_root
check_cilium_installed || return 1
check_kernel_bpf
# Flush any leftover nftables inet firewall table from a previous
# 'custom'/'sysdeck-fw' backend. Cilium manages its own
# datapath — a stale nftables table would conflict.
if have nft; then
nft delete table inet firewall 2>/dev/null || true
log_info "Cleared any stale nftables 'firewall' table."
fi
# Ensure cilium-agent is running (standalone mode). On K8s, cilium
# runs as a DaemonSet and this is a no-op.
if [[ -x "$CILIUM_AGENT_BIN" ]] && ! systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then
log_info "Starting $CILIUM_AGENT_SVC ..."
systemctl start "$CILIUM_AGENT_SVC" || log_warn "cilium-agent did not start — assuming K8s DaemonSet mode."
fi
# Wait briefly for cilium API to be reachable.
local i
for i in 1 2 3 4 5; do
if "$CILIUM_BIN" status --brief >/dev/null 2>&1; then
break
fi
sleep 1
done
# Apply the default policy (or the operator's override).
local policy="$DEFAULT_POLICY_FILE"
if [[ -f "$POLICY_FILE" ]]; then
policy="$POLICY_FILE"
log_info "Using operator policy: $POLICY_FILE"
else
log_info "Using shipped default policy: $DEFAULT_POLICY_FILE"
fi
if [[ ! -f "$policy" ]]; then
die "Policy file not found: $policy. Reinstall the sysdeck package."
fi
log_info "Validating policy..."
if ! "$CILIUM_BIN" policy validate "$policy" 2>&1; then
die "Policy validation failed."
fi
log_info "Applying policy..."
if "$CILIUM_BIN" policy apply "$policy" 2>&1; then
log_info "Cilium policy applied successfully."
else
die "Policy apply failed."
fi
}
# ============================================================================
# STOP
# ============================================================================
fw_stop() {
log_info "Stopping Cilium eBPF firewall backend..."
check_root
if ! check_cilium_installed; then
log_warn "cilium-cli not installed — nothing to stop."
return 0
fi
# Delete all Cilium policies (reverts to default allow-all).
# This does NOT unload the BPF programs — cilium-agent keeps running
# so the operator can re-apply a policy without reinstalling.
"$CILIUM_BIN" policy delete --all 2>/dev/null || log_warn "policy delete --all failed (no policies loaded?)."
if [[ -x "$CILIUM_AGENT_BIN" ]] && systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then
log_info "Stopping $CILIUM_AGENT_SVC ..."
systemctl stop "$CILIUM_AGENT_SVC" || log_warn "could not stop cilium-agent."
fi
log_info "Cilium backend stopped (BPF programs removed when agent exits)."
}
# ============================================================================
# RESTART
# ============================================================================
fw_restart() {
fw_stop
sleep 1
fw_start
}
# ============================================================================
# STATUS
# ============================================================================
fw_status() {
if ! check_cilium_installed; then
echo "cilium-cli not installed."
return 0
fi
"$CILIUM_BIN" status 2>&1 || true
echo
echo "--- Policy summary ---"
"$CILIUM_BIN" policy get 2>&1 | head -40 || true
}
# ============================================================================
# CHECK
# ============================================================================
fw_check() {
check_cilium_installed || return 1
local policy="$DEFAULT_POLICY_FILE"
[[ -f "$POLICY_FILE" ]] && policy="$POLICY_FILE"
if [[ ! -f "$policy" ]]; then
die "Policy file not found: $policy"
fi
"$CILIUM_BIN" policy validate "$policy"
}
# ============================================================================
# DISPATCH
# ============================================================================
main() {
local command="${1:-help}"
case "$command" in
start) fw_start ;;
stop) fw_stop ;;
restart|reload) fw_restart ;;
detect) fw_detect ;;
status) fw_status ;;
check|validate) fw_check ;;
help|--help|-h)
cat <<EOF
cilium.sh — Cilium eBPF firewall backend for SysDeck
Usage: cilium.sh <start|stop|restart|detect|status|check>
Subcommands:
start install cilium (if missing) + apply the default policy
stop delete all Cilium policies + stop cilium-agent
restart stop + start
detect print detection summary (cilium version, kernel BPF features)
status print cilium status + policy summary
check validate the policy file
Environment variables:
CILIUM_BIN path to cilium CLI (default: cilium)
CILIUM_AGENT_BIN path to cilium-agent binary (default: /usr/bin/cilium-agent)
POLICY_FILE operator policy override (default: /etc/sysdeck/firewall/cilium-policy.yaml)
EOF
;;
*)
die "Unknown command: $command\nRun 'cilium.sh help' for usage."
;;
esac
}
main "$@"

1013
firewall/templates/no-services.sh Executable file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,420 @@
#!/usr/bin/env bash
#
# Name: public-webserver
# Description: Public server variant for a web stack. Exposes Varnish (80, cache front) and Caddy HTTPS (443) publicly; Caddy HTTP backend (8080) and MariaDB (3306) are bound to loopback only and never exposed. SSH rate-limited with auto-ban. Optional Caddy admin API (2019) is loopback-only. Designed for VPS web servers running a reverse-proxy/cache stack with a database.
# Distro: arch,debian
# Services: ssh,caddy,varnish,mariadb
#
# ============================================================================
# public-webserver.sh - SysDeck public-server variant: web stack
# ============================================================================
#
# v0.0.47 PORT-TOPOLOGY FIX. Per user directive: "the web server
# template, and vps template i setup the webserver on 8080 and
# varnish on 80 for an automatic cache environment." The v0.0.44
# template had the topology backwards — it exposed Caddy on :80 and
# Varnish on :8080. v0.0.47 flips it: Varnish is the public cache
# front on :80, Caddy HTTP backend lives on :8080 (loopback only),
# Caddy HTTPS lives on :443 (public, terminates TLS). The :8080
# backend is now ALWAYS loopback-only — the VARNISH_PUBLIC toggle
# has been removed because it was a footgun (the previous default
# exposed the backend cache-miss path to the internet, bypassing
# Varnish entirely).
#
# Topology:
# Client → :80 (Varnish cache frontend, public — HTTP redirect to :443)
# Client → :443 (Caddy HTTPS, public — terminates TLS)
# Varnish → :8080 (Caddy backend, loopback only — Varnish cache miss)
#
# Public TCP ports:
# 22 SSH (rate-limited, auto-ban brute force)
# 80 HTTP (Varnish frontend — must be public for ACME http-01
# challenge + redirect to HTTPS)
# 443 HTTPS (Caddy — terminates TLS)
#
# Loopback-only TCP ports (NEVER exposed — firewall enforces):
# 8080 Caddy HTTP backend (Varnish cache-miss target)
# 3306 MariaDB
# 2019 Caddy admin API
#
# The firewall enforces the loopback-only policy at the kernel level:
# even if Caddy or MariaDB is misconfigured to listen on 0.0.0.0, the
# firewall drops the packet before it reaches the daemon. This is
# defense in depth — the daemon's own bind is the primary control.
#
# Standard template interface (start/stop/restart/detect/status/check).
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
SCRIPT_NAME="public-webserver"
SCRIPT_VERSION="0.0.47"
TABLE_NAME="firewall"
NFT_CMD="${NFT_CMD:-nft}"
RULES_FILE="${RULES_FILE:-/tmp/sysdeck-firewall-public-webserver.rules}"
# ── Configurable ports (auto-detected) ──────────────────────────────
# v0.0.47: Varnish is the public cache front on :80; Caddy HTTP backend
# is loopback-only on :8080. Caddy HTTPS terminates TLS on :443.
SSH_PORT="${SYSDECK_PUBLIC_WEBSERVER_SSH_PORT:-22}"
VARNISH_PORT="${SYSDECK_PUBLIC_WEBSERVER_VARNISH:-80}"
CADDY_HTTP_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTP:-8080}"
CADDY_HTTPS_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTPS:-443}"
CADDY_ADMIN_PORT="${SYSDECK_PUBLIC_WEBSERVER_CADDY_ADMIN:-2019}"
MARIADB_PORT="${SYSDECK_PUBLIC_WEBSERVER_MARIADB:-3306}"
# ── Rate limits ─────────────────────────────────────────────────────
SSH_RATE_LIMIT="4/minute"
SSH_BURST="8"
SSH_BAN_TIMEOUT="3600s"
HTTP_RATE_LIMIT="100/second"
HTTP_BURST="200"
HTTPS_RATE_LIMIT="100/second"
HTTPS_BURST="200"
# ── Log ─────────────────────────────────────────────────────────────
LOG_PREFIX="[NFT-DROP] "
LOG_RATE="5/second"
LOG_BURST="10"
# ── Bogons ──────────────────────────────────────────────────────────
BOGONS_V4="0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4"
BOGONS_V6="::1/128, fc00::/7, fe80::/10, ff00::/8"
log_info() { printf '[%s] [INFO] %s\n' "$SCRIPT_NAME" "$*" >&2; }
log_debug() { printf '[%s] [DEBUG] %s\n' "$SCRIPT_NAME" "$*" >&2 || true; }
die() { printf '[%s] [FATAL] %s\n' "$SCRIPT_NAME" "$*" >&2; exit 1; }
check_nftables() {
command -v "$NFT_CMD" &>/dev/null || die "nftables not installed. Install with: pacman -S nftables / apt install nftables"
}
# ── Service detection ───────────────────────────────────────────────
detect_ssh_port() {
local cfg="/etc/ssh/sshd_config"
if [[ -f "$cfg" ]]; then
local port
port=$(awk '/^[[:space:]]*Port[[:space:]]+/ { print $2; exit }' "$cfg" 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then SSH_PORT="$port"; return; fi
fi
SSH_PORT="22"
}
# Parse Caddy's Caddyfile for explicit port bindings. Caddy's HTTPS
# default is 443 — the operator can override with `:8443` or similar
# in the site block. The HTTP backend port (8080) is what Varnish
# connects to on loopback; Caddy should bind it to 127.0.0.1:8080
# (or 0.0.0.0:8080 — the firewall enforces loopback-only access).
# We surface any non-default bindings but do NOT change the firewall
# ports — the operator is expected to set
# SYSDECK_PUBLIC_WEBSERVER_CADDY_HTTP / _HTTPS env vars or edit the
# script if they want the firewall to match.
detect_caddy_ports() {
local cfg="/etc/caddy/Caddyfile"
if [[ -f "$cfg" ]]; then
# Just log the bindings — don't override the env vars.
local bindings
bindings=$(grep -E '^\s*:?[a-z]*://|^[[:space:]]*:[0-9]+' "$cfg" 2>/dev/null | head -5 || true)
if [[ -n "${bindings:-}" ]]; then
log_debug "Caddyfile site bindings:\n${bindings}"
fi
fi
# Caddy admin API port — defaults to 2019 (loopback only).
local admin_cfg="/etc/caddy/caddy-api.json"
if [[ -f "$admin_cfg" ]]; then
local port
port=$(grep -oE '"listen"[[:space:]]*:[[:space:]]*"localhost:[0-9]+"|"listen"[[:space:]]*:[[:space:]]*":?[0-9]+"' "$admin_cfg" 2>/dev/null \
| grep -oE '[0-9]+' | head -1 || true)
if [[ -n "${port:-}" ]]; then CADDY_ADMIN_PORT="$port"; fi
fi
}
# Parse Varnish's listen port. Varnish's systemd unit usually passes
# -a :80 on the command line for a cache-front-of-Caddy setup; the
# VCL itself just defines the backend. We check the systemd unit
# override first, then /etc/default/varnish (VARNISH_LISTEN_PORT),
# then fall back to 80 — the standard production topology where
# Varnish sits in front of Caddy on :80 and Caddy backend listens
# on :8080 (loopback only).
detect_varnish_port() {
# Check /etc/systemd/system/varnish.service.d/*.conf for -a :PORT
local unit_dir="/etc/systemd/system/varnish.service.d"
if [[ -d "$unit_dir" ]]; then
local port
port=$(grep -rhoE '\-a[[:space:]]*:?[a-z0-9.]*:([0-9]+)' "$unit_dir" 2>/dev/null \
| grep -oE '[0-9]+$' | head -1 || true)
if [[ -n "${port:-}" ]]; then VARNISH_PORT="$port"; return; fi
fi
# Debian/Ubuntu: /etc/default/varnish VARNISH_LISTEN_PORT=...
if [[ -f /etc/default/varnish ]]; then
local port
port=$(awk -F= '/^VARNISH_LISTEN_PORT=/ { gsub(/["'\'' \t]/, "", $2); print $2; exit }' /etc/default/varnish 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then VARNISH_PORT="$port"; return; fi
fi
# Default for the public-webserver template is 80 (per v0.0.47
# user directive — Varnish is the public cache front).
: "${VARNISH_PORT:=80}"
}
# Parse MariaDB config for the listen port. The config is split across
# multiple files in /etc/mysql/mariadb.conf.d/ on Debian. We look for
# [mysqld] port = ... in any *.cnf file there.
detect_mariadb_port() {
local cfg_dirs=("/etc/mysql/mariadb.conf.d" "/etc/mysql" "/etc")
local cfg_files=(
"$cfg_dirs[0]/50-server.cnf"
"$cfg_dirs[0]/60-galera.cnf"
"/etc/my.cnf"
"/etc/mysql/my.cnf"
)
# Also check the .d directories.
while IFS= read -r -d '' f; do cfg_files+=("$f"); done < <(
find "${cfg_dirs[@]}" -name '*.cnf' -print0 2>/dev/null || true
)
local f
for f in "${cfg_files[@]}"; do
[[ -f "$f" ]] || continue
local port
port=$(awk '
/^\[mysqld\]/ { in_m=1; next }
/^\[/ { in_m=0 }
in_m && /^[[:space:]]*port[[:space:]]*=/ { gsub(/[^0-9]/, "", $3); print $3; exit }
' "$f" 2>/dev/null || true)
if [[ -n "${port:-}" ]]; then MARIADB_PORT="$port"; return; fi
done
MARIADB_PORT="3306"
}
# ── Build ruleset ───────────────────────────────────────────────────
build_ruleset() {
cat <<RULESET
#!/usr/sbin/nft -f
flush ruleset
table inet ${TABLE_NAME} {
# ── Sets ──────────────────────────────────────────────────────
set ssh_abuse {
type ipv4_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set ssh_abuse6 {
type ipv6_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set bogons_v4 {
type ipv4_addr
flags interval
elements = { ${BOGONS_V4} }
}
set bogons_v6 {
type ipv6_addr
flags interval
elements = { ${BOGONS_V6} }
}
# ── Chains ────────────────────────────────────────────────────
chain input {
type filter hook input priority 0; policy drop;
ip saddr @bogons_v4 drop
ip6 saddr @bogons_v6 drop
ct state vmap { established: accept, related: accept }
iifname "lo" accept
ct state invalid drop
# ICMP / ICMPv6 essentials.
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, packet-too-big, destination-unreachable, time-exceeded } accept
# ── SSH (port ${SSH_PORT}) ─────────────────────────────────
ip saddr @ssh_abuse drop
ip6 saddr @ssh_abuse6 drop
tcp dport ${SSH_PORT} ct state new \
limit rate ${SSH_RATE_LIMIT} burst ${SSH_BURST} packets accept
tcp dport ${SSH_PORT} ct state new \
add @ssh_abuse { ip saddr } \
add @ssh_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}ssh-rate " drop
# ── HTTP (Varnish cache front ${VARNISH_PORT}) ─────────────
# Varnish is the public cache frontend on :80 — handles ACME
# http-01 challenge + redirects to :443 for HTTPS traffic.
tcp dport ${VARNISH_PORT} ct state new \
limit rate ${HTTP_RATE_LIMIT} burst ${HTTP_BURST} packets accept
tcp dport ${VARNISH_PORT} ct state new \
log prefix "${LOG_PREFIX}http-rate " drop
# ── HTTPS (Caddy ${CADDY_HTTPS_PORT}) ──────────────────────
# Caddy terminates TLS on :443. Varnish also forwards pinned
# HTTPS traffic here via PROXY protocol (operator configures
# that in the VCL — the firewall doesn't need to know).
tcp dport ${CADDY_HTTPS_PORT} ct state new \
limit rate ${HTTPS_RATE_LIMIT} burst ${HTTPS_BURST} packets accept
tcp dport ${CADDY_HTTPS_PORT} ct state new \
log prefix "${LOG_PREFIX}https-rate " drop
# Drop invalid TCP flag combinations.
tcp flags & (fin|syn|rst|psh|ack|urg) == 0 drop
tcp flags & (fin|syn) == (fin|syn) drop
tcp flags & (syn|rst) == (syn|rst) drop
tcp flags & (fin|rst) == (fin|rst) drop
tcp flags & (psh|fin) == (psh|fin) drop
# ── DEFENSE IN DEPTH ───────────────────────────────────────
# Even if Caddy HTTP backend, MariaDB, or Caddy admin is
# misconfigured to bind 0.0.0.0, these rules DROP the packet
# before it reaches the daemon. The default policy is drop —
# these explicit drops just LOG the attempt so the operator
# sees misconfiguration. v0.0.47: :8080 (Caddy HTTP backend)
# is now in this defense-in-depth block — it must NEVER be
# reachable from outside loopback.
tcp dport ${CADDY_HTTP_PORT} ip saddr != 127.0.0.0/8 \
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
log prefix "${LOG_PREFIX}caddy-http-backend-blocked " drop
tcp dport ${CADDY_HTTP_PORT} ip6 saddr != ::1 \
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
log prefix "${LOG_PREFIX}caddy-http-backend6-blocked " drop
tcp dport ${MARIADB_PORT} ip saddr != 127.0.0.0/8 \
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
log prefix "${LOG_PREFIX}mariadb-blocked " drop
tcp dport ${MARIADB_PORT} ip6 saddr != ::1 \
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
log prefix "${LOG_PREFIX}mariadb6-blocked " drop
tcp dport ${CADDY_ADMIN_PORT} ip saddr != 127.0.0.0/8 \
limit rate ${LOG_RATE} burst ${LOG_BURST} packets \
log prefix "${LOG_PREFIX}caddy-admin-blocked " drop
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}input "
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
RULESET
}
# ── Actions ─────────────────────────────────────────────────────────
fw_start() {
check_nftables
detect_ssh_port
detect_caddy_ports
detect_varnish_port
detect_mariadb_port
log_info "Starting public-webserver firewall:"
log_info " ssh=${SSH_PORT} (public, rate-limited)"
log_info " varnish=${VARNISH_PORT} (public cache front, HTTP redirect to HTTPS)"
log_info " caddy-https=${CADDY_HTTPS_PORT} (public, terminates TLS)"
log_info " caddy-http-backend=${CADDY_HTTP_PORT} (loopback only — Varnish cache-miss target)"
log_info " caddy-admin=${CADDY_ADMIN_PORT} (loopback only)"
log_info " mariadb=${MARIADB_PORT} (loopback only, defense-in-depth drop)"
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded."
}
fw_stop() {
check_nftables
log_info "Stopping public-webserver firewall"
"$NFT_CMD" delete table inet "$TABLE_NAME" 2>/dev/null || true
rm -f "$RULES_FILE"
}
fw_restart() {
fw_stop
fw_start
}
fw_detect() {
detect_ssh_port
detect_caddy_ports
detect_varnish_port
detect_mariadb_port
local os_name="unknown"
if [[ -f /etc/os-release ]]; then . /etc/os-release; os_name="${PRETTY_NAME:-${NAME:-unknown}}"; fi
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Template: public-webserver (Varnish → Caddy + MariaDB)
|
| Public ports:
| ${SSH_PORT}/tcp SSH (rate-limited, auto-ban)
| ${VARNISH_PORT}/tcp Varnish cache front (HTTP, redirect to HTTPS)
| ${CADDY_HTTPS_PORT}/tcp Caddy HTTPS (terminates TLS)
|
| Loopback-only ports (defense-in-depth drop at firewall):
| ${CADDY_HTTP_PORT}/tcp Caddy HTTP backend (Varnish cache-miss target)
| ${MARIADB_PORT}/tcp MariaDB
| ${CADDY_ADMIN_PORT}/tcp Caddy admin API
|
| Topology (v0.0.47 — cache-front-of-origin):
| Client → :${VARNISH_PORT} (Varnish cache front, public)
| Client → :${CADDY_HTTPS_PORT} (Caddy HTTPS, public)
| Varnish → :${CADDY_HTTP_PORT} (Caddy HTTP backend, loopback only)
|
| Config files:
| SSH: /etc/ssh/sshd_config
| Caddy: /etc/caddy/Caddyfile
| Varnish: /etc/systemd/system/varnish.service.d/*.conf
| /etc/default/varnish (VARNISH_LISTEN_PORT=)
| MariaDB: /etc/mysql/mariadb.conf.d/*.cnf
+-----------------------------------------------------+
EOF
}
fw_status() {
check_nftables
"$NFT_CMD" list table inet "$TABLE_NAME" 2>&1 || echo "table not loaded."
}
fw_check() {
check_nftables
detect_ssh_port
detect_caddy_ports
detect_varnish_port
detect_mariadb_port
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -c -f "$RULES_FILE"
}
# ── Dispatch ────────────────────────────────────────────────────────
main() {
local command="${1:-help}"
case "$command" in
start) fw_start ;;
stop) fw_stop ;;
restart|reload) fw_restart ;;
detect) fw_detect ;;
status) fw_status ;;
check|validate) fw_check ;;
help|--help|-h)
sed -n '1,60p' "$0"
;;
*)
die "Unknown command: $command\nRun '$0 help' for usage."
;;
esac
}
main "$@"

View File

@ -0,0 +1,364 @@
#!/usr/bin/env bash
#
# Name: remote-admin
# Description: Public server variant for remote administration. Exposes SSH (22) and Cockpit (9090) with aggressive rate limiting, port-scan detection, and SSH brute-force auto-ban. Designed for VPS / cloud hosts where the operator needs remote shell + web admin access from anywhere, but wants the surface protected against brute-force and scan traffic.
# Distro: arch,debian
# Services: ssh,cockpit
#
# ============================================================================
# remote-admin.sh - SysDeck public-server variant: remote admin
# ============================================================================
#
# v0.0.44 NEW. Per user directive: "another thing the firewall module
# needs is a few public server variants. like: remote admin enabled
# ssh and cockpit ...". This template targets the remote-admin use
# case — the box is reachable from the internet on two admin ports
# only: SSH (22) and Cockpit (9090). Everything else is dropped.
#
# What this template does:
# - inet table "firewall" (unified IPv4/IPv6)
# - input default policy: drop
# - forward default policy: drop
# - output default policy: accept
# - accept established + related (ct state)
# - accept loopback
# - accept ICMP echo-request + echo-reply + needed ICMPv6 (NDP)
# - accept TCP 22 (SSH) with rate limit (4 new conns / minute / source)
# — sources exceeding the rate are added to the ssh_abuse set
# (1 hour timeout) and dropped
# - accept TCP 9090 (Cockpit web UI) with rate limit
# (10 new conns / minute / source)
# - drop invalid TCP flag combos (NULL / XMAS / SYN+FIN / SYN+RST)
# - drop fragments
# - drop bogons on input (martian IPv4 + IPv6 docs)
# - log dropped packets at 5/second burst 10
#
# Detection:
# - Reads SSH_PORT from /etc/ssh/sshd_config (falls back to 22)
# - Reads Cockpit port from /etc/cockpit/cockpit.conf
# (Listen = ... directive, falls back to 9090)
# - Both ports are surfaced in the detect output so the panel
# can show them in the Service/Port editor.
#
# Hardening notes:
# - No 0.0.0.0 listener assumption (per v0.0.43 directive — this
# is a FIREWALL template, not a listener config; the firewall
# itself never binds any address).
# - SYNPROXY NOT enabled here — the box only listens on 2 ports,
# and SYNPROXY adds complexity for little benefit when the rate
# limiter already handles SYN floods.
# - Bogon list is small (just RFC 1918 + 169.254 + 127.0.0.0/8 +
# 0.0.0.0/8 + IPv6 ::1 + fc00::/7 + fe80::/10) — keeps the ruleset
# short for easy review.
#
# Standard template interface (start/stop/restart/detect/status/check)
# implemented so the existing bridge.firewall.apply/stop/restart/detect/
# check subcommands work unchanged.
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
SCRIPT_NAME="remote-admin"
SCRIPT_VERSION="0.0.44"
TABLE_NAME="firewall"
NFT_CMD="${NFT_CMD:-nft}"
RULES_FILE="${RULES_FILE:-/tmp/sysdeck-firewall-remote-admin.rules}"
# ── Configurable ports (auto-detected, can be overridden) ───────────
# These are read by the detect / start actions. The operator can also
# override by editing this script's variables directly.
SSH_PORT="${SYSDECK_REMOTE_ADMIN_SSH_PORT:-22}"
COCKPIT_PORT="${SYSDECK_REMOTE_ADMIN_COCKPIT_PORT:-9090}"
# ── Rate limits ─────────────────────────────────────────────────────
SSH_RATE_LIMIT="4/minute"
SSH_BURST="8"
SSH_BAN_TIMEOUT="3600s" # 1 hour
COCKPIT_RATE_LIMIT="10/minute"
COCKPIT_BURST="20"
COCKPIT_BAN_TIMEOUT="600s" # 10 minutes
# ── Log ─────────────────────────────────────────────────────────────
LOG_PREFIX="[NFT-DROP] "
LOG_RATE="5/second"
LOG_BURST="10"
# ── Bogons (small list — keep short for reviewability) ──────────────
BOGONS_V4="0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4"
BOGONS_V6="::1/128, fc00::/7, fe80::/10, ff00::/8"
log_info() { printf '[%s] [INFO] %s\n' "$SCRIPT_NAME" "$*" >&2; }
log_debug() { printf '[%s] [DEBUG] %s\n' "$SCRIPT_NAME" "$*" >&2 || true; }
die() { printf '[%s] [FATAL] %s\n' "$SCRIPT_NAME" "$*" >&2; exit 1; }
check_nftables() {
command -v "$NFT_CMD" &>/dev/null || die "nftables not installed. Install with: pacman -S nftables / apt install nftables"
}
# ── Service detection ───────────────────────────────────────────────
#
# Auto-detect SSH port from /etc/ssh/sshd_config. Falls back to 22 if
# the file is absent or the Port directive is missing/commented out.
detect_ssh_port() {
local cfg="/etc/ssh/sshd_config"
if [[ -f "$cfg" ]]; then
# Match: Port 2222 (ignoring commented #Port lines)
local port
port=$(awk '
/^[[:space:]]*Port[[:space:]]+/ { print $2; exit }
' "$cfg" 2>/dev/null || true)
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
SSH_PORT="$port"
return
fi
fi
SSH_PORT="22"
}
# Auto-detect Cockpit port from /etc/cockpit/cockpit.conf. Cockpit's
# listener configuration uses a [Listen] section with a `Port = ...`
# key, OR a `Listen = addr:port` directive in [WebService]. We try
# both. Falls back to 9090.
detect_cockpit_port() {
local cfg="/etc/cockpit/cockpit.conf"
if [[ -f "$cfg" ]]; then
local port
# Try [WebService] Listen = 9090 OR Listen = 0.0.0.0:9090
# (Note: we explicitly do NOT support 0.0.0.0 binds per the
# v0.0.43 directive — but if the operator has already set
# one, we extract just the port.)
port=$(awk '
/^\[WebService\]/ { in_ws=1; next }
/^\[/ { in_ws=0 }
in_ws && /^[[:space:]]*Listen[[:space:]]*=/ {
v=$3
# Strip "addr:" prefix if present
sub(/^.*:/, "", v)
print v
exit
}
' "$cfg" 2>/dev/null || true)
# Try [Socket] section as well
if [[ -z "${port:-}" ]]; then
port=$(awk '
/^\[Socket\]/ { in_s=1; next }
/^\[/ { in_s=0 }
in_s && /^[[:space:]]*Port[[:space:]]*=/ { print $3; exit }
' "$cfg" 2>/dev/null || true)
fi
if [[ -n "${port:-}" && "$port" =~ ^[0-9]+$ ]]; then
COCKPIT_PORT="$port"
return
fi
fi
COCKPIT_PORT="9090"
}
# ── Build ruleset ───────────────────────────────────────────────────
build_ruleset() {
cat <<RULESET
#!/usr/sbin/nft -f
flush ruleset
table inet ${TABLE_NAME} {
# ── Sets ──────────────────────────────────────────────────────
# SSH brute-force ban list. Populated by the rate-limit rule
# when a source exceeds ${SSH_RATE_LIMIT}. Entries expire after
# ${SSH_BAN_TIMEOUT}.
set ssh_abuse {
type ipv4_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
set ssh_abuse6 {
type ipv6_addr
flags timeout
timeout ${SSH_BAN_TIMEOUT}
}
# Cockpit rate-limit ban list.
set cockpit_abuse {
type ipv4_addr
flags timeout
timeout ${COCKPIT_BAN_TIMEOUT}
}
set cockpit_abuse6 {
type ipv6_addr
flags timeout
timeout ${COCKPIT_BAN_TIMEOUT}
}
# Bogon source addresses (martian / RFC 1918 / etc).
set bogons_v4 {
type ipv4_addr
flags interval
elements = { ${BOGONS_V4} }
}
set bogons_v6 {
type ipv6_addr
flags interval
elements = { ${BOGONS_V6} }
}
# ── Chains ────────────────────────────────────────────────────
chain input {
type filter hook input priority 0; policy drop;
# Drop bogons first.
ip saddr @bogons_v4 drop
ip6 saddr @bogons_v6 drop
# Established / related — accept.
ct state vmap { established: accept, related: accept }
# Loopback.
iifname "lo" accept
# Invalid.
ct state invalid drop
# ICMP / ICMPv6 essentials.
ip protocol icmp icmp type { echo-request, echo-reply, destination-unreachable, time-exceeded } accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, echo-reply, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert, packet-too-big, destination-unreachable, time-exceeded } accept
# ── SSH (port ${SSH_PORT}) ─────────────────────────────────
# If source is in the ban set, drop.
ip saddr @ssh_abuse drop
ip6 saddr @ssh_abuse6 drop
# Rate-limit new SSH connections. Sources exceeding
# ${SSH_RATE_LIMIT} burst ${SSH_BURST} get added to ssh_abuse.
tcp dport ${SSH_PORT} ct state new \
limit rate ${SSH_RATE_LIMIT} burst ${SSH_BURST} packets accept
tcp dport ${SSH_PORT} ct state new \
add @ssh_abuse { ip saddr } \
add @ssh_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}ssh-rate " drop
# ── Cockpit (port ${COCKPIT_PORT}) ────────────────────────
ip saddr @cockpit_abuse drop
ip6 saddr @cockpit_abuse6 drop
tcp dport ${COCKPIT_PORT} ct state new \
limit rate ${COCKPIT_RATE_LIMIT} burst ${COCKPIT_BURST} packets accept
tcp dport ${COCKPIT_PORT} ct state new \
add @cockpit_abuse { ip saddr } \
add @cockpit_abuse6 { ip6 saddr } \
log prefix "${LOG_PREFIX}cockpit-rate " drop
# Drop invalid TCP flag combinations.
tcp flags & (fin|syn|rst|psh|ack|urg) == 0 drop
tcp flags & (fin|syn) == (fin|syn) drop
tcp flags & (syn|rst) == (syn|rst) drop
tcp flags & (fin|rst) == (fin|rst) drop
tcp flags & (psh|fin) == (psh|fin) drop
tcp flags & (urg|psh|ack|fin|rst|syn) == (urg|psh|ack|fin|rst|syn) drop
# Log + drop everything else.
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}input "
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
limit rate ${LOG_RATE} burst ${LOG_BURST} packets log prefix "${LOG_PREFIX}forward "
drop
}
chain output {
type filter hook output priority 0; policy accept;
}
}
RULESET
}
# ── Actions ─────────────────────────────────────────────────────────
fw_start() {
check_nftables
detect_ssh_port
detect_cockpit_port
log_info "Starting remote-admin firewall: ssh=${SSH_PORT}, cockpit=${COCKPIT_PORT}"
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -f "$RULES_FILE"
log_info "Firewall loaded."
}
fw_stop() {
check_nftables
log_info "Stopping remote-admin firewall"
"$NFT_CMD" delete table inet "$TABLE_NAME" 2>/dev/null || true
rm -f "$RULES_FILE"
}
fw_restart() {
fw_stop
fw_start
}
fw_detect() {
detect_ssh_port
detect_cockpit_port
local os_name="unknown"
if [[ -f /etc/os-release ]]; then . /etc/os-release; os_name="${PRETTY_NAME:-${NAME:-unknown}}"; fi
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Template: remote-admin (SSH + Cockpit)
| SSH port: ${SSH_PORT} (from /etc/ssh/sshd_config)
| Cockpit port: ${COCKPIT_PORT} (from /etc/cockpit/cockpit.conf)
| Rate limits:
| SSH: ${SSH_RATE_LIMIT} burst ${SSH_BURST} (ban ${SSH_BAN_TIMEOUT})
| Cockpit: ${COCKPIT_RATE_LIMIT} burst ${COCKPIT_BURST} (ban ${COCKPIT_BAN_TIMEOUT})
+-----------------------------------------------------+
EOF
}
fw_status() {
check_nftables
"$NFT_CMD" list table inet "$TABLE_NAME" 2>&1 || echo "table not loaded."
}
fw_check() {
check_nftables
detect_ssh_port
detect_cockpit_port
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -c -f "$RULES_FILE"
}
# ── Dispatch ────────────────────────────────────────────────────────
main() {
local command="${1:-help}"
case "$command" in
start) fw_start ;;
stop) fw_stop ;;
restart|reload) fw_restart ;;
detect) fw_detect ;;
status) fw_status ;;
check|validate) fw_check ;;
help|--help|-h)
sed -n '1,60p' "$0"
;;
*)
die "Unknown command: $command\nRun '$0 help' for usage."
;;
esac
}
main "$@"

479
firewall/templates/sysdeck-fw.sh Executable file
View File

@ -0,0 +1,479 @@
#!/usr/bin/env bash
#
# Name: sysdeck-fw
# Description: SysDeck FW — unified nftables zone firewall. Takes influence from Smoothwall Express (RED/ORANGE/GREEN/BLUE color-zone model) and IPFire (source-verified outbound + AirWall isolation + flow offload) under our own identifier. Modern nftables syntax with sets, verdict maps, synproxy, bogon filtering, connlimit, optional flow offload, and DMZ port-forwarding. Designed for eBPF-capable kernels with XDP integration points.
# Distro: arch,debian
# Services: ssh
#
# ============================================================================
# sysdeck-fw.sh - SysDeck Unified nftables Zone Firewall
# ============================================================================
#
# v0.0.37 UNIFIED ZONE FIREWALL. This template takes influence from
# two open-source firewall distributions — Smoothwall Express
# (RED/ORANGE/GREEN/BLUE color-zone model) and IPFire (source-verified
# outbound + AirWall isolation + flow offload). We do not ship a
# template called "smoothwall" or "ipfire" — those are other
# projects' trademarks. The unified SysDeck FW template preserves the
# feature sets we took influence from under our own identifier.
#
# What this template takes influence from:
#
# FROM THE ZONE MODEL (takes influence from Smoothwall Express):
# RED untrusted Internet (WAN)
# GREEN trusted LAN
# ORANGE DMZ (servers exposed to RED but isolated from GREEN)
# BLUE wireless LAN (semi-trusted, isolated from GREEN)
#
# FROM SOURCE-VERIFIED OUTBOUND (takes influence from IPFire):
# Each non-RED zone has a configured CIDR. Outbound traffic from
# the zone is only accepted if the source IP matches the CIDR.
# This defeats IP-spoofing inside the firewall (a compromised
# host on BLUE cannot pretend to be on GREEN by spoofing a GREEN
# IP — the forward chain checks both iifname AND saddr).
#
# FROM AIRWALL ISOLATION (takes influence from IPFire):
# BLUE (WiFi) is treated as semi-trusted. By default, BLUE cannot
# reach GREEN at all — not even for DNS. The operator can disable
# AirWall via AIRWALL=false in the config file to allow BLUE ->
# GREEN DNS only (useful for a unified resolver).
#
# FROM FLOW OFFLOAD (takes influence from IPFire):
# Optional hardware acceleration. When FLOW_OFFLOAD=true, the
# ruleset includes a flowtable that accelerates established TCP/UDP
# connections. Requires a NIC driver with flow offload support.
#
# FROM ZONE FORWARDING MATRIX (takes influence from both):
# GREEN -> RED allow (LAN -> Internet)
# GREEN -> ORANGE allow (LAN -> DMZ)
# GREEN -> BLUE allow (LAN -> WiFi)
# BLUE -> RED allow (WiFi -> Internet; AirWall outbound OK)
# BLUE -> ORANGE allow (WiFi -> DMZ)
# BLUE -> GREEN DENY (AirWall — even DNS blocked, unless
# AIRWALL=false, in which case DNS only)
# ORANGE -> RED allow (DMZ -> Internet for updates)
# ORANGE -> GREEN DENY
# ORANGE -> BLUE DENY
# RED -> GREEN DENY
# RED -> BLUE DENY
# RED -> ORANGE only via DMZ_FORWARDS port-forward rules
#
# FROM DMZ PORT-FORWARDING (takes influence from both):
# Operator configures DMZ_FORWARDS="extport:intport:orangeip,..."
# The template emits both DNAT (prerouting) and the forward-allow
# rule. Example: "80:80:10.0.0.10,443:443:10.0.0.10".
#
# ANTI-REQUIREMENTS (per user directive v0.0.36 + v0.0.37):
# - UFW: nftables frontend, no eBPF. Skipped.
# - fwbuilder: GUI rule generator, too complex. Skipped.
# - iptables-legacy: pre-nftables, old. Skipped.
# - iptables-nft: compatibility wrapper, adds no value over native
# nftables. Skipped.
# - Shorewall: iptables-based, no eBPF integration points. Skipped.
# - We do NOT ship a template called "smoothwall" or "ipfire" —
# those are trademarks of their respective projects. We took
# influence from them for sysdeck-fw. This is SysDeck FW.
#
# Requirements:
# - Linux kernel 6.6+ with CONFIG_NF_TABLES=y
# - nftables >= 1.0.0
#
# Configuration:
# Edit the variables below, or drop a config file at
# /etc/sysdeck/firewall/sysdeck-fw.conf to override. The config
# file is sourced if present.
#
# ============================================================================
set -euo pipefail
IFS=$'\n\t'
TABLE_NAME="firewall"
# ============================================================================
# CONFIGURATION
# ============================================================================
# Zone interfaces. RED (WAN) is auto-detected from the default route
# if RED_IF is empty. GREEN/ORANGE/BLUE are optional — zones without
# an interface are skipped at start.
RED_IF="${RED_IF:-}"
GREEN_IF="${GREEN_IF:-}"
ORANGE_IF="${ORANGE_IF:-}"
BLUE_IF="${BLUE_IF:-}"
# Zone CIDRs (used for source-verified outbound). Each non-RED zone
# has a configured CIDR; outbound from the zone is only accepted if
# the source IP matches. Defeats IP spoofing inside the firewall.
RED_CIDR="${RED_CIDR:-}" # WAN — leave blank
GREEN_CIDR="${GREEN_CIDR:-10.0.0.0/24}"
ORANGE_CIDR="${ORANGE_CIDR:-10.0.1.0/24}"
BLUE_CIDR="${BLUE_CIDR:-192.168.1.0/24}"
# Inbound ports per zone (CSV). Empty = no inbound beyond established.
RED_IN_TCP="${RED_IN_TCP:-22}" # SSH from Internet
RED_IN_UDP="${RED_IN_UDP:-}" # e.g. 51820 for WireGuard
GREEN_IN_TCP="${GREEN_IN_TCP:-22,80,443}"
GREEN_IN_UDP="${GREEN_IN_UDP:-53}" # DNS for LAN clients
ORANGE_IN_TCP="${ORANGE_IN_TCP:-80,443}"
ORANGE_IN_UDP="${ORANGE_IN_UDP:-}"
BLUE_IN_TCP="${BLUE_IN_TCP:-22,80,443}"
BLUE_IN_UDP="${BLUE_IN_UDP:-53}"
# AirWall: when true (default), BLUE is fully isolated from GREEN.
# Set to "false" to allow BLUE -> GREEN DNS only (for a unified resolver).
AIRWALL="${AIRWALL:-true}"
# Flow offload (hardware acceleration). Set to "true" to enable.
# Requires a NIC driver with flow offload support.
FLOW_OFFLOAD="${FLOW_OFFLOAD:-false}"
# DMZ port-forwarding: RED extport -> ORANGE host:intport (CSV).
# Example: "80:80:10.0.0.10,443:443:10.0.0.10"
DMZ_FORWARDS="${DMZ_FORWARDS:-}"
# Load operator overrides if present.
CONFIG_FILE="${CONFIG_FILE:-/etc/sysdeck/firewall/sysdeck-fw.conf}"
if [[ -f "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
source "$CONFIG_FILE"
fi
NFT_CMD="${NFT_CMD:-nft}"
# ============================================================================
log_info() { printf '[sysdeck-fw] [INFO] %s\n' "$*" >&2; }
log_warn() { printf '[sysdeck-fw] [WARN] %s\n' "$*" >&2; }
log_error() { printf '[sysdeck-fw] [ERROR] %s\n' "$*" >&2; }
die() { log_error "$*"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
# ============================================================================
# PRE-FLIGHT
# ============================================================================
check_root() { [[ $EUID -eq 0 ]] || die "Requires root (cockpit superuser channel)."; }
check_nftables() { have "$NFT_CMD" || die "nftables not installed. Install: pacman -S nftables / apt install nftables."; }
# Auto-detect the RED (WAN) interface from the default route if not set.
autodetect_red() {
if [[ -z "$RED_IF" ]]; then
RED_IF="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')"
[[ -z "$RED_IF" ]] && die "Could not auto-detect RED (WAN) interface. Set RED_IF in $CONFIG_FILE."
log_info "Auto-detected RED interface: $RED_IF"
fi
}
# ============================================================================
# RULE GENERATION
# ============================================================================
csv_to_nft_set() {
# Emit a space-separated list of items from a CSV, skipping empties.
local csv="$1"
[[ -z "$csv" ]] && return 0
echo "$csv" | tr ',' ' '
}
# Emit a conditional nftables line only if the given zone interface is set.
# Usage: zone_line "$GREEN_IF" "iifname \"$GREEN_IF\" ip saddr @green_net oifname \"$RED_IF\" accept comment \"GREEN -> RED\""
zone_line() {
local iface="$1"
local rule="$2"
[[ -n "$iface" ]] && echo " $rule"
}
build_ruleset() {
autodetect_red
local red_tcp_in="$(csv_to_nft_set "$RED_IN_TCP")"
local red_udp_in="$(csv_to_nft_set "$RED_IN_UDP")"
local green_tcp_in="$(csv_to_nft_set "$GREEN_IN_TCP")"
local green_udp_in="$(csv_to_nft_set "$GREEN_IN_UDP")"
local orange_tcp_in="$(csv_to_nft_set "$ORANGE_IN_TCP")"
local orange_udp_in="$(csv_to_nft_set "$ORANGE_IN_UDP")"
local blue_tcp_in="$(csv_to_nft_set "$BLUE_IN_TCP")"
local blue_udp_in="$(csv_to_nft_set "$BLUE_IN_UDP")"
cat <<EOF
#!/usr/sbin/nft -f
# SysDeck FW — unified nftables zone firewall. Generated by sysdeck-fw.sh.
# Zones:
# RED (WAN): iface=${RED_IF} cidr=WAN
# GREEN (LAN): iface=${GREEN_IF:-unset} cidr=${GREEN_CIDR:-unset}
# ORANGE (DMZ): iface=${ORANGE_IF:-unset} cidr=${ORANGE_CIDR:-unset}
# BLUE (WiFi): iface=${BLUE_IF:-unset} cidr=${BLUE_CIDR:-unset}
# AirWall: ${AIRWALL}
# Flow offload: ${FLOW_OFFLOAD}
flush table inet ${TABLE_NAME} 2>/dev/null
table inet ${TABLE_NAME} {
# ── Sets ─────────────────────────────────────────────────────────
set ssh_abuse { type ipv4_addr; flags interval; timeout 1h; }
set port_scanners { type ipv4_addr; flags interval; timeout 1h; }
set connlimit_abuse { type ipv4_addr; timeout 10m; }
set bogons_v4 {
type ipv4_addr; flags interval;
elements = {
0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24,
192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24,
203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32
}
}
# Zone source CIDRs (source-verified outbound).
$( [[ -n "$GREEN_CIDR" ]] && echo "set green_net { type ipv4_addr; flags interval; elements = { $GREEN_CIDR }; }" )
$( [[ -n "$ORANGE_CIDR" ]] && echo "set orange_net { type ipv4_addr; flags interval; elements = { $ORANGE_CIDR }; }" )
$( [[ -n "$BLUE_CIDR" ]] && echo "set blue_net { type ipv4_addr; flags interval; elements = { $BLUE_CIDR }; }" )
# ── Input (to firewall host) ─────────────────────────────────────
chain input {
type filter hook input priority filter; policy drop;
iifname "lo" accept
iifname "$RED_IF" ip saddr @bogons_v4 drop comment "bogon from RED"
ip saddr @ssh_abuse drop
ip saddr @port_scanners drop
ct state established,related accept
ct state invalid drop
ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate 50/second burst 100 packets accept
ct state new tcp flags & (fin|syn|rst|ack) == syn drop
ip protocol icmp icmp type echo-request limit rate 5/second accept
ip6 nexthdr icmpv6 icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert } accept
iifname "$RED_IF" tcp dport { $red_tcp_in } accept comment "RED inbound TCP"
iifname "$RED_IF" udp dport { $red_udp_in } accept comment "RED inbound UDP"
$( [[ -n "$GREEN_IF" ]] && echo "iifname \"$GREEN_IF\" ip saddr @green_net tcp dport { $green_tcp_in } accept comment \"GREEN inbound (source-verified)\"" )
$( [[ -n "$GREEN_IF" ]] && echo "iifname \"$GREEN_IF\" ip saddr @green_net udp dport { $green_udp_in } accept comment \"GREEN inbound (source-verified)\"" )
$( [[ -n "$ORANGE_IF" ]] && echo "iifname \"$ORANGE_IF\" ip saddr @orange_net tcp dport { $orange_tcp_in } accept comment \"ORANGE inbound (source-verified)\"" )
$( [[ -n "$ORANGE_IF" ]] && echo "iifname \"$ORANGE_IF\" ip saddr @orange_net udp dport { $orange_udp_in } accept comment \"ORANGE inbound (source-verified)\"" )
$( [[ -n "$BLUE_IF" ]] && echo "iifname \"$BLUE_IF\" ip saddr @blue_net tcp dport { $blue_tcp_in } accept comment \"BLUE inbound (source-verified)\"" )
$( [[ -n "$BLUE_IF" ]] && echo "iifname \"$BLUE_IF\" ip saddr @blue_net udp dport { $blue_udp_in } accept comment \"BLUE inbound (source-verified)\"" )
}
# ── Forward (between zones) ──────────────────────────────────────
chain forward {
type filter hook forward priority filter; policy drop;
iifname "$RED_IF" ip saddr @bogons_v4 drop
ct state established,related accept
ct state invalid drop
# SYN flood protection on RED ingress (synproxy).
iifname "$RED_IF" tcp flags syn notrack accept comment "synproxy: pass new SYN to synproxy chain"
# ── Zone-to-zone matrix (source-verified) ──────────────────────
# GREEN -> RED : allow
# GREEN -> ORANGE: allow
# GREEN -> BLUE : allow
$( zone_line "$GREEN_IF" "iifname \"$GREEN_IF\" ip saddr @green_net oifname \"$RED_IF\" accept comment \"GREEN -> RED\"" )
$( [[ -n "$GREEN_IF" && -n "$ORANGE_IF" ]] && echo " iifname \"$GREEN_IF\" ip saddr @green_net oifname \"$ORANGE_IF\" accept comment \"GREEN -> ORANGE\"" )
$( [[ -n "$GREEN_IF" && -n "$BLUE_IF" ]] && echo " iifname \"$GREEN_IF\" ip saddr @green_net oifname \"$BLUE_IF\" accept comment \"GREEN -> BLUE\"" )
# BLUE -> RED : allow (AirWall outbound OK)
# BLUE -> ORANGE: allow
# BLUE -> GREEN: DENY (AirWall) unless AIRWALL=false → DNS only
$( zone_line "$BLUE_IF" "iifname \"$BLUE_IF\" ip saddr @blue_net oifname \"$RED_IF\" accept comment \"BLUE -> RED (AirWall outbound)\"" )
$( [[ -n "$BLUE_IF" && -n "$ORANGE_IF" ]] && echo " iifname \"$BLUE_IF\" ip saddr @blue_net oifname \"$ORANGE_IF\" accept comment \"BLUE -> ORANGE\"" )
$( [[ "$AIRWALL" == "false" && -n "$BLUE_IF" && -n "$GREEN_IF" ]] && echo " iifname \"$BLUE_IF\" ip saddr @blue_net oifname \"$GREEN_IF\" udp dport 53 accept comment \"AirWall-off: BLUE -> GREEN DNS only\"" )
# ORANGE -> RED : allow (DMZ -> Internet for updates)
# ORANGE -> GREEN: DENY
# ORANGE -> BLUE : DENY
$( zone_line "$ORANGE_IF" "iifname \"$ORANGE_IF\" ip saddr @orange_net oifname \"$RED_IF\" accept comment \"ORANGE -> RED\"" )
# DMZ port-forward rules (RED extport -> ORANGE host:intport).
EOF
# Emit port-forward rules (forward chain — accept the forwarded traffic).
if [[ -n "$DMZ_FORWARDS" ]]; then
local IFS=','
for fwd in $DMZ_FORWARDS; do
local extport="${fwd%%:*}"
local rest="${fwd#*:}"
local intport="${rest%%:*}"
local orangeip="${rest##*:}"
if [[ -n "$extport" && -n "$intport" && -n "$orangeip" && -n "$ORANGE_IF" ]]; then
echo " iifname \"$RED_IF\" oifname \"$ORANGE_IF\" tcp dport $extport ip daddr $orangeip accept comment \"forward RED:$extport -> ORANGE:$orangeip:$intport\""
fi
done
fi
cat <<EOF
}
# ── synproxy chain (SYN flood mitigation) ────────────────────────
chain synproxy {
tcp flags syn notrack accept
}
# ── Output ───────────────────────────────────────────────────────
chain output {
type filter hook output priority filter; policy accept;
}
EOF
# Flow offload (optional, hardware acceleration).
if [[ "$FLOW_OFFLOAD" == "true" ]]; then
local flow_devices="$RED_IF"
[[ -n "$GREEN_IF" ]] && flow_devices+=", $GREEN_IF"
cat <<EOF
# ── Flow offload (hardware acceleration) ─────────────────────────
flowtable f1 {
hook ingress priority 0; devices = { $flow_devices };
}
chain flowtable {
type filter hook forward priority 0; policy accept;
meta l4proto { tcp, udp } flow add 2>/dev/null accept comment "flow offload"
}
EOF
fi
cat <<EOF
# ── NAT ──────────────────────────────────────────────────────────
chain nat_postrouting {
type nat hook postrouting priority srcnat; policy accept;
# Masquerade traffic from GREEN/BLUE/ORANGE going out RED.
$( [[ -n "$GREEN_IF" ]] && echo "oifname \"$RED_IF\" ip saddr $GREEN_CIDR masquerade comment \"GREEN NAT\"" )
$( [[ -n "$BLUE_IF" ]] && echo "oifname \"$RED_IF\" ip saddr $BLUE_CIDR masquerade comment \"BLUE NAT\"" )
$( [[ -n "$ORANGE_IF" ]] && echo "oifname \"$RED_IF\" ip saddr $ORANGE_CIDR masquerade comment \"ORANGE NAT\"" )
}
chain nat_prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Port-forwarding DNAT rules (RED extport -> ORANGE host:intport).
EOF
# Emit DNAT rules.
if [[ -n "$DMZ_FORWARDS" ]]; then
local IFS=','
for fwd in $DMZ_FORWARDS; do
local extport="${fwd%%:*}"
local rest="${fwd#*:}"
local intport="${rest%%:*}"
local orangeip="${rest##*:}"
if [[ -n "$extport" && -n "$intport" && -n "$orangeip" ]]; then
echo " iifname \"$RED_IF\" tcp dport $extport dnat to $orangeip:$intport"
fi
done
fi
cat <<EOF
}
}
EOF
}
# ============================================================================
# ACTIONS
# ============================================================================
RULES_FILE="$(mktemp /tmp/sysdeck-fw-XXXXXX.nft)"
trap 'rm -f "$RULES_FILE"' EXIT
fw_start() {
log_info "Starting SysDeck FW (unified nftables zone firewall)..."
check_root
check_nftables
log_info "Generating ruleset..."
build_ruleset > "$RULES_FILE"
log_info "Validating..."
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
cp "$RULES_FILE" /tmp/sysdeck-fw-failed.nft
die "Validation failed. Ruleset saved to /tmp/sysdeck-fw-failed.nft"
fi
log_info "Loading..."
if "$NFT_CMD" -f "$RULES_FILE"; then
log_info "Firewall started."
else
die "Failed to load ruleset."
fi
}
fw_stop() {
log_info "Stopping SysDeck FW..."
check_root
check_nftables
"$NFT_CMD" delete table inet "$TABLE_NAME" 2>/dev/null || log_warn "table $TABLE_NAME not present."
log_info "Firewall stopped."
}
fw_restart() {
fw_stop
sleep 1
fw_start
}
fw_detect() {
local os_name="unknown"
if [[ -f /etc/os-release ]]; then . /etc/os-release; os_name="${PRETTY_NAME:-${NAME:-unknown}}"; fi
autodetect_red 2>/dev/null || RED_IF="unknown"
cat <<EOF
+-----------------------------------------------------+
| Service Detection Summary |
+-----------------------------------------------------+
| OS: ${os_name}
| Template: sysdeck-fw (unified nftables zone firewall)
| AirWall: ${AIRWALL}
| Flow offload: ${FLOW_OFFLOAD}
| Zones:
| RED (WAN): iface=${RED_IF:-unset} cidr=WAN
| GREEN (LAN): iface=${GREEN_IF:-unset} cidr=${GREEN_CIDR:-unset}
| ORANGE (DMZ): iface=${ORANGE_IF:-unset} cidr=${ORANGE_CIDR:-unset}
| BLUE (WiFi): iface=${BLUE_IF:-unset} cidr=${BLUE_CIDR:-unset}
| Inbound RED TCP: ${RED_IN_TCP:-none}
| DMZ forwards: ${DMZ_FORWARDS:-none}
| Config file: ${CONFIG_FILE}
+-----------------------------------------------------+
EOF
}
fw_status() {
check_nftables
"$NFT_CMD" list table inet "$TABLE_NAME" 2>&1 || echo "table not loaded."
}
fw_check() {
check_nftables
build_ruleset > "$RULES_FILE"
"$NFT_CMD" -c -f "$RULES_FILE"
}
# ============================================================================
# DISPATCH
# ============================================================================
main() {
local command="${1:-help}"
case "$command" in
start) fw_start ;;
stop) fw_stop ;;
restart|reload) fw_restart ;;
detect) fw_detect ;;
status) fw_status ;;
check|validate) fw_check ;;
help|--help|-h)
sed -n '1,90p' "$0"
;;
*)
die "Unknown command: $command\nRun '$0 help' for usage."
;;
esac
}
main "$@"

File diff suppressed because it is too large Load Diff

77
packaging/PKGBUILD Executable file
View File

@ -0,0 +1,77 @@
# Maintainer: Jeremy Anderson <info@dcos.net>
# Contributor: Jeremy Anderson <info@dcos.net>
# Upstream: https://dcos.net
pkgname=sysdeck
pkgver=0.2.0
pkgrel=1
pkgdesc="Unified operations surface for Linux infrastructure — twenty-six domain modules behind one Cockpit dashboard"
arch=('any')
url="https://dcos.net"
license=('MIT')
depends=('cockpit' 'python>=3.9')
optdepends=(
'podman: container management panel'
'nftables: firewall rules panel'
'lynis: integrity audit panel'
'iproute2: network security panel'
'kubectl: service mesh topology panel'
'cryptsetup: LUKS vault management panel'
'fwupd: firmware update panel'
'tpm2-tools: TPM firmware panel'
'mkosi: image builder panel (Arch primary)'
'archiso: bootable Arch Live ISO builder'
'opensc: PKCS#11 smartcard auth panel'
'pcsclite: smartcard reader daemon'
'glances: system monitoring panel'
'lm_sensors: hardware sensor readings panel'
'sysbench: system benchmark panel'
'kata-containers: Kata Containers panel (SysDeck Kata)'
'jellyfin: Jellyfin media server panel (SysDeck Jellyfin)'
'photoprism: PhotoPrism photo manager backend (SysDeck Photos)'
'piwigo: Piwigo photo manager backend (SysDeck Photos)'
'lychee: Lychee photo manager backend (SysDeck Photos)'
'librephotos: LibrePhotos photo manager backend (SysDeck Photos)'
'ceph: Ceph distributed filesystem backend (SysDeck Remote FS)'
'glusterfs: GlusterFS scale-out filesystem backend (SysDeck Remote FS)'
'moosefs: MooseFS distributed filesystem backend (SysDeck Remote FS)'
'beegfs: BeeGFS parallel filesystem backend (SysDeck Remote FS)'
'orangefs: OrangeFS parallel filesystem backend (SysDeck Remote FS)'
'polkit: privilege escalation for bridge helpers (strongly recommended)'
'appstream: application metadata for Cockpit Applications menu',
'bash: for sysdeck-diagnose.sh and cockpit-smoke-test.sh'
)
makedepends=('make')
backup=()
source=("${pkgname}-${pkgver}.tar.bz2")
sha256sums=('SKIP') # Replace with actual hash for release
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
make install DESTDIR="${pkgdir}"
# Arch-specific: ensure cockpit can find the bridge Python package.
# The bridge installs to /usr/lib/sysdeck/bridge/; add a symlink
# from the Arch Python site-packages so `python3 -m sysdeck.bridge.*`
# resolves correctly from the cockpit spawn context.
local site_packages
site_packages=$(python3 -c "import site; print(site.getsitepackages()[0])")
install -d "${pkgdir}${site_packages}"
ln -sf /usr/lib/sysdeck/bridge "${pkgdir}${site_packages}/sysdeck"
}
post_install() {
# Restart cockpit.socket so the new plugin appears in the menu.
systemctl try-restart cockpit.socket 2>/dev/null || true
echo ">>> SysDeck installed. Restart cockpit.socket if not done automatically."
echo ">>> sudo systemctl restart cockpit.socket"
}
post_upgrade() {
systemctl try-restart cockpit.socket 2>/dev/null || true
}
post_remove() {
systemctl try-restart cockpit.socket 2>/dev/null || true
echo ">>> SysDeck removed. Restart cockpit.socket to flush the menu."
}

2638
packaging/debian/changelog Executable file

File diff suppressed because it is too large Load Diff

27
packaging/debian/control Executable file
View File

@ -0,0 +1,27 @@
Source: sysdeck
Section: admin
Priority: optional
Maintainer: Jeremy Anderson <info@dcos.net>
Build-Depends: debhelper-compat (= 13), make, python3 (>= 3.9)
Standards-Version: 4.7.0
Homepage: https://dcos.net
Vcs-Browser: https://dcos.net
Vcs-Git: https://dcos.net/sysdeck.git
Package: sysdeck
Architecture: all
Depends: cockpit-bridge (>= 239), python3 (>= 3.9), ${misc:Depends}
Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream, kata-containers, jellyfin
Suggests: kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server
Description: Unified operations surface for Linux infrastructure
SysDeck is a drop-in plugin for an existing Cockpit install.
It consolidates twenty-three domain modules — containers, firewall,
integrity auditing, network security, service mesh, encryption vaults,
fleet compute, Kata Containers, firmware, image building, mining,
theme engine, hardware authentication, DAG-driven build orchestration,
system monitoring, hardware sensors, system benchmarking, package
management, policy & permissions, database control, Jellyfin media
server, photo manager (PhotoPrism/Piwigo/Lychee/Nextcloud-Memories/
LibrePhotos), and remote filesystem manager (Ceph/GlusterFS/MooseFS/
BeeGFS/OrangeFS) — behind a single dashboard accessible from the
Cockpit web UI.

1
packaging/debian/copyright Executable file
View File

@ -0,0 +1 @@
MIT license for SysDeck — see /usr/share/doc/sysdeck/LICENSE

12
packaging/debian/postinst Executable file
View File

@ -0,0 +1,12 @@
#!/bin/sh
# postinst: restart cockpit.socket after install
set -e
if [ "$1" = "configure" ]; then
# Restart cockpit.socket so the new plugin appears in the menu.
if command -v systemctl >/dev/null 2>&1; then
systemctl try-restart cockpit.socket 2>/dev/null || true
fi
fi
#DEBHELPER#

11
packaging/debian/postrm Executable file
View File

@ -0,0 +1,11 @@
#!/bin/sh
# postrm: restart cockpit.socket after removal to flush the menu
set -e
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
if command -v systemctl >/dev/null 2>&1; then
systemctl try-restart cockpit.socket 2>/dev/null || true
fi
fi
#DEBHELPER#

15
packaging/debian/rules Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env python3
# SysDeck - Debian rules
# Author: Jeremy Anderson (https://dcos.net)
%:
dh $@
override_dh_auto_install:
$(MAKE) install DESTDIR=debian/sysdeck
override_dh_auto_build:
# Pure static assets — nothing to compile.
override_dh_auto_clean:
$(MAKE) clean

1
packaging/debian/source/format Executable file
View File

@ -0,0 +1 @@
3.0 (quilt)

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SysDeck — 3rd-party Modules install/uninstall polkit action.
Authorizes the cockpit bridge to invoke, as root:
- /usr/bin/python3 /usr/lib/sysdeck/bridge/modules3p.py install <id>
- /usr/bin/python3 /usr/lib/sysdeck/bridge/modules3p.py uninstall <id>
The bridge internally invokes pacman / git / curl / bsdtar / tar /
rm as subprocesses — those inherit the root privilege granted here.
Install to: /usr/share/polkit-1/actions/org.sysdeck.modules3p.policy
The action id is org.sysdeck.modules3p.modify. The cockpit bridge
prompts the operator for this action when modules.js calls
cockpit.spawn(..., { superuser: 'try' }) on the bridge's install /
uninstall subcommands.
v0.0.46 design note: the front-end renders the license inline next
to the Install button, so this polkit prompt (which fires AFTER the
operator clicks Install) is the second of two acceptance gestures —
the first being the click itself, which accepted the inline license.
-->
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>SysDeck</vendor>
<vendor_url>https://dcos.net</vendor_url>
<action id="org.sysdeck.modules3p.modify">
<description>Install or remove third-party Cockpit modules</description>
<description xml:lang="en">Install or remove third-party Cockpit modules</description>
<message>Authentication is required to install or remove a third-party Cockpit module</message>
<message xml:lang="en">Authentication is required to install or remove a third-party Cockpit module</message>
<defaults>
<allow_any>auth_admin</allow_any>
<allow_inactive>auth_admin</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/python3</annotate>
<annotate key="org.freedesktop.policykit.exec.argv1">/usr/lib/sysdeck/bridge/modules3p.py</annotate>
<annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
</action>
</policyconfig>

View File

@ -0,0 +1,453 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SysDeck - PolKit Policy
Author: Jeremy Anderson (https://dcos.net)
Cockpit-bridge runs as the logged-in user. When a bridge helper needs to
perform a privileged operation (modify the firewall, install a package,
flash firmware, manage LUKS volumes), it goes through pkexec + polkit.
This file defines the actions that SysDeck's bridge helpers can request.
Without this file, privileged bridge operations fail with "Not authorized:
The user does not have permission to perform this action." Cockpit-ws
grants proper auth context only to registered applications — see
packaging/sysdeck.metainfo.xml for the AppStream registration.
Install this file to /usr/share/polkit-1/actions/org.sysdeck.policy so
that polkit picks it up at install time. The user will be prompted to
authenticate (via cockpit's auth dialog or the system's polkit agent)
the first time a privileged operation is requested in a session.
Coarse-grained by design: one action per privilege domain. Refine to
per-operation actions (org.sysdeck.firewall.add-rule, etc.) once the
bridge helpers grow more sophisticated.
-->
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>SysDeck</vendor>
<vendor_url>https://dcos.net</vendor_url>
<!-- ============================================================= -->
<!-- System management: systemctl start/stop/enable/disable, -->
<!-- hostnamectl, timedatectl, localectl, machinectl. -->
<!-- ============================================================= -->
<action id="org.sysdeck.system.manage">
<description>Manage system services and configuration</description>
<description xml:lang="en">Manage system services and configuration</description>
<message>System policy prevents SysDeck from managing system services and configuration.</message>
<message xml:lang="en">System policy prevents SysDeck from managing system services and configuration.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/hostnamectl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/timedatectl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/localectl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/loginctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/machinectl</annotate>
</action>
<!-- ============================================================= -->
<!-- Firewall: nftables rule management (nft add/insert/delete/ -->
<!-- flush). Read-only operations (nft list) do not need this. -->
<!-- -->
<!-- v0.0.36: extended to authorize the Cilium eBPF backend. -->
<!-- Per user directive: "next we will add cilium support as a -->
<!-- drop down option in the fw area, the user can select custom -->
<!-- which is default with the templates that are basic. or they -->
<!-- can select celium, or smoothwall or ipfire or other firewall -->
<!-- scripts that install cleanly with value for ebpf era and -->
<!-- nftables." The cilium backend uses the cilium CLI + cilium- -->
<!-- agent binary + (optionally) helm for K8s-based install. -->
<!-- ============================================================= -->
<action id="org.sysdeck.firewall.modify">
<description>Modify firewall rules (nftables + Cilium eBPF)</description>
<description xml:lang="en">Modify firewall rules (nftables + Cilium eBPF)</description>
<message>System policy prevents SysDeck from modifying firewall rules.</message>
<message xml:lang="en">System policy prevents SysDeck from modifying firewall rules.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<!-- nftables (nftables-native backends: custom, smoothwall, ipfire) -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/nft</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/nft</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/iptables</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/ip6tables</annotate>
<!-- v0.0.36: Cilium eBPF backend -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/cilium</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/cilium</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/cilium-agent</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/cilium-agent</annotate>
<!-- v0.0.36: helm for K8s-based Cilium install -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/helm</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/helm</annotate>
</action>
<!-- ============================================================= -->
<!-- Package management: pacman, apt, dnf install/remove/upgrade. -->
<!-- Read-only operations (list, search, info) do not need this. -->
<!-- ============================================================= -->
<action id="org.sysdeck.packages.modify">
<description>Install, remove, and upgrade system packages</description>
<description xml:lang="en">Install, remove, and upgrade system packages</description>
<message>System policy prevents SysDeck from modifying installed packages.</message>
<message xml:lang="en">System policy prevents SysDeck from modifying installed packages.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/pacman</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/apt</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/dnf</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/yum</annotate>
</action>
<!-- ============================================================= -->
<!-- Firmware: fwupdmgr update/install, tpm2-tools operations. -->
<!-- ============================================================= -->
<action id="org.sysdeck.firmware.modify">
<description>Update firmware and manage TPM 2.0 state</description>
<description xml:lang="en">Update firmware and manage TPM 2.0 state</description>
<message>System policy prevents SysDeck from modifying firmware or TPM state.</message>
<message xml:lang="en">System policy prevents SysDeck from modifying firmware or TPM state.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/fwupdmgr</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/tpm2</annotate>
</action>
<!-- ============================================================= -->
<!-- Encryption vaults: cryptsetup luksFormat/open/close, -->
<!-- LUKS key management. -->
<!-- ============================================================= -->
<action id="org.sysdeck.vault.modify">
<description>Manage LUKS encryption volumes</description>
<description xml:lang="en">Manage LUKS encryption volumes</description>
<message>System policy prevents SysDeck from managing LUKS volumes.</message>
<message xml:lang="en">System policy prevents SysDeck from managing LUKS volumes.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/cryptsetup</annotate>
</action>
<!-- ============================================================= -->
<!-- Image builder (v0.0.30+): mkosi + archiso on Arch; vmdb2 + -->
<!-- live-build on Debian. osbuild / livemedia-creator (Fedora- -->
<!-- only) removed — the suite no longer targets Fedora/RHEL for -->
<!-- the Builder panel. -->
<!-- v0.0.31+: the build/profile-create/profile-delete subcommands -->
<!-- also need write access to /etc/mkosi/, /etc/vmdb2/ and -->
<!-- /var/lib/sysdeck/builder/. The bridge runs the backend via -->
<!-- subprocess; the cockpit superuser channel handles root priv. -->
<!-- v0.1.0: profile-import-packages also queries the host's -->
<!-- package manager (pacman -Qqe / apt-mark showmanual / -->
<!-- dnf repoquery --userinstalled) to capture the operator's -->
<!-- explicitly-installed package set. -->
<!-- ============================================================= -->
<action id="org.sysdeck.builder.modify">
<description>Build system images and ISOs</description>
<description xml:lang="en">Build system images and ISOs</description>
<message>System policy prevents SysDeck from building system images.</message>
<message xml:lang="en">System policy prevents SysDeck from building system images.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mkosi</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mkarchiso</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/vmdb2</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/lb</annotate>
<!-- v0.1.0: host package-list query for profile-import-packages. -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/pacman</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/apt-mark</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/dnf</annotate>
</action>
<!-- ============================================================= -->
<!-- SysDeck Fester (v0.0.31+): DAG-driven build orchestration. -->
<!-- The v0.0.31 fester bridge helper still runs `systemctl list- -->
<!-- units` in read-only mode (no polkit needed). This action -->
<!-- covers the future DAG-orchestration path that will start/ -->
<!-- stop build-farm services and read journal logs. The action -->
<!-- is unused in v0.0.31 but ships now so admins can set up -->
<!-- polkit rules before the orchestrator lands. -->
<!-- ============================================================= -->
<action id="org.sysdeck.fester.modify">
<description>Manage SysDeck Fester build-farm orchestration</description>
<description xml:lang="en">Manage SysDeck Fester build-farm orchestration</description>
<message>System policy prevents SysDeck Fester from managing build-farm services.</message>
<message xml:lang="en">System policy prevents SysDeck Fester from managing build-farm services.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/journalctl</annotate>
</action>
<!-- ============================================================= -->
<!-- Policy & Permissions (v0.0.32+): ACLs, cgroups, VLANs, -->
<!-- eBPF namespace separation, AppArmor (optional). -->
<!-- v0.0.33+: extends to Smack, TOMOYO, Yama, LoadPin, Lockdown, -->
<!-- BPF-LSM, Landlock, and file capabilities (setcap/getcap). -->
<!-- -->
<!-- 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." -->
<!-- -->
<!-- v0.0.33 directive: "lets now add smack, tomoyo, yama and -->
<!-- others as well to the same policy module." -->
<!-- -->
<!-- SELinux is skipped (native to host distro). 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. Smack, TOMOYO, Yama, -->
<!-- LoadPin, Lockdown, BPF-LSM, and Landlock follow the same -->
<!-- pattern. -->
<!-- ============================================================= -->
<action id="org.sysdeck.policy.modify">
<description>Manage policy and permissions (ACLs, cgroups, VLANs, eBPF, file capabilities, LSM stack: AppArmor/Smack/TOMOYO/Yama/LoadPin/Lockdown/BPF-LSM/Landlock)</description>
<description xml:lang="en">Manage policy and permissions (ACLs, cgroups, VLANs, eBPF, file capabilities, LSM stack: AppArmor/Smack/TOMOYO/Yama/LoadPin/Lockdown/BPF-LSM/Landlock)</description>
<message>System policy prevents SysDeck from modifying ACLs, cgroups, VLANs, eBPF programs, file capabilities, or LSM state.</message>
<message xml:lang="en">System policy prevents SysDeck from modifying ACLs, cgroups, VLANs, eBPF programs, file capabilities, or LSM state.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<!-- ACLs -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/setfacl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/setfacl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/getfacl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/getfacl</annotate>
<!-- cgroups (mkdir is the only required binary) -->
<annotate key="org.freedesktop.policykit.exec.path">/bin/mkdir</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mkdir</annotate>
<!-- bpffs / eBPF pin -->
<annotate key="org.freedesktop.policykit.exec.path">/bin/mount</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/mount</annotate>
<!-- VLANs -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/ip</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/ip</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/vconfig</annotate>
<!-- eBPF -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/bpftool</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/bpftool</annotate>
<!-- namespaces (read-only enumerate, but polkit annotation is harmless) -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/lsns</annotate>
<!-- AppArmor (optional) -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/aa-enforce</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/aa-enforce</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/aa-complain</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/aa-complain</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/aa-status</annotate>
<!-- v0.0.33: Smack userspace tools -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/smackload</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/smackcipsos</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/smackcipso</annotate>
<!-- v0.0.33: TOMOYO userspace tools -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/tomoyo-setprofile</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/tomoyo-set-profile</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/tomoyo-savepolicy</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/tomoyo-init</annotate>
<!-- v0.0.33: File capabilities (setcap / getcap) -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/setcap</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/setcap</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/getcap</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/getcap</annotate>
</action>
<!-- ============================================================= -->
<!-- DB Control (v0.0.32): start/stop/restart database engines. -->
<!-- The bridge helper runs `systemctl start/stop/restart -->
<!-- <engine>.service` directly (the v0.0.15-era `sudo systemctl` -->
<!-- shell-out was the bug the user complained about in v0.0.31 — -->
<!-- "the update needs sudo so the command fails" — same root -->
<!-- cause). The cockpit way (v0.0.31+ pattern): the JS panel -->
<!-- passes { superuser: 'try' } to cockpit.spawn so the cockpit -->
<!-- bridge prompts the operator via polkit for this action. -->
<!-- ============================================================= -->
<action id="org.sysdeck.db.modify">
<description>Start, stop, and restart database engines</description>
<description xml:lang="en">Start, stop, and restart database engines</description>
<message>System policy prevents SysDeck from managing database engine services.</message>
<message xml:lang="en">System policy prevents SysDeck from managing database engine services.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/systemctl</annotate>
</action>
<!-- ============================================================= -->
<!-- Jellyfin Media Server (v0.0.35): start/stop/restart -->
<!-- jellyfin.service. Per user directive: "next we will -->
<!-- integrate a jellyfin management module where it starts, -->
<!-- stops, and loads the admin panel in the module." The -->
<!-- bridge runs `systemctl start/stop/restart jellyfin.service` -->
<!-- directly; the JS panel passes { superuser: 'try' } so -->
<!-- polkit prompts the operator. -->
<!-- ============================================================= -->
<action id="org.sysdeck.jellyfin.modify">
<description>Start, stop, and restart the Jellyfin media server</description>
<description xml:lang="en">Start, stop, and restart the Jellyfin media server</description>
<message>System policy prevents SysDeck from managing the Jellyfin media server.</message>
<message xml:lang="en">System policy prevents SysDeck from managing the Jellyfin media server.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/jellyfin</annotate>
</action>
<!-- ============================================================= -->
<!-- Photo Manager (v0.0.35): start/stop/restart photo backend -->
<!-- services. Per user directive: "as well as a photo manager -->
<!-- of equal quality. with its own module." Multi-backend: -->
<!-- PhotoPrism, Piwigo, Lychee, Nextcloud-Memories, LibrePhotos. -->
<!-- The bridge runs `systemctl start/stop/restart <service>` -->
<!-- directly; the JS panel passes { superuser: 'try' } so -->
<!-- polkit prompts the operator. -->
<!-- ============================================================= -->
<action id="org.sysdeck.photos.modify">
<description>Start, stop, and restart photo management backends</description>
<description xml:lang="en">Start, stop, and restart photo management backends</description>
<message>System policy prevents SysDeck from managing photo management backend services.</message>
<message xml:lang="en">System policy prevents SysDeck from managing photo management backend services.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/photoprism</annotate>
</action>
<!-- ============================================================= -->
<!-- Remote FS Manager (v0.0.35): start/stop/restart remote -->
<!-- filesystem backend services + cluster status queries. -->
<!-- Per user directive: "then a remote fs manager such as -->
<!-- ceph, and others but not nfs or amanada fs." Backends: -->
<!-- Ceph, GlusterFS, MooseFS, BeeGFS, OrangeFS. The bridge -->
<!-- runs `systemctl start/stop/restart <service>` directly; the -->
<!-- JS panel passes { superuser: 'try' } so polkit prompts the -->
<!-- operator. Cluster-info subcommand also calls ceph, gluster, -->
<!-- moosefs-cli, beegfs-ctl, pvfs2-server — annotated here so -->
<!-- polkit allows them under the same action. -->
<!-- ============================================================= -->
<action id="org.sysdeck.remotefs.modify">
<description>Start, stop, and restart remote filesystem backends (Ceph, GlusterFS, MooseFS, BeeGFS, OrangeFS)</description>
<description xml:lang="en">Start, stop, and restart remote filesystem backends (Ceph, GlusterFS, MooseFS, BeeGFS, OrangeFS)</description>
<message>System policy prevents SysDeck from managing remote filesystem backend services.</message>
<message xml:lang="en">System policy prevents SysDeck from managing remote filesystem backend services.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/systemctl</annotate>
<!-- Ceph -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/ceph</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/ceph</annotate>
<!-- GlusterFS -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/gluster</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/gluster</annotate>
<!-- MooseFS -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/moosefs-cli</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/moosefs-cli</annotate>
<!-- BeeGFS -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/beegfs-ctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/opt/beegfs/sbin/beegfs-ctl</annotate>
<!-- OrangeFS / PVFS2 -->
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/pvfs2-server</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/pvfs2-server</annotate>
</action>
<!-- ============================================================= -->
<!-- Kata Containers (v0.0.38): kata-runtime, kata-monitor, ctr, -->
<!-- crictl, and the QCrows kernel-bundle tools. The v0.0.38 -->
<!-- rewrite replaced the mock React bundle with a real bridge -->
<!-- that calls these binaries. Most kata subcommands are read- -->
<!-- only (list, inspect, metrics, summary, version, check, -->
<!-- pxe-status, qcrows-list) and do NOT need this action. The -->
<!-- action ships now so future mutating verbs (sandbox create / -->
<!-- stop / remove, qcrows-export, qcrows-initrd-regen) are -->
<!-- authorized when they land. -->
<!-- ============================================================= -->
<action id="org.sysdeck.kata.modify">
<description>Manage Kata Containers sandboxes and QCrows kernel bundles</description>
<description xml:lang="en">Manage Kata Containers sandboxes and QCrows kernel bundles</description>
<message>System policy prevents SysDeck from managing Kata Containers.</message>
<message xml:lang="en">System policy prevents SysDeck from managing Kata Containers.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/kata-runtime</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/kata-runtime</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/kata-monitor</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/kata-monitor</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/ctr</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/crictl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/qcrows-export</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/qcrows-initrd-regen</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
</action>
<!-- ============================================================= -->
<!-- Monitoring (v0.0.39): Prometheus + Grafana service control. -->
<!-- The shared SysDeck Monitoring panel has two tabs (Prometheus -->
<!-- + Grafana). Read-only queries (summary, targets, alerts, -->
<!-- dashboards, datasources, health, etc.) hit the HTTP APIs -->
<!-- directly and need no polkit. Mutating verbs (restart, reload) -->
<!-- invoke systemctl — this action authorizes that. -->
<!-- Prometheus is Apache-2.0; Grafana is AGPL-3.0. Neither is -->
<!-- bundled — the bridge talks to their HTTP APIs. -->
<!-- ============================================================= -->
<action id="org.sysdeck.monitoring.modify">
<description>Manage Prometheus and Grafana monitoring services</description>
<description xml:lang="en">Manage Prometheus and Grafana monitoring services</description>
<message>System policy prevents SysDeck from managing monitoring services.</message>
<message xml:lang="en">System policy prevents SysDeck from managing monitoring services.</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>auth_admin_keep</allow_inactive>
<allow_active>auth_admin_keep</allow_active>
</defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/usr/sbin/systemctl</annotate>
<annotate key="org.freedesktop.policykit.exec.path">/bin/systemctl</annotate>
</action>
</policyconfig>

94
packaging/setup.py Executable file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
SysDeck - setup.py
Author: Jeremy Anderson (https://dcos.net)
Pip-installable package that lays out the cockpit plugin under
/usr/share/cockpit/sysdeck/ and the Python bridge under
/usr/lib/sysdeck/.
Works on all supported distros (Arch, Debian, Fedora/RHEL).
The distro detection at install time determines which Python
site-packages directory gets the bridge symlink.
Usage:
pip3 install .
pip3 install . --target=/tmp/overlay
"""
import os
import shutil
import subprocess
from setuptools import setup, find_packages
VERSION = "0.2.0"
PACKAGE = "sysdeck"
def glob_files(directory, pattern="*"):
"""Return all files matching pattern under directory."""
result = []
for root, _dirs, files in os.walk(directory):
for f in files:
if pattern == "*" or f.endswith(pattern):
result.append(os.path.join(root, f))
return result
def detect_distro():
"""Detect distro for distro-aware post-install actions."""
try:
with open("/etc/os-release", encoding="utf-8") as fh:
for line in fh:
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"').lower()
except (FileNotFoundError, PermissionError):
pass
return "unknown"
setup(
name=PACKAGE,
version=VERSION,
description="Unified operations surface for Linux infrastructure — eighteen domain modules behind one cockpit dashboard with live bridge channel integration.",
long_description=open("README.md").read() if os.path.exists("README.md") else "",
long_description_content_type="text/markdown",
author="Jeremy Anderson",
author_email="info@dcos.net",
url="https://dcos.net",
license="MIT",
python_requires=">=3.9",
packages=find_packages(where="bridge") + ["tests"],
package_dir={"": "bridge", "tests": "tests"},
extras_require={
"glances": ["glances"],
"benchmark": ["sysbench"],
},
data_files=[
# Cockpit plugin root: manifest + entry HTML + bundle + styles + logo
(f"/usr/share/cockpit/{PACKAGE}", [
"manifest.json", "index.html", "suite.js", "suite.css", "logo.svg",
"README.md", "LICENSE",
]),
# ES module sources — needed at runtime for dynamic imports
(f"/usr/share/cockpit/{PACKAGE}/src", glob_files("src")),
(f"/usr/share/cockpit/{PACKAGE}/src/modules", [
f"src/modules/{f}" for f in os.listdir("src/modules") if f.endswith(".js")
]),
# Type declarations + dev mock
(f"/usr/share/cockpit/{PACKAGE}/src", [
"src/cockpit-types.d.ts",
"src/mock-cockpit.js",
]),
],
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Programming Language :: JavaScript",
"Programming Language :: Python :: 3",
"Topic :: System :: Systems Administration",
],
)

872
packaging/sysdeck.metainfo.xml Executable file
View File

@ -0,0 +1,872 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SysDeck - AppStream Metainfo (umbrella component)
This file follows the pattern used by cockpit-project itself in
src/appstream/org.cockpit_project.cockpit_*.metainfo.xml.in — see
e.g. org.cockpit_project.cockpit_networkmanager.metainfo.xml.in.
Pattern verified from the cockpit source code:
pkg/apps/watch-appstream.py:237 — reads <launchable type="cockpit-manifest">NAME</launchable>
pkg/apps/utils.tsx:152 — matches launchable.type == "cockpit-manifest"
src/appstream/*.xml.in — all use <launchable>, not <provides><cockpit-manifest>
Previous versions of this file (v0.0.17-v0.0.20) used
<provides><cockpit-manifest>NAME</cockpit-manifest></provides>, which is
NOT what the cockpit apps page reads. It is silently ignored — the plugin
would not appear in the Applications menu. v0.0.21 corrects this.
The <id> follows the reverse-DNS convention used by cockpit-project:
org.cockpit_project.cockpit_<name> (cockpit's own plugins)
net.dcos.sysdeck (this umbrella component)
The <launchable> text must match the `name` field of the plugin's
manifest.json — NOT the directory name. For sysdeck-containers, the
manifest.json has `"name": "sysdeck-containers"`, so the launchable
text is "sysdeck-containers".
This umbrella metainfo covers the whole SysDeck suite. Each individual
plugin does not ship its own metainfo (to avoid 18 components in the
AppStream cache). The umbrella component is what appears in Cockpit's
Applications page; clicking it could open the first plugin's page.
-->
<component type="addon">
<id>net.dcos.sysdeck</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT</project_license>
<name>SysDeck</name>
<summary>Unified operations surface for Linux infrastructure — 23 Cockpit plugins</summary>
<description>
<p>
SysDeck is a suite of 23 standalone Cockpit plugins — containers,
firewall, integrity auditing, network security, service mesh,
encryption vaults, fleet compute, Kata Containers, firmware, image
building, mining, theme engine, hardware authentication, DAG-driven
build orchestration, system monitoring, hardware sensors, system
benchmarking, package management, policy &amp; permissions, database
control, Jellyfin media server, photo manager (PhotoPrism/Piwigo/
Lychee/Nextcloud-Memories/LibrePhotos), and remote filesystem manager
(Ceph/GlusterFS/MooseFS/BeeGFS/OrangeFS). Each plugin appears as its
own sidebar entry in the Cockpit web UI.
</p>
<p>
The plugins ship as static HTML+JS+CSS plus a Python bridge helper
package. They install under /usr/share/cockpit/sysdeck-&lt;name&gt;/ and
are discovered automatically by cockpit-bridge. No separate web
server, no Node.js runtime, no database — the plugins run inside
the cockpit web service.
</p>
<p>
Each backend tool (podman, nftables, fwupd, etc.) is invoked as a
separate subprocess via the bridge helper; no third-party code is
bundled. See THIRD_PARTY.md for the full attribution matrix.
</p>
</description>
<url type="homepage">https://dcos.net</url>
<url type="bugtracker">https://dcos.net/sysdeck/issues</url>
<url type="help">https://dcos.net/sysdeck/quickstart</url>
<!-- The 26 <launchable> entries below match the `name` field of each
plugin's manifest.json. The cockpit apps page (pkg/apps/utils.tsx:152)
reads launchable.type == "cockpit-manifest" and uses the text to
link the AppStream component to the corresponding Cockpit plugin.
v0.0.35: SysDeck Kata restored as its own sidebar entry; added
sysdeck-jellyfin, sysdeck-photos, sysdeck-remotefs.
v0.0.46: added sysdeck-modules (in-suite 3rd-party module installer).
v0.0.47: added sysdeck-services (service/port editor promoted to
its own sidebar entry). -->
<launchable type="cockpit-manifest">sysdeck-containers</launchable>
<launchable type="cockpit-manifest">sysdeck-firewall</launchable>
<launchable type="cockpit-manifest">sysdeck-integrity</launchable>
<launchable type="cockpit-manifest">sysdeck-netsec</launchable>
<launchable type="cockpit-manifest">sysdeck-mesh</launchable>
<launchable type="cockpit-manifest">sysdeck-vault</launchable>
<launchable type="cockpit-manifest">sysdeck-fleet</launchable>
<launchable type="cockpit-manifest">sysdeck-kata</launchable>
<launchable type="cockpit-manifest">sysdeck-fester</launchable>
<launchable type="cockpit-manifest">sysdeck-firmware</launchable>
<launchable type="cockpit-manifest">sysdeck-builder</launchable>
<launchable type="cockpit-manifest">sysdeck-mining</launchable>
<launchable type="cockpit-manifest">sysdeck-themes</launchable>
<launchable type="cockpit-manifest">sysdeck-auth</launchable>
<launchable type="cockpit-manifest">sysdeck-glances</launchable>
<launchable type="cockpit-manifest">sysdeck-sensors</launchable>
<launchable type="cockpit-manifest">sysdeck-benchmark</launchable>
<launchable type="cockpit-manifest">sysdeck-packages</launchable>
<launchable type="cockpit-manifest">sysdeck-policy</launchable>
<launchable type="cockpit-manifest">sysdeck-db</launchable>
<launchable type="cockpit-manifest">sysdeck-jellyfin</launchable>
<launchable type="cockpit-manifest">sysdeck-photos</launchable>
<launchable type="cockpit-manifest">sysdeck-remotefs</launchable>
<launchable type="cockpit-manifest">sysdeck-monitoring</launchable>
<launchable type="cockpit-manifest">sysdeck-modules</launchable>
<launchable type="cockpit-manifest">sysdeck-services</launchable>
<categories>
<category>System</category>
</categories>
<keywords>
<keyword>cockpit</keyword>
<keyword>operations</keyword>
<keyword>monitoring</keyword>
<keyword>firewall</keyword>
<keyword>containers</keyword>
<keyword>firmware</keyword>
<keyword>jellyfin</keyword>
<keyword>photos</keyword>
<keyword>ceph</keyword>
<keyword>remote-filesystem</keyword>
</keywords>
<content_rating type="oars-1.1" />
<releases>
<release version="0.2.0" date="2026-08-20">
<description>
<p>v0.2.0 MASTER EDITION: one tarball bundling the cockpit
edition, the new SysDeck Web Edition (web/), and Fester
pre-integrated (vendored at web/mini-services/fester, own
independent version 0.2.1). The fester bridge is a real REST
client now, the fester panel is fully built, and the Makefile
recipe indentation is fixed.</p>
</description>
</release>
<release version="0.1.3" date="2026-08-19">
<description>
<p>v0.1.3 — CRITICAL FIX: host package import was silently failing
+ mkosi still wasn't reading the profile config. Two root causes
fixed, plus new download/manage UI for builds.</p>
<p>IMPORT BUG: _detect_host_packages() relied on `from __init__
import PKG_MANAGER` which silently failed in the cockpit
superuser channel context (different Python path). PKG_MANAGER
defaulted to "unknown", the host query returned an EMPTY list,
and the import wrote nothing. The operator saw "tries to build
only 2" because the build used the profile's original template
packages. FIX: now uses shutil.which() to find pacman/apt-mark/
dnf directly — no import dependency, works in any context.</p>
<p>BUILD BUG: v0.1.2's --include flag does NOT work as a config
loader. mkosi's --include includes a drop-in fragment ON TOP OF
the base mkosi.conf — it does NOT replace the base config. If
there's no mkosi.conf in the cwd, mkosi uses defaults and
ignores the --include file entirely. FIX: build() now creates a
temp directory, symlinks the profile file as `mkosi.conf`, and
sets work_dir to that temp dir. mkosi finds the symlink, follows
it, reads the actual profile. Works for ANY profile path.</p>
<p>NEW: artifact download + management UI. Each artifact has a
Download button (reads via cockpit.spawn cat + Blob), a per-file
Delete button, and a per-profile Clear all button. Profile card
header shows total artifact size.</p>
<p>NEW: build management. Each build has a Delete button with
two-step confirm (state+log only, or also artifacts). New
subcommand: build-delete.</p>
<p>REGRESSION TESTS: 11 new unit tests in
TestBuilderArtifactManagement (7) + TestBuilderMkosiTempWorkDir
(3). Total: 254 tests (was 243; +11). All pass.</p>
</description>
</release>
<release version="0.1.2" date="2026-08-19">
<description>
<p>v0.1.2 — CRITICAL FIX: mkosi was not reading the profile
config at all. Packages were silently ignored. Operator
reported: "the builder absolutely does not work yet. it has
zero awareness of packages we tell it to add."</p>
<p>ROOT CAUSE 1 (config not loaded):
_backend_build_command() for mkosi had no flag telling mkosi
WHERE the profile config file is. mkosi only reads a file
literally named `mkosi.conf` from the cwd. For v0.0.x profiles
at /etc/mkosi/mkosi.conf.d/&lt;name&gt;.conf, mkosi ran in that
dir, found no `mkosi.conf`, and used EMPTY defaults — zero
packages, default distro, default everything.</p>
<p>FIX 1: _backend_build_command() now ALWAYS passes
--include &lt;profile_path&gt; on the CLI. This tells mkosi to
explicitly load the profile config by path, regardless of its
filename or location.</p>
<p>ROOT CAUSE 2 (legacy Packages= syntax): profiles created by
v0.0.x used the old indented Packages= syntax
(Packages=\n linux\n...). mkosi v22+ only understands
single-line (Packages=linux ...). The old form is silently
parsed as a single package name with embedded newlines, which
doesn't exist in any repo — so mkosi installs NOTHING.</p>
<p>FIX 2: new _migrate_legacy_mkosi_packages() function detects
the old indented syntax and rewrites it to single-line IN-PLACE
before the build command is constructed. build() calls this
automatically on every mkosi build. Migration is logged in
build state JSON and log file header. No-op on modern syntax.</p>
<p>REGRESSION TESTS: 4 new unit tests in TestBuilderBuildPath
cover migration (old syntax rewrite, modern no-op, no-section
no-op, end-to-end during build). Existing test_build_success_path
extended to verify --include is on the command line and points
at the profile file. Total unit tests now 243 (was 239 in
v0.1.1; +4). All build-time guards pass.</p>
</description>
</release>
<release version="0.1.1" date="2026-08-19">
<description>
<p>v0.1.1 — OUTPUT PATH SAFETY FIX. An operator reported:
"this is NOT a safe output path. fix this now." The v0.1.0
release relied on OutputDirectory= in the scaffolded
mkosi.conf to route build outputs to
/var/lib/sysdeck/builder/artifacts/&lt;name&gt;/. But when
the operator built an OLD v0.0.x profile (whose mkosi.conf
had no OutputDirectory= setting), mkosi defaulted to
writing image.raw into the cwd — which was
/etc/mkosi/mkosi.conf.d/, a system config directory owned
by root. mkosi then refused to overwrite the existing
image.raw, blocking every rebuild.</p>
<p>ROOT CAUSE: _backend_build_command() for mkosi was just
["mkosi", "build"] with no CLI output flags. It trusted the
profile's mkosi.conf to set OutputDirectory=, which doesn't
exist on v0.0.x profiles, can be hand-edited to anything,
and is ignored by mkosi if the profile is a drop-in fragment
mkosi never reads.</p>
<p>FIX: _backend_build_command() now ALWAYS passes --output,
--output-dir, and --force on the CLI for mkosi builds. CLI
flags override mkosi.conf, so the output path is forced to
/var/lib/sysdeck/builder/artifacts/&lt;name&gt;/&lt;name&gt;.raw
regardless of what the profile says. --force overwrites any
existing image so rebuilds don't fail with "Output path
exists already."</p>
<p>SAFETY CHECK: build() now refuses to proceed if the
resolved output_dir is not under /var/lib/, /tmp/,
/var/tmp/, or the configured BUILDER_ARTIFACTS_DIR. Blocks
/etc/, /usr/, /boot/, /bin/, /sbin/, /lib/, /root/,
/home/, etc. Belt-and-suspenders: even if an operator
passes options.output_dir=/etc/something via the JS bridge,
the build is refused before subprocess.run is called.</p>
<p>LEGACY PROFILE WARNING: build() now detects profiles in
/etc/mkosi/mkosi.conf.d/ (the v0.0.x drop-in layout) and
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/&lt;name&gt;/
mkosi.conf for a real profile."</p>
<p>LOG IMPROVEMENT: build log header now includes the
resolved output_dir so the operator can see exactly where
the image will land before mkosi starts.</p>
<p>REGRESSION TESTS: 2 new unit tests in TestBuilderBuildPath
cover the safety check (refuses /etc/) and the legacy-profile
warning. 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 unit tests now 239 (was 237 in v0.1.0;
+2). All build-time guards pass.</p>
</description>
</release>
<release version="0.1.0" date="2026-08-19">
<description>
<p>v0.1.0 — BUILDER PROFILE FIXUP + HOST PKG IMPORT. Three
compounding bugs in the v0.0.x mkosi build path were silently
producing empty 33M images with no kernel, no systemd, no
openssh — the operator clicked Build on a freshly-created
profile and got back a 33M image.raw containing only
iana-etc + filesystem. Plus a new operator feature requested
in the same release cycle: "import current os pkg list to
profile should be an option".</p>
<p>BUG 1 (scaffold location): profile-create wrote
/etc/mkosi/mkosi.conf.d/&lt;name&gt;.conf — a drop-in fragment
that mkosi only honors when a parent /etc/mkosi/mkosi.conf
exists to layer it onto. With no parent, mkosi ran with
empty defaults. Fix: each profile now lives in its own
directory /etc/mkosi/profiles/&lt;name&gt;/mkosi.conf (the
only filename mkosi reads automatically from the cwd).
MKOSI_DIRS updated to scan /etc/mkosi/profiles first.</p>
<p>BUG 2 (Packages= syntax): _MKOSI_TEMPLATE and
_write_packages_mkosi used the indented-continuation form
which was the old systemd-mkosi (&lt;=v15) syntax. mkosi v22+
(Arch ships 25.x) expects single-line space-separated:
Packages=linux linux-firmware systemd openssh. 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.</p>
<p>BUG 3 (output routing): mkosi wrote its output to the cwd
(/etc/mkosi/mkosi.conf.d/image.raw) but build() only scanned
/var/lib/sysdeck/builder/artifacts/&lt;profile&gt;/ for
artifacts — so every successful build looked like a failure
in the panel. Fix: _MKOSI_TEMPLATE now sets OutputDirectory=
to the per-profile artifacts dir so mkosi writes directly
there.</p>
<p>NEW FEATURE: profile-import-packages subcommand. Queries
the host's explicitly-installed package set (pacman -Qqe on
Arch, apt-mark showmanual on Debian, dnf repoquery
--userinstalled on Fedora) and writes it into a profile's
package list via the existing _write_packages dispatch.
Defaults to append mode so the profile's baseline (kernel,
systemd, openssh) is preserved. Supports --mode=replace,
--dry-run for preview, and --packages= for manual override.
New polkit exec paths for pacman/apt-mark/dnf added to
org.sysdeck.builder.modify.</p>
<p>PANEL UX: each profile row in the Builder panel now has
a "Import host pkgs" button. Click — dry-run preview —
window.confirm with package count, source distro, and first
200 packages — append write. Falls back to operator cancel
without writing.</p>
<p>REGRESSION TESTS: 7 new unit tests in
TestBuilderImportHostPackages cover _detect_host_packages
dispatch (pacman path + dedup), the --packages override
end-to-end, --dry-run no-write behavior, and the
unknown-profile / no-args / bad-mode / COMMANDS-registration
error paths. 4 existing tests in TestBuilderPackagesField
updated for the new single-line Packages= syntax; 1 new test
guards against a regression where the writer emits the new
form but the reader only understands the old one.</p>
<p>VERSION SYNC: bumped 0.0.50 → 0.1.0 across all 9 release
surfaces. Total unit tests now 237 (was 228 in v0.0.50; +8
TestBuilderImportHostPackages + 1 new
test_mkosi_modern_single_line_input_parsed). All build-time
guards pass.</p>
</description>
</release>
<release version="0.0.50" date="2026-08-19">
<description>
<p>v0.0.50 — BUILD PATH NameError FIX. 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 _new_build_id() line 492:
safe_profile = re.sub(r"[^A-Za-z0-9_-]", "_", profile).</p>
<p>ROOT CAUSE: bridge/builder.py's module-level imports were
import json / os / shutil / subprocess / 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 full-
featured build operations were added), but no test ever
exercised the build() code path. 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.</p>
<p>FIX: added `import re` to the module-level imports in
bridge/builder.py. Removed the now-redundant local `import
re` inside _write_packages_vmdb2 (it was a v0.0.49 workaround
that's no longer needed — the module-level import covers both
callers).</p>
<p>REGRESSION TESTS: 9 new unit tests in
tests/test_bridge_parsers.py TestBuilderBuildPath cover
_new_build_id (format, sanitization of unsafe chars,
preservation of safe chars, and an explicit assertion that
`re` is in the builder module's globals so the bug can't
recur if anyone refactors the imports). The class also
includes build() end-to-end tests with mocked
subprocess.run — success path (verifies state file + log
file written, response shape correct, subprocess actually
called), unknown profile, no args, backend-not-installed,
and non-zero returncode records state "failed". All tests
mock the module-level BUILDER_STATE_DIR / BUILDER_LOGS_DIR
/ BUILDER_ARTIFACTS_DIR so they run hermetically.</p>
<p>AUDIT: ran an AST-based audit of bridge/builder.py to find
any other names used at module level but not imported. No
real undefined names found — every flagged item was a
comprehension local, tuple-unpacking target, except-clause
target, or __file__. The build path is now fully exercisable
by tests.</p>
<p>VERSION SYNC: bumped 0.0.49 → 0.0.50 across all 9 release
surfaces. This is a Python-only fix — no JS changes, no new
bridge subcommands, no new plugin/polkit/bridge-helper files.
All 228 unit tests pass (was 219 in v0.0.49; +9
TestBuilderBuildPath).</p>
</description>
</release>
<release version="0.0.49" date="2026-08-19">
<description>
<p>v0.0.49 — BUILDER INLINE PACKAGE LIST. Per user directive:
"we should allow adding a pacman -Sy applist.txt with a
literal list of baseline apps for the profile being generated."
Both the Create Profile and Copy shipped profile forms now
include a Baseline packages textarea, a file upload input
(applist.txt), and a merge-mode toggle (append | replace).
The package list is written to the backend-specific package
file in the same operation as the scaffold/copy.</p>
<p>BACKEND COVERAGE: all 4 backends supported (mkosi, vmdb2,
archiso, live-build). Each writes to its native package-list
location: mkosi → [Packages] section of &lt;name&gt;.conf,
vmdb2 → bootstrap.include list in &lt;name&gt;.yaml, archiso
→ packages.x86_64 in the profile dir, live-build →
config/package-lists/sysdeck.list.</p>
<p>INPUT: textarea for inline paste (one package per line, #
comments allowed) AND file upload (applist.txt / .list / .conf
accepted). File upload populates the textarea via the
browser's FileReader API so the operator can review/edit
before submitting. 1 MB cap on uploaded files.</p>
<p>MERGE MODE: operator chooses per-operation via a toggle.
append (default for Copy) preserves the baseline's existing
packages, adds the operator's, deduplicates. replace (default
for Create) overwrites the baseline's package file with the
operator's list.</p>
<p>NEW BRIDGE HELPERS in bridge/builder.py: _extract_opts
(splits argv into positional + --key=value opts),
_parse_packages_text (parses multiline text into deduped
list), _write_packages_mkosi/vmdb2/archiso/live_build
(per-backend writers), _write_packages (dispatcher).
Extended profile_create() and profile_copy() to accept
--packages=&lt;json&gt; and --mode=append|replace.</p>
<p>UPDATED shared/bridge.js: profileCreate(name, backend, base,
packagesText, mode) and profileCopy(srcName, newName, backend,
packagesText, mode). packagesText is JSON-encoded so newlines
and quotes survive the argv boundary. When omitted, no package
file is written (back-compat with v0.0.48).</p>
<p>NEW TESTS: 28 unit tests in tests/test_bridge_parsers.py
TestBuilderPackagesField cover _parse_packages_text (6),
_extract_opts (4), per-backend writers (10), dispatcher (3),
and end-to-end profile_create/profile_copy with --packages
(5). All use tempdirs; none touch real /etc/ paths.</p>
<p>VERSION SYNC: bumped 0.0.48 → 0.0.49 across all 9 release
surfaces.</p>
</description>
</release>
<release version="0.0.48" date="2026-08-19">
<description>
<p>v0.0.48 — BUILDER PROFILE-CREATE BUGFIX. An operator on an
archiso-only Arch host (or a live-build-only Debian host)
reported hitting "Error: profile-create supports ('mkosi',
'vmdb2'); archiso profiles are not scaffolded (use the shipped
ones)" when trying to create a build profile.</p>
<p>ROOT CAUSE: the v0.0.31 Create Profile dropdown in
plugins/sysdeck-builder/builder.js filtered backends to
mkosi/vmdb2 — correct — but fell back to primary.id when the
filtered list was empty. On a host whose primary backend was
archiso or live-build, the dropdown offered that backend, the
operator selected it, clicked Create, and the bridge rejected
it because archiso/live-build use shipped directory-based
profile trees, not single-file specs that can be scaffolded
from scratch.</p>
<p>FIX 1: renderCreateProfile no longer falls back to
primary.id. When no mkosi/vmdb2 backend is installed, the form
renders an inline install hint with the exact pacman/apt
command instead of a dropdown that would have errored.</p>
<p>FIX 2: a new "Copy shipped profile" form
(renderCopyProfile) lists every shipped archiso and live-build
profile discovered via profiles() and offers a one-click copy
into /etc/ via the new bridge.builder.profileCopy() method.
This is the supported way to create profiles for the
directory-based backends — the panel-side answer to the
bridge's "use the shipped ones" hint that previously had no
affordance.</p>
<p>NEW BRIDGE COMMAND: bridge/builder.py profile_copy() —
copies /usr/share/archiso/configs/&lt;src&gt;/ →
/etc/archiso/configs/&lt;new&gt;/ (and the live-build
equivalent). Validates new-name (no slashes, no "."/".." to
prevent path traversal), resolves source via profiles(),
refuses non-directory-based backends with a clear "use
profile-create" hint, refuses if destination exists. Same
polkit action as profile-create (org.sysdeck.builder.modify)
— no new polkit file needed.</p>
<p>NEW BRIDGE.JS METHOD: bridge.builder.profileCopy(srcName,
newName, backend) runs with { superuser: 'try' }, same as
profileCreate / profileDelete.</p>
<p>DESTINATION ROOTS REFACTOR: ARCHISO_COPY_DEST and
LIVE_BUILD_COPY_DEST are now module-level constants in
bridge/builder.py (was: hardcoded inside profile_copy).
Mirrors the existing ARCHISO_DIRS / LIVE_BUILD_DIRS pattern
and lets unit tests patch them with tempdirs.</p>
<p>NEW TESTS: 15 unit tests in tests/test_bridge_parsers.py
TestBuilderProfileCopy cover arg validation, source
resolution (not-found, wrong-backend, mkosi/vmdb2 rejection),
success paths (archiso + live-build), and failure modes
(dest-exists, source-not-a-dir, permission-error-with-polkit-
hint). All use tempdirs and mock.patch; none touch real /etc/
or /usr/share/ paths.</p>
<p>VERSION SYNC: bumped 0.0.47 → 0.0.48 across all 9 release
surfaces (Makefile, bridge/__init__.py, packaging/setup.py,
PKGBUILD, RPM spec, debian/changelog, compat-manifest.json,
metainfo.xml, README.md).</p>
</description>
</release>
<release version="0.0.47" date="2026-08-18">
<description>
<p>v0.0.47 — LOGIC-FLAW FIXES. 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."</p>
<p>FIREWALL: public-webserver.sh PORT-TOPOLOGY FIX. The v0.0.44
template had the cache topology backwards — it exposed Caddy on
:80 and Varnish on :8080. v0.0.47 flips it: Varnish is the
public cache front on :80, Caddy HTTP backend lives on :8080
(loopback only), Caddy HTTPS lives on :443 (public, terminates
TLS). The VARNISH_PUBLIC toggle is removed — :8080 is now
ALWAYS loopback-only (the previous default exposed the
cache-miss path to the internet, bypassing Varnish entirely).
Defense-in-depth drops added for :8080 alongside the existing
MariaDB + Caddy admin drops.</p>
<p>FIREWALL: vps-webserver.sh DEFAULT TOPOLOGY. When Varnish is
detected at all, the operator's documented setup is now the
explicit default — Varnish on :80, Caddy HTTP backend on :8080
(loopback only), Caddy HTTPS on :443. Previously this only
happened if Varnish was already listening on :80 at runtime;
now detecting Varnish is enough to flip Caddy HTTP to :8080
loopback.</p>
<p>NEW PLUGIN: sysdeck-services — first-class sidebar entry at
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 module. Adds a filter box (search by name/id/port/
process), a show-only-editable toggle, and a Refresh button.
The bridge surface (bridge.firewall.services / service-info /
set-service-port / restart-service) is unchanged; a new
bridge.services proxy was added to bridge.js so the new panel
has a clean API.</p>
<p>GLANCES: DEFAULT-ON EMBEDDED WEBUI. The panel now auto-starts
the Glances built-in webserver (glances -w --bind 127.0.0.1
--port 61208) on mount — no click required. The iframe is now
the primary view, sized to fill the viewport (min-height:
calc(100vh - 200px)). The legacy SysDeck snapshot cards are
moved into a collapsed details element at the bottom of the
page so they don't push the iframe below the fold. Manifest
CSP updated to allow frame-src http://127.0.0.1:61208 +
http://localhost:61208.</p>
<p>VERSION SYNC: bumped 0.0.46 → 0.0.47 across all 9 release
surfaces. Also caught up bridge/__init__.py + packaging/setup.py
from 0.0.45 (the v0.0.46 release bumped PKGBUILD/spec/debian
but missed these two files).</p>
</description>
</release>
<release version="0.0.46" date="2026-08-18">
<description>
<p>v0.0.46 — IN-SUITE 3RD-PARTY MODULE INSTALLER. 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."</p>
<p>NEW PLUGIN: sysdeck-modules — a first-class sidebar entry at
order 44 that replaces the side-channel cockpit-module-pull.sh
shell script. Each catalog row shows the module name, a license
badge (MIT / LGPL-2.1 / GPL-3.0 etc.), the developer/author,
the source URL, and a clickable homepage link — all rendered
INLINE next to a 1-click Install button. Clicking Install is
the operator's acceptance of the inline-displayed license. No
modal, no separate confirmation step.</p>
<p>NEW BRIDGE: bridge/modules3p.py — a 10-entry catalog covering
cockpit-machines, cockpit-podman, cockpit-storaged,
cockpit-identities, cockpit-navigator, cockpit-file-sharing,
cockpit-zfs-manager, cockpit-pacman, cockpit-sensors, and
cockpit-benchmark. Four install kinds: pacman (native package),
git (depth-1 clone), deb-tar (extract data.tar.xz from a .deb),
tarball (curl + tar -x). The bridge refuses silent installs
(no --accept-license=1 ⇒ license-not-accepted) as a guard
against malicious callers.</p>
<p>NEW POLKIT ACTION: org.sysdeck.modules3p.modify authorizes
/usr/bin/python3 /usr/lib/sysdeck/bridge/modules3p.py install|
uninstall &lt;id&gt; with auth_admin_keep for active sessions.</p>
<p>AUDIT LOG: every install / uninstall appends a JSON record
to /etc/cockpit/MODULE_LICENSES.log (shared with the legacy
cockpit-module-pull.sh). Legacy plain-text lines are preserved
as {raw: ...} records.</p>
<p>SHARED bridge.js: added bridge.modules3p surface with
catalog / status / preflight / install / uninstall / audit
methods. install + uninstall use { superuser: 'try' }.</p>
<p>THIRD_PARTY.md: appended v0.0.46 section documenting the
in-suite installer + per-entry catalog table. Updated existing
"see cockpit-module-pull.sh" notes to point at the new panel.</p>
</description>
</release>
<release version="0.0.35" date="2026-08-18">
<description>
<p>v0.0.35 — KATA SPLIT + THREE NEW MODULES. Per user directive:
"kata containers should be called SysDeck Kata and moved out
of the tools area. and dont call it hidden thats akward. next
we will integrate a jellyfin management module where it starts,
stops, and loads the admin panel in the module as well as a
photo manager of equal quality. with its own module. then a
remote fs manager such as ceph, and others but not nfs or
amanada fs"</p>
<p>KATA RESTORED AS STANDALONE SIDEBAR ENTRY. The Kata Containers
plugin was demoted to a hidden "tools" entry inside the merged
Containers &amp; VMs panel in v0.0.34 — labeled "Kata Containers
(hidden helper)" with priority -1. v0.0.35 splits Kata back out:
renamed to <strong>SysDeck Kata</strong>, moved from
plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/,
converted from a "tools" manifest entry to a "menu" entry
(label "SysDeck Kata", order 27), removed the "hidden helper"
wording, dropped the priority -1, and restored a dedicated
keywords list. The Containers panel now manages Podman only —
the Kata tab and its iframe were removed. The pre-built
cockpit-kata React bundle (index.js + index.css) is shipped
unchanged.</p>
<p>JELLYFIN MODULE. New plugin plugins/sysdeck-jellyfin/
(manifest.json + index.html + jellyfin.js) + new bridge helper
bridge/jellyfin.py. Surfaces: summary, status, start, stop,
restart, web-status, libraries. The bridge runs
<code>systemctl start/stop/restart jellyfin.service</code>
via the cockpit superuser channel (polkit
org.sysdeck.jellyfin.modify); the panel iframes the running
Jellyfin admin UI at http://127.0.0.1:8096 — same pattern as
the v0.0.34 Glances integration. Library list is best-effort
via GET /Library/VirtualFolders on the local Jellyfin instance.</p>
<p>PHOTO MANAGER MODULE. New plugin plugins/sysdeck-photos/
+ new bridge helper bridge/photos.py. Multi-backend design
(same shape as the DB Control module): PhotoPrism (port 2342,
MIT), Piwigo (port 80, GPL-2.0), Lychee (port 80, MIT),
Nextcloud-Memories (port 80, AGPL-3.0), LibrePhotos (port 3000,
MIT). Each backend is auto-detected via CLI tool / systemd unit
/ config-dir presence; the bridge runs <code>systemctl
start/stop/restart &lt;service&gt;</code> for the chosen backend
and the panel iframes its admin UI when running. Polkit action:
org.sysdeck.photos.modify.</p>
<p>REMOTE FS MANAGER MODULE. New plugin plugins/sysdeck-remotefs/
+ new bridge helper bridge/remotefs.py. Multi-backend: Ceph
(LGPL-2.1), GlusterFS (GPL-2.0), MooseFS (GPL-2.0), BeeGFS
(BeeGFS EULA — free), OrangeFS (BSD-3). Each backend is
auto-detected; the bridge runs <code>systemctl start/stop/
restart &lt;service&gt;</code> and the cluster-info subcommand
queries backend-specific cluster status (<code>ceph status
--format=json</code>, <code>gluster pool list</code>,
<code>moosefs-cli info</code>, <code>beegfs-ctl --listnodes</code>,
<code>pvfs2-server -m</code>). Polkit action:
org.sysdeck.remotefs.modify authorizes the systemctl binary plus
ceph/gluster/moosefs-cli/beegfs-ctl/pvfs2-server CLIs. NFS and
Amanda are explicitly EXCLUDED per directive — documented in
the panel footer and in bridge/remotefs.py:EXCLUDED.</p>
<p>PLUGIN COUNT: 20 → 23. The v0.0.34 hidden helper
(sysdeck-containers-kata) is renamed to sysdeck-kata and
promoted to a visible sidebar entry; three new visible modules
are added. tests/check_manifest_consistency.py expected count
updated to 23. scripts/generate-plugins.py updated to back
up + restore hand-maintained plugins (sysdeck-kata ships a
pre-built React bundle that can't be regenerated by the suite
generator).</p>
<p>VERSION SYNC. Makefile, bridge/__init__.py, packaging/setup.py,
packaging/PKGBUILD, packaging/sysdeck.spec, packaging/debian/
changelog, compat/compat-manifest.json all bumped to 0.0.35.</p>
</description>
</release>
<release version="0.0.30" date="2026-08-17">
<description>
<p>BUILDER MODULE REWRITE — target distros are now Arch Linux
and Debian. The previous bridge/builder.py 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 actually ships to.</p>
<p>Rewritten bridge/builder.py detects and surfaces the
canonical image-builder backends for the target distros:
Arch Linux uses mkosi (primary, systemd's image builder) plus
archiso (bootable Live ISOs); Debian uses vmdb2 (primary, the
Debian project's own image builder) plus live-build (Debian
Live ISOs). Detection is via shutil.which() — works on any
distro, and a Debian host with mkosi installed is still
surfaced correctly.</p>
<p>New Python subcommands: status, profiles, summary, backends,
install-hint. `summary` returns combined status + profiles in
one call so the panel renders from a single bridge spawn.
Profile discovery walks well-known config dirs per backend:
/etc/mkosi/mkosi.conf[.d/*.conf] + mkosi.profiles/*.profile for
mkosi; /usr/share/archiso/configs/* + /etc/archiso/configs/*
for archiso; /etc/vmdb2/*.yaml + /usr/share/vmdb2/specs/*.yaml
for vmdb2; any dir under /etc|/usr/share|~/.config/live-build
containing a `config/` subdir for live-build.</p>
<p>Rewrote plugins/sysdeck-builder/builder.js: replaces the
composer-cli blueprints list call with bridge.builder.summary().
Renders per-backend profile cards (grouped by backend) and
shows a distro-specific install hint when no backend is
installed (e.g. "sudo pacman -S --needed mkosi" on Arch,
"sudo apt install -y vmdb2" on Debian).</p>
<p>Updated shared/bridge.js builder surface — exposes
summary/profiles/status/backends/installHint. Updated
packaging/polkit/org.sysdeck.policy: org.sysdeck.builder.modify
action now authorizes /usr/bin/mkosi, /usr/bin/mkarchiso,
/usr/bin/vmdb2, /usr/bin/lb. osbuild + livemedia-creator
annotations removed (Fedora-only, no longer targeted).
Updated packaging/PKGBUILD optdepends: added mkosi and archiso.
Updated packaging/debian/control Suggests: added mkosi, vmdb2,
archiso, live-build. Updated compat/compat-manifest.json
builder entry: Arch and Debian distro_support upgraded from
'none' to 'full'; Fedora entry repointed from osbuild-composer
to mkosi (cross-distro). Updated plugins/sysdeck-builder/
manifest.json keywords and scripts/generate-plugins.py —
replaced 'osbuild' keyword with 'mkosi', 'vmdb2', 'archiso',
'live-build'. Synced docs (README.md, QUICKSTART.md, BLOG.md,
QA.md, docs/INSTALL.md, THIRD_PARTY.md).</p>
</description>
</release>
<release version="0.0.29" date="2026-08-17">
<description>
<p>EMAIL MIGRATION: author/maintainer contact address changed
from jeremy@dcos.net to info@dcos.net across every release
surface (debian/changelog, debian/control, setup.py, PKGBUILD,
sysdeck.spec). The author name "Jeremy Anderson" is preserved
everywhere; only the email address is replaced — the project
moved to a shared info@ inbox.</p>
<p>REMOVED DUPLICATE CONTAINER ENTRY: deleted
standalone-plugins/cockpit-podman/. Podman ships its own
native Cockpit module upstream, so bundling a second
cockpit-podman manifest here was duplicating upstream —
installing both would produce two competing sidebar entries
pointing at the same backend.</p>
<p>NEW standalone-plugins/cockpit-incus/manifest.json: sidebar
link (order 46, gated on /usr/bin/incus) for Incus system
container and VM management. Incus is the LXC/LXD successor
maintained by the Linux Containers project. This slot was the
original intent for the third standalone plugin slot, which
had been mis-assigned to podman.</p>
<p>UPDATED compat/compat-manifest.json: the
standalone_plugins.cockpit-podman entry is replaced with
standalone_plugins.cockpit-incus. Per-distro install commands:
pacman -S incus (Arch), dnf install incus (Fedora 40+),
apt install incus (Debian 13 trixie / bookworm backports).
Distro support: full on all three target distros.</p>
<p>UPDATED sysdeck-diagnose.sh: section 13 reference loop now
iterates over cockpit-incus / cockpit-machines / cockpit-ostree
(was cockpit-podman / cockpit-machines / cockpit-ostree).
Section 14 comparison text updated to refer to "the reference
plugins above" instead of "the cockpit-podman reference".</p>
</description>
</release>
<release version="0.0.28" date="2026-08-17">
<description>
<p>FIXED 2 BROKEN BRIDGE SUBCOMMANDS that slipped through v0.0.27's
"subcommand alignment" pass:</p>
<ul>
<li>firmware.py: bridge.js called `python3 firmware.py devices` but
the helper only implemented `summary`. Every visit to the Firmware
plugin page crashed with "Unknown subcommand: devices". Added a
real `devices` subcommand returning fwupdmgr's native
{Devices: [...]} shape (capital D, matches the panel's expected
access pattern).</li>
<li>benchmark.py: bridge.js called `python3 benchmark.py run-test
&lt;name&gt;` when the user clicked "Run" in the Available Tests
table, but the helper had no `run-test` subcommand. Added
`run-test` that runs `sysbench &lt;name&gt; run` and returns the
parsed result dict — same shape as run-cpu/run-memory/run-io.</li>
</ul>
<p>NEW BUILD-TIME GUARD: `check-bridge-subcommands` in `make check`.
Cross-checks every bridgeCmd() call in shared/bridge.js against the
COMMANDS dict declared in each bridge/&lt;module&gt;.py. Would have
caught both bugs above. Regression-tested: reverting firmware.py to
its v0.0.27 state causes the guard to fail with a clear message.</p>
<p>FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
.suite-progress, .suite-progress-bar, .suite-progress-fill,
.suite-stat-value, .suite-stat-label, .suite-row, .suite-row-between,
.suite-grid, .cols-2, .cols-3, .suite-col-2, .suite-col-3,
.suite-btn-primary, .suite-badge.info, .suite-input, .suite-warn.
Without them, progress bars in glances/fleet/netsec were invisible
and multi-column layouts collapsed to a single column. All added.</p>
<p>FIXED COCKPIT-SMOKE-TEST.SH: the embedded manifest used
"requires": { "cockpit": "&gt;=239" } — the broken pattern Cockpit
silently rejects. Smoke test would produce a false "Cockpit is
broken" diagnostic. Fixed to "cockpit": "239" (bare number).</p>
<p>IMPROVED DIAGNOSTIC: sysdeck-diagnose.sh now verifies
/usr/lib/sysdeck/bridge/*.py exists and is executable, and spot-
checks firmware.py `devices` and benchmark.py `run-test`
subcommands work end-to-end.</p>
<p>FIXED DOCS: bridge/__init__.py docstring still showed the broken
`python3 -m sysdeck.bridge.&lt;module&gt;` invocation pattern that
was fixed in v0.0.26. Updated to the absolute-path invocation.</p>
</description>
</release>
<release version="0.0.27" date="2026-08-17">
<description>
<p>FIXED 6 MISSING PYTHON HELPERS: mining.py, builder.py, fester.py,
kata.py, vault.py, mesh.py were never written — every plugin that
called one got "Module load failed: can't open file". Wrote minimal
stubs that return empty data so modules render with "no items".</p>
<p>FIXED 4 SUBCOMMAND MISMATCHES: bridge.js called subcommands the
Python helpers didn't have. Now aligned with the actual COMMANDS
dict in each helper: auth.smartcards→slots,
netsec.listeningPorts→sockets, integrity.trustScore→score,
integrity.runLynis→scan, firewall.listChains→chains,
firewall.listRules→ruleset, fleet.uptime/nodeCount derive from
summary, firmware.tpmInfo reads PCR0 directly via tpm2_pcrread.</p>
</description>
</release>
<release version="0.0.26" date="2026-08-17">
<description>
<p>ROOT CAUSE FOUND AND FIXED: every plugin that called a bridge
helper failed with ModuleNotFoundError: No module named
'sysdeck.bridge'. Cause: shared/bridge.js called
`python3 -m sysdeck.bridge.&lt;module&gt;`, which requires a Python
package layout (sysdeck/bridge/&lt;module&gt;.py) that doesn't exist
in the install. The actual layout is
/usr/lib/sysdeck/bridge/&lt;module&gt;.py (flat files, not a nested
package).</p>
<p>Fix: changed bridgeCmd() to call helpers by absolute path:
`python3 /usr/lib/sysdeck/bridge/&lt;module&gt;.py &lt;args&gt;`.
No package layout, no PYTHONPATH, no symlink needed.</p>
<p>Note: only firmware/fleet/themes 'worked' in v0.0.25 because they
use Promise.allSettled() (catches rejections silently) or
cockpit.file() (no Python helper needed) — they were showing
'unavailable' cards, not real data.</p>
<p>New guard: check-no-broken-python-module in `make check`.</p>
</description>
</release>
<release version="0.0.25" date="2026-08-17">
<description>
<p>ROOT CAUSE FOUND AND FIXED: every plugin page showed "Module load
failed: error loading dynamically imported module:
http://127.0.0.1:9090/cockpit/@localhost/sysdeck-common/bridge.js".</p>
<p>Cause: shared/sysdeck-common/ had bridge.js and sysdeck.css but NO
manifest.json. Cockpit only registers a directory as a package if it
contains manifest.json (packages.py:457 scans cockpit/*/manifest.json).
Without registration, every URL like
/cockpit/@localhost/sysdeck-common/bridge.js returned 404, and the
dynamic import("../sysdeck-common/bridge.js") failed.</p>
<p>Fix: added shared/manifest.json with name="sysdeck-common". Pattern
verified from cockpit's own pkg/static/manifest.json (just `{}`).</p>
</description>
</release>
<release version="0.0.24" date="2026-08-17">
<description>
<p>THOROUGH UNINSTALLER: previous `make uninstall` only removed
/usr/share/cockpit/sysdeck-* (with dash). v0.0.9-v0.0.19 installed
to /usr/share/cockpit/sysdeck/ (no dash) — that directory was NEVER
removed by uninstall, leaving stale manifests that Cockpit would
discover alongside the new ones. v0.0.24 uninstall now removes
every prior-version install path.</p>
<p>VISIBLE ERROR REPORTING: every plugin's index.html now installs
window error handlers that replace "Loading…" with the actual
error message on the page. No devtools required.</p>
<p>COCKPIT.JS PRESENCE CHECK: every plugin's index.html checks
`if (!window.cockpit)` before importing bridge.js, and shows a
clear error if cockpit.js failed to load.</p>
</description>
</release>
<release version="0.0.23" date="2026-08-17">
<description>
<p>ROOT CAUSE FOUND AND FIXED: every plugin page was stuck on "Loading…"
because shared/bridge.js did `import cockpit from "../base1/cockpit.js"`
— an ES module import. But pkg/base1/cockpit.js is NOT an ES module:
it's a UMD/IIFE that sets window.cockpit as a global. The import
returned undefined, so cockpit.spawn() threw when mount() ran, and
the plugin page never rendered.</p>
<p>Fix: replaced the broken import with `const cockpit = window.cockpit`
— exactly how cockpit's own esbuild plugin (build.js:71-83) accesses
it after rewriting `import cockpit from "cockpit"` to
`module.exports = cockpit`.</p>
<p>New guard: check-no-broken-cockpit-import in `make check` scans
every JS file for the broken pattern. Regression-tested.</p>
</description>
</release>
<release version="0.0.22" date="2026-08-17">
<description>
<p>ROOT CAUSE FOUND AND FIXED: the requires.cockpit field was set to
">=239" in every manifest since v0.0.9. Cockpit's packages.py uses
sortify_version() (a 0-pad of numeric components) to compare versions,
NOT a semver parser. ">=239" becomes ">=00000239" which is GREATER than
any real cockpit version, causing packages.py:263 to raise JsonError
and silently reject every manifest at install time.</p>
<p>Fix: changed requires.cockpit from ">=239" to "239" (bare number) in
all 18 manifests. This matches the pattern in cockpit's own
pkg/systemd/manifest.json ("cockpit": "265").</p>
<p>Also renamed all 18 sidebar labels from "SD &lt;Name&gt;" to
"SysDeck &lt;Name&gt;" per user requirement: SysDeck should never be
abbreviated.</p>
</description>
</release>
<release version="0.0.21" date="2026-08-17">
<description>
<p>Fix AppStream metainfo: use &lt;launchable type="cockpit-manifest"&gt;
instead of &lt;provides&gt;&lt;cockpit-manifest&gt; — matching the pattern
used by cockpit-project's own plugins in src/appstream/.</p>
<p>Rewrite tests/check_manifest_consistency.py to validate against the
REAL Cockpit manifest contract from pkg/shell/manifests.ts and
src/cockpit/packages.py, not the invented contract used in v0.0.19-v0.0.20.</p>
</description>
</release>
<release version="0.0.20" date="2026-08-17">
<description>
<p>ARCHITECTURAL OVERHAUL: split the single SysDeck shell into 18
standalone Cockpit plugins.</p>
</description>
</release>
<release version="0.0.19" date="2026-08-17">
<description>
<p>Rewrote manifest to match cockpit-podman reference pattern.</p>
</description>
</release>
</releases>
</component>

1502
packaging/sysdeck.spec Executable file

File diff suppressed because it is too large Load Diff

50
plugins/sysdeck-auth/auth.js Executable file
View File

@ -0,0 +1,50 @@
/*
* SysDeck - Hardware Auth Panel
* Author: Jeremy Anderson (https://dcos.net)
*
* Lists smartcard reader slots via pkcs11-tool. Falls back to a hint
* card when opensc / pcsc-lite is absent.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const slots = await bridge.auth.smartcards();
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Hardware Auth</h2>
<p class="suite-panel-subtitle">PKCS#11 / pcsc-lite — ${slots.length} slots</p>
</header>
<div class="suite-card">
<h3 class="suite-card-title">Smartcard Readers</h3>
<div class="suite-card-body">
${slots.length
? `<ul>${slots.map((s) => `<li class="suite-mono">${s.description}</li>`).join('')}</ul>`
: '<p class="suite-muted">No smartcard readers detected. Install <code>opensc</code> and <code>pcsc-lite</code>, then start <code>pcscd.service</code>.</p>'}
</div>
</div>
<div class="suite-card">
<h3 class="suite-card-title">Quick Actions</h3>
<div class="suite-card-body suite-row">
<button class="suite-btn" id="btn-list-certs">List Certificates</button>
<button class="suite-btn" id="btn-reader-info">Reader Info</button>
</div>
</div>
`;
panel.querySelector('#btn-list-certs')?.addEventListener('click', async () => {
try {
const out = await bridge.spawn(['pkcs11-tool', '--list-objects', '--type', 'cert']);
EventBus.emit('auth.list-certs', { count: (out.match(/Certificate/g) || []).length });
alert(out);
} catch (err) { alert(err.message || err); }
});
panel.querySelector('#btn-reader-info')?.addEventListener('click', async () => {
try {
const out = await bridge.spawn(['pcsc_scan']);
alert(out);
} catch (err) { alert(err.message || err); }
});
}
function renderSkeleton() {
return `<div class="suite-skeleton"><div class="suite-skeleton-line w-1/2"></div></div>`;
}

69
plugins/sysdeck-auth/index.html Executable file
View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Hardware Auth</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./auth.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
{
"version": 0,
"name": "sysdeck-auth",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Hardware Auth",
"order": 33,
"keywords": [
{
"matches": [
"auth",
"smartcard",
"pkcs11",
"opensc",
"kerberos",
"ssh"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,119 @@
/*
* SysDeck - Benchmark Panel (v0.0.11)
* Author: Jeremy Anderson (https://dcos.net)
*
* System benchmarking via sysbench. Attributes the cockpit-benchmark
* plugin (MIT, ealier) whose standalone plugin lives at
* https://github.com/ealier/cockpit-benchmark.
*
* This panel invokes sysbench via cockpit.spawn as a separate process —
* no cockpit-benchmark or sysbench code is bundled. sysbench is GPL-2.0
* licensed; the suite (MIT) and sysbench remain independent programs.
*
* Shows available tests, run controls, and results history.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
let tests = [];
try {
tests = await bridge.benchmark.listTests();
} catch (err) {
panel.innerHTML = renderError(err);
return;
}
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">System Benchmark</h2>
<p class="suite-panel-subtitle">sysbench + cockpit-benchmark integration</p>
<span class="suite-badge info">MIT · ealier</span>
</header>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Available Tests</h3>
<button class="suite-btn suite-btn-ghost" id="btn-benchmark-refresh">↻ Refresh</button>
</div>
<table class="suite-table">
<thead><tr><th>Test</th><th>Tool</th><th>Actions</th></tr></thead>
<tbody>
${tests.map((t) => `<tr>
<td class="suite-table-mono">${t.name}</td>
<td>${t.tool}</td>
<td><button class="suite-btn suite-btn-primary" data-test="${t.name}">Run</button></td>
</tr>`).join('') || '<tr><td colspan="3" class="suite-muted">No benchmarks available. Install sysbench.</td></tr>'}
</tbody>
</table>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Quick Benchmarks</h3>
</div>
<div class="suite-row">
<button class="suite-btn suite-btn-primary" id="btn-run-cpu">CPU Benchmark</button>
<button class="suite-btn suite-btn-primary" id="btn-run-memory">Memory Benchmark</button>
<button class="suite-btn suite-btn-primary" id="btn-run-io">File I/O Benchmark</button>
</div>
<div id="benchmark-results" class="suite-muted">No results yet. Click a benchmark to run.</div>
</div>
`;
const resultsDiv = panel.querySelector('#benchmark-results');
const runBench = async (name, resultFn) => {
resultsDiv.innerHTML = `<em>Running ${name} benchmark...</em>`;
try {
const result = await resultFn();
EventBus.emit('benchmark.run', { test: name, result });
resultsDiv.innerHTML = `
<h4>${name} Results</h4>
<p>Events/sec: <strong>${result.events_per_sec ?? 'N/A'}</strong></p>
<p>Avg latency: <strong>${result.latency_ms != null ? result.latency_ms.toFixed(2) + ' ms' : 'N/A'}</strong></p>
<details><summary>Raw output</summary><pre>${result.raw || 'N/A'}</pre></details>
`;
} catch (err) {
resultsDiv.innerHTML = `<span class="suite-badge warn">Error: ${err.message || err}</span>`;
}
};
panel.querySelector('#btn-run-cpu')?.addEventListener('click', () => runBench('CPU', () => bridge.benchmark.runCpu()));
panel.querySelector('#btn-run-memory')?.addEventListener('click', () => runBench('Memory', () => bridge.benchmark.runMemory()));
panel.querySelector('#btn-run-io')?.addEventListener('click', () => runBench('File I/O', () => bridge.benchmark.runIo()));
panel.querySelectorAll('[data-test]').forEach((btn) => {
btn.addEventListener('click', async () => {
const test = btn.dataset.test;
btn.disabled = true;
try {
const result = await bridge.benchmark.runTest(test);
EventBus.emit('benchmark.run', { test });
resultsDiv.innerHTML = `<h4>${test} completed</h4><pre>${result}</pre>`;
} catch (err) {
resultsDiv.innerHTML = `<span class="suite-badge warn">${err.message || err}</span>`;
}
btn.disabled = false;
});
});
panel.querySelector('#btn-benchmark-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}
function renderError(err) {
return `<div class="suite-card">
<h3 class="suite-card-title">Benchmark tools unavailable</h3>
<p class="suite-card-body suite-muted">${err.message || err}. Install sysbench for system benchmarking.</p>
<p class="suite-muted">cockpit-benchmark (MIT) by ealier — <a href="https://github.com/ealier/cockpit-benchmark">https://github.com/ealier/cockpit-benchmark</a></p>
<p class="suite-muted">sysbench (GPL-2.0) — <a href="https://github.com/akopytov/sysbench">https://github.com/akopytov/sysbench</a></p>
</div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Benchmark</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./benchmark.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,24 @@
{
"version": 0,
"name": "sysdeck-benchmark",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Benchmark",
"order": 36,
"keywords": [
{
"matches": [
"benchmark",
"sysbench",
"stress",
"performance"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,969 @@
/*
* SysDeck - Image Builder Panel (v0.0.49)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.49 PACKAGES FIELD. Both the Create Profile and Copy shipped
* profile forms now include a Baseline packages textarea, a file
* upload input (applist.txt), and a merge-mode toggle (append |
* replace). The package list is written to the backend-specific
* package file in the same operation as the scaffold/copy:
* mkosi → [Packages] section of <name>.conf
* vmdb2 → bootstrap.include list in <name>.yaml
* archiso → packages.x86_64 in the profile dir
* live-build → config/package-lists/sysdeck.list
* Default mode is "replace" for Create (the scaffold's minimal
* defaults are replaced by the operator's list) and "append" for
* Copy (the baseline's packages are preserved, the operator's list
* adds to them). The textarea is the source of truth — file uploads
* populate the textarea via FileReader so the operator can review/
* edit before submitting.
*
* v0.0.48 FIX: profile-create only supports mkosi/vmdb2 (single-file
* specs). The v0.0.31 Create Profile dropdown fell back to `primary.id`
* when neither was installed — on an archiso-only or live-build-only
* host, the operator was funneled straight into the "profile-create
* supports ('mkosi', 'vmdb2')" error. Fixed by (a) gating the Create
* Profile form on a scaffoldable backend being installed and showing
* an inline install hint otherwise, and (b) adding a new "Copy shipped
* profile" form that calls the new bridge.builder.profileCopy() to
* copy a shipped archiso/live-build profile tree into /etc/.
*
* v0.0.31 EXPANDED TO FULL-FEATURED. v0.0.30 was a status + profile
* viewer: it could list installed backends (mkosi/vmdb2/archiso/
* live-build) and walk their config dirs, but could not actually
* build anything, could not create/edit profiles, could not show
* build artifacts or logs.
*
* v0.0.31 adds:
* - Build button per profile — invokes the backend in the profile's
* directory via subprocess under the cockpit superuser channel
* (polkit org.sysdeck.builder.modify). The build runs synchronously
* and streams stdout+stderr to a log file under
* /var/lib/sysdeck/builder/logs/<build-id>.log.
* - Builds table — state (running / succeeded / failed), profile,
* backend, started, finished, duration, artifacts, with a
* View Log button per build.
* - Per-build log viewer — tails the build's log file (capped at
* 1MB to avoid blowing up the JSON response for huge builds).
* - Artifacts card — lists image/ISO files produced by past builds
* under /var/lib/sysdeck/builder/artifacts/<profile>/.
* - Create Profile form — scaffolds a new mkosi.conf or vmdb2 YAML
* in /etc/mkosi/mkosi.conf.d/ or /etc/vmdb2/. The operator edits
* the scaffolded file before building. v0.0.48: only shown when a
* scaffoldable backend (mkosi/vmdb2) is installed; otherwise an
* inline hint is shown instead.
* v0.0.49: the form now includes a Baseline packages textarea +
* file upload + merge-mode toggle (append | replace). The package
* list is written to the backend-specific package file in the same
* operation as the scaffold.
* - Copy shipped profile form — (v0.0.48) copies a shipped archiso
* or live-build profile tree from /usr/share/ into /etc/ so the
* operator can edit it before building. The supported way to
* create profiles for the directory-based backends.
* v0.0.49: same Baseline packages textarea + file upload + merge-
* mode toggle as Create Profile. Default mode for Copy is append
* (preserves the baseline's packages); for Create it's replace.
* - Delete Profile button — removes operator-created profiles.
* Refuses to delete shipped profiles under /usr/share.
*
* v0.0.30 backend detection and profile discovery are preserved.
* When no backend is installed, the panel still renders the install
* hint with the exact pacman/apt command for the host distro.
*
* Bridge surface (see shared/bridge.js → bridge.builder):
* summary() → {state, primary, backends, profiles, distro, ...}
* installHint() → {primary, primary_cmd, iso, iso_cmd} per host distro
* build(profile, backend, options) → {build_id, rc, success, ...}
* profileCreate(name, backend, base) → {created, path, ...}
* profileCopy(srcName, newName, backend) → {copied, path, ...} // v0.0.48
* profileDelete(name, force) → {deleted, ...}
* buildStatus() → [{build_id, state, ...}, ...]
* buildLog(id) → {build_id, log, path}
* artifacts(profile?) → {by_profile, artifacts}
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const summary = await safe(bridge.builder.summary(), {
state: 'unavailable',
primary: null,
backends: [],
profiles: [],
profileCount: 0,
distro: 'unknown',
});
const builds = await safe(bridge.builder.buildStatus(), []);
const artifacts = await safe(bridge.builder.artifacts(), { by_profile: {}, artifacts: 0 });
const active = summary.state === 'active';
const primary = summary.primary;
const backends = summary.backends || [];
const profiles = summary.profiles || [];
const distro = summary.distro || 'unknown';
// Group profiles by backend for the panel rendering.
const byBackend = new Map();
for (const p of profiles) {
const key = p.backend || (primary ? primary.id : 'unknown');
if (!byBackend.has(key)) byBackend.set(key, []);
byBackend.get(key).push(p);
}
let hint = null;
if (!active) {
hint = await safe(bridge.builder.installHint(), null);
}
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Image Builder</h2>
<p class="suite-panel-subtitle">
${primary
? `${primary.id} ${primary.version || ''} — <span class="suite-badge success">${summary.state}</span>`
: `no backend installed — <span class="suite-badge danger">${summary.state}</span>`}
· ${builds.length} build${builds.length === 1 ? '' : 's'} tracked
· ${artifacts.artifacts || 0} artifact${(artifacts.artifacts || 0) === 1 ? '' : 's'}
</p>
</header>
${active
? renderBackends(backends, primary)
: renderHint(hint, distro)}
${active ? renderProfiles(byBackend, primary, profiles.length, { bridge, EventBus }) : ''}
${active ? renderCreateProfile(backends, primary) : ''}
${active ? renderCopyProfile(profiles, primary) : ''}
${renderBuilds(builds)}
<div class="suite-card" id="builder-log-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title" id="builder-log-title">Build Log</h3>
<button class="suite-btn suite-btn-ghost" id="btn-builder-log-close">✕</button>
</div>
<pre class="suite-mono" id="builder-log-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:400px"></pre>
</div>
${active ? renderArtifacts(artifacts) : ''}
`;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('builder.loaded', { active, primary, buildCount: builds.length });
}
async function safe(p, fallback) {
try {
const v = await p;
return v ?? fallback;
} catch {
return fallback;
}
}
function renderBackends(backends, primary) {
if (!backends.length) return '';
return `
<div class="suite-card">
<h3 class="suite-card-title">Installed backends (${backends.length})</h3>
<div class="suite-card-body">
<ul class="suite-list">
${backends.map(b => `
<li class="suite-mono">
<strong>${b.id}</strong>
${b.version ? `<span class="suite-muted"> ${escapeHtml(b.version)}</span>` : ''}
${b.id === (primary && primary.id) ? '<span class="suite-badge success">primary</span>' : ''}
<span class="suite-muted">(${b.kind})</span>
</li>
`).join('')}
</ul>
</div>
</div>
`;
}
function renderProfiles(byBackend, primary, total, _ctx) {
if (!total) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Profiles (0)</h3>
<div class="suite-card-body">
<p class="suite-muted">
No build profiles found. Use the Create Profile form
below to scaffold a new <code>${primary ? primary.id : 'mkosi'}.conf</code>,
or drop one in <code>/etc/${primary ? primary.id : 'mkosi'}/</code>.
</p>
</div>
</div>
`;
}
const cards = [];
for (const [backendId, items] of byBackend) {
cards.push(`
<div class="suite-card">
<h3 class="suite-card-title">${escapeHtml(backendId)} profiles (${items.length})</h3>
<div class="suite-card-body">
<table class="suite-table">
<thead><tr><th>Name</th><th>Type</th><th>Path</th><th>Build</th><th>Packages</th></tr></thead>
<tbody>
${items.map(p => `
<tr>
<td class="suite-table-mono"><strong>${escapeHtml(p.name)}</strong></td>
<td class="suite-muted">${escapeHtml(p.type)}</td>
<td class="suite-table-mono suite-muted" style="max-width:300px;overflow:hidden;text-overflow:ellipsis">${escapeHtml(p.path)}</td>
<td>
<button class="suite-btn suite-btn-primary btn-builder-build"
data-profile="${escapeHtml(p.name)}"
data-backend="${escapeHtml(backendId)}">▶ Build</button>
</td>
<td>
<button class="suite-btn suite-btn-ghost btn-builder-import"
data-profile="${escapeHtml(p.name)}"
title="Import this host's explicitly-installed packages into ${escapeHtml(p.name)}">⇩ Import host pkgs</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`);
}
return cards.join('');
}
function renderPackagesField(prefix, defaultMode) {
// v0.0.49: shared package-list field used by both renderCreateProfile
// and renderCopyProfile. Emits:
// - a <textarea> for inline paste (one package per line, # comments ok)
// - a file <input> that populates the textarea via FileReader
// - a merge-mode <select> (append | replace)
// The `prefix` distinguishes element IDs so both forms can coexist
// on the same page (e.g. "cp-create-" vs "cp-copy-"). The
// `defaultMode` is the form's default — "replace" for Create (the
// scaffold's minimal defaults are replaced by the operator's list),
// "append" for Copy (the baseline's packages are preserved).
//
// The textarea is the source of truth: file uploads populate the
// textarea so the operator can review/edit before submitting. The
// submit handler reads the textarea value and passes it to the
// bridge as a JSON-encoded string.
return `
<div style="margin-top:0.75rem;border-top:1px dashed #444;padding-top:0.5rem">
<label class="suite-muted" style="font-size:0.85rem;display:block;margin-bottom:0.25rem">
Baseline packages <span class="suite-muted">(optional — one per line, <code>#</code> comments allowed)</span>
</label>
<textarea class="suite-input" id="${prefix}-packages" rows="6"
placeholder="linux&#10;linux-firmware&#10;base&#10;vim&#10;nginx&#10;# my baseline apps"
style="width:100%;font-family:monospace;font-size:0.85rem"></textarea>
<div class="suite-row" style="gap:0.5rem;margin-top:0.4rem;align-items:center;flex-wrap:wrap">
<label class="suite-muted" style="font-size:0.8rem">
or upload <code>applist.txt</code>:
<input type="file" id="${prefix}-packages-file" accept=".txt,.list,.conf,text/plain"
style="font-size:0.8rem;display:inline-block;margin-left:0.25rem" />
</label>
<label class="suite-muted" style="font-size:0.8rem;margin-left:auto">
merge mode:
<select class="suite-input" id="${prefix}-mode" style="width:auto;display:inline-block;margin-left:0.25rem">
<option value="append" ${defaultMode === 'append' ? 'selected' : ''}>append (add to baseline)</option>
<option value="replace" ${defaultMode === 'replace' ? 'selected' : ''}>replace (overwrite)</option>
</select>
</label>
</div>
</div>
`;
}
function renderCreateProfile(backends, primary) {
// v0.0.48: only mkosi and vmdb2 are scaffoldable via profile-create.
// The previous code fell back to `primary.id` when neither was
// installed, which on an archiso-only or live-build-only host
// funneled the operator straight into the "profile-create supports
// ('mkosi', 'vmdb2')" error. Now we render an inline hint instead.
const scaffoldable = backends.filter(b => b.id === 'mkosi' || b.id === 'vmdb2');
if (!scaffoldable.length) {
const primaryId = primary ? primary.id : '(none)';
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Create Profile</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
No scaffoldable backend is installed. <code>profile-create</code>
only supports <code>mkosi</code> and <code>vmdb2</code> (single-file
specs). This host's primary backend is
<code>${escapeHtml(primaryId)}</code>, which uses shipped
directory-based profiles — copy one with the form below
instead, or install a scaffoldable backend:
</p>
<ul class="suite-list" style="margin-top:0.5rem">
<li class="suite-mono"><strong>Arch:</strong> sudo pacman -S --needed mkosi</li>
<li class="suite-mono"><strong>Debian:</strong> sudo apt install -y vmdb2</li>
</ul>
</div>
</div>
`;
}
const backendOptions = scaffoldable
.map(b => `<option value="${b.id}">${b.id}</option>`).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Create Profile</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
Scaffolds a minimal profile in <code>/etc/&lt;backend&gt;/</code>.
Edit the scaffolded file before building. mkosi and vmdb2 only —
archiso / live-build use shipped profile dirs you should copy
via the "Copy shipped profile" form below.
</p>
<div class="suite-row" style="gap:0.5rem;margin-top:0.5rem">
<input type="text" class="suite-input" id="cp-name" placeholder="profile name (e.g. myarch)" style="flex:1" />
<select class="suite-input" id="cp-backend" style="width:120px">
${backendOptions}
</select>
<button class="suite-btn suite-btn-primary" id="btn-builder-create">+ Create</button>
</div>
${renderPackagesField('cp-create', 'replace')}
</div>
</div>
`;
}
function renderCopyProfile(profiles, primary) {
// v0.0.48: directory-based backends (archiso, live-build) ship
// profile trees under /usr/share that the operator should copy
// into /etc/ and edit. This form lists every shipped profile of
// those backends discovered via profiles() and offers a one-click
// copy via bridge.builder.profileCopy().
const copyable = profiles.filter(p => p.backend === 'archiso' || p.backend === 'live-build');
if (!copyable.length) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Copy shipped profile</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
No shipped archiso / live-build profiles were discovered
on this host. Install one of the ISO backends to make
shipped baselines available:
</p>
<ul class="suite-list" style="margin-top:0.5rem">
<li class="suite-mono"><strong>Arch:</strong> sudo pacman -S --needed archiso</li>
<li class="suite-mono"><strong>Debian:</strong> sudo apt install -y live-build</li>
</ul>
</div>
</div>
`;
}
// Group options by backend for clarity.
const grouped = new Map();
for (const p of copyable) {
if (!grouped.has(p.backend)) grouped.set(p.backend, []);
grouped.get(p.backend).push(p);
}
const optGroups = [...grouped.entries()].map(([backend, items]) => {
const opts = items.map(p =>
`<option value="${escapeHtml(p.name)}|${escapeHtml(p.backend)}">${escapeHtml(p.name)} (${escapeHtml(p.backend)})</option>`
).join('');
return `<optgroup label="${escapeHtml(backend)}">${opts}</optgroup>`;
}).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Copy shipped profile</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
Copies a shipped <code>archiso</code> or <code>live-build</code>
profile tree from <code>/usr/share/</code> into
<code>/etc/</code> so you can edit it before building.
This is the supported way to create new profiles for the
directory-based backends (they cannot be scaffolded from
scratch — <code>profile-create</code> only handles the
single-file <code>mkosi</code>/<code>vmdb2</code> specs).
</p>
<div class="suite-row" style="gap:0.5rem;margin-top:0.5rem">
<select class="suite-input" id="cp-copy-src" style="flex:1">
${optGroups}
</select>
<input type="text" class="suite-input" id="cp-copy-name" placeholder="new name (e.g. myarch)" style="flex:1" />
<button class="suite-btn suite-btn-primary" id="btn-builder-copy">⎘ Copy</button>
</div>
${renderPackagesField('cp-copy', 'append')}
</div>
</div>
`;
}
function renderBuilds(builds) {
if (!builds || !builds.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Builds (0)</h3>
<div class="suite-card-body">
<p class="suite-muted">No builds run yet. Click ▶ Build on a profile above to start one.</p>
</div>
</div>
`;
}
const rows = builds.map((b) => {
const stateBadge = b.state === 'succeeded'
? '<span class="suite-badge success">succeeded</span>'
: b.state === 'failed'
? '<span class="suite-badge danger">failed</span>'
: '<span class="suite-badge warn">running</span>';
const duration = b.duration_s != null ? `${b.duration_s.toFixed(1)}s` : '—';
const artifacts = (b.artifacts || []).map(a => escapeHtml(a.name)).join(', ') || '—';
return `
<tr>
<td class="suite-table-mono suite-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${escapeHtml(b.build_id)}</td>
<td class="suite-table-mono">${escapeHtml(b.profile)}</td>
<td class="suite-muted">${escapeHtml(b.backend)}</td>
<td>${stateBadge}</td>
<td class="suite-muted">${escapeHtml(b.started || '—')}</td>
<td class="suite-muted">${escapeHtml(b.finished || '—')}</td>
<td class="suite-muted">${duration}</td>
<td class="suite-table-mono suite-muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis">${artifacts}</td>
<td>
<button class="suite-btn suite-btn-ghost btn-builder-log" data-build-id="${escapeHtml(b.build_id)}">📜 Log</button>
<button class="suite-btn suite-btn-ghost btn-builder-delete" data-build-id="${escapeHtml(b.build_id)}" data-profile="${escapeHtml(b.profile)}" title="Delete build record">🗑</button>
</td>
</tr>
`;
}).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Builds (${builds.length})</h3>
<button class="suite-btn suite-btn-ghost" id="btn-builder-refresh">↻ Refresh</button>
</div>
<table class="suite-table">
<thead><tr><th>Build ID</th><th>Profile</th><th>Backend</th><th>State</th><th>Started</th><th>Finished</th><th>Duration</th><th>Artifacts</th><th>Actions</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
function renderArtifacts(artifacts) {
const byProfile = artifacts.by_profile || {};
const profiles = Object.keys(byProfile);
if (!profiles.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Artifacts (0)</h3>
<div class="suite-card-body">
<p class="suite-muted">No artifacts yet. Build outputs land under <code>/var/lib/sysdeck/builder/artifacts/&lt;profile&gt;/</code>.</p>
</div>
</div>
`;
}
const cards = profiles.map((p) => {
const files = byProfile[p] || [];
if (!files.length) return '';
const totalSize = files.reduce((s, f) => s + (f.size || 0), 0);
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">${escapeHtml(p)} artifacts (${files.length}, ${formatSize(totalSize)})</h3>
<button class="suite-btn suite-btn-ghost btn-artifacts-clear"
data-profile="${escapeHtml(p)}"
title="Delete ALL artifacts for ${escapeHtml(p)}">🗑 Clear all</button>
</div>
<table class="suite-table">
<thead><tr><th>Name</th><th>Size</th><th>Modified</th><th>Actions</th></tr></thead>
<tbody>
${files.map(f => `
<tr>
<td class="suite-table-mono">${escapeHtml(f.name)}</td>
<td class="suite-muted">${formatSize(f.size)}</td>
<td class="suite-muted">${escapeHtml(f.modified || '—')}</td>
<td>
<button class="suite-btn suite-btn-ghost btn-artifact-download"
data-profile="${escapeHtml(p)}"
data-name="${escapeHtml(f.name)}"
data-path="${escapeHtml(f.path)}"
title="Download ${escapeHtml(f.name)}">⬇ Download</button>
<button class="suite-btn suite-btn-ghost btn-artifact-delete"
data-profile="${escapeHtml(p)}"
data-name="${escapeHtml(f.name)}"
title="Delete ${escapeHtml(f.name)}">🗑</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}).join('');
return cards;
}
function renderHint(hint, distro) {
const fallback = `
<div class="suite-card">
<h3 class="suite-card-title">Install an image builder</h3>
<div class="suite-card-body">
<p class="suite-muted">
No image-builder backend was detected on this host.
Install one of the supported tools to enable this panel:
</p>
<ul class="suite-list">
<li class="suite-mono"><strong>Arch Linux:</strong> sudo pacman -S --needed mkosi</li>
<li class="suite-mono"><strong>Debian:</strong> sudo apt install -y vmdb2</li>
</ul>
<p class="suite-muted">
Detected distro: <code>${escapeHtml(distro)}</code>
</p>
</div>
</div>
`;
if (!hint) return fallback;
return `
<div class="suite-card">
<h3 class="suite-card-title">Install an image builder</h3>
<div class="suite-card-body">
<p class="suite-muted">
No image-builder backend was detected on this host.
For <strong>${escapeHtml(distro)}</strong>, the recommended
primary tool is <code>${escapeHtml(hint.primary)}</code>:
</p>
<pre class="suite-mono suite-cmd">${escapeHtml(hint.primary_cmd)}</pre>
<p class="suite-muted">
For bootable ISOs, use <code>${escapeHtml(hint.iso)}</code>:
</p>
<pre class="suite-mono suite-cmd">${escapeHtml(hint.iso_cmd)}</pre>
</div>
</div>
`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
const logCard = panel.querySelector('#builder-log-card');
const logPre = panel.querySelector('#builder-log-pre');
const logTitle = panel.querySelector('#builder-log-title');
const showLog = (title, text) => {
if (!logCard || !logPre) return;
logCard.style.display = 'block';
logTitle.textContent = title;
logPre.textContent = text;
};
panel.querySelector('#btn-builder-log-close')?.addEventListener('click', () => {
if (logCard) logCard.style.display = 'none';
});
// Build buttons (one per profile row).
panel.querySelectorAll('.btn-builder-build').forEach((btn) => {
btn.addEventListener('click', async () => {
const profile = btn.dataset.profile;
const backend = btn.dataset.backend;
btn.disabled = true;
btn.textContent = '⏳ Building ...';
showLog(`Build ${profile} (${backend})`, `Running ${backend} build via cockpit superuser channel...\n(cockpit will prompt for auth)\n\nThis may take several minutes. The build runs synchronously — you can navigate away and check the Builds table for status.`);
try {
const r = await bridge.builder.build(profile, backend);
const lines = [];
lines.push(`Build ID: ${r.build_id}`);
lines.push(`Profile: ${r.profile}`);
lines.push(`Backend: ${r.backend}`);
lines.push(`State: ${r.state}`);
lines.push(`Exit: ${r.rc}`);
if (r.duration_s != null) lines.push(`Duration: ${r.duration_s.toFixed(1)}s`);
if (r.artifacts && r.artifacts.length) {
lines.push('');
lines.push('Artifacts:');
for (const a of r.artifacts) lines.push(` ${a.name} (${formatSize(a.size)})`);
}
lines.push('');
lines.push('Log path: ' + (r.log_path || '(unknown)'));
// Fetch the full log.
try {
const logResult = await bridge.builder.buildLog(r.build_id);
if (logResult.log) {
lines.push('');
lines.push('--- log ---');
lines.push(logResult.log);
}
} catch (err) {
lines.push(`(log fetch failed: ${err.message || err})`);
}
showLog(`Build ${profile} — ${r.state}`, lines.join('\n'));
EventBus.emit('builder.build-done', r);
setTimeout(() => mount(panel, { bridge, EventBus }), 1000);
} catch (err) {
showLog(`Build ${profile} — error`, String(err.message || err));
} finally {
btn.disabled = false;
btn.textContent = '▶ Build';
}
});
});
// v0.1.0: Import host pkgs buttons (one per profile row). Two-step:
// first a dry-run preview so the operator sees the package count
// and source distro before committing, then on confirm an actual
// append-mode write. Append is the safe default — the profile's
// existing baseline (kernel, systemd, openssh) is preserved and
// the host's explicitly-installed packages are layered on top.
panel.querySelectorAll('.btn-builder-import').forEach((btn) => {
btn.addEventListener('click', async () => {
const profile = btn.dataset.profile;
btn.disabled = true;
btn.textContent = '⏳ Querying...';
showLog(`Import host packages — ${profile}`, 'Querying host package manager via cockpit superuser channel...');
try {
// Step 1: dry-run preview.
const preview = await bridge.builder.profileImportPackages(profile, 'append', true);
if (preview.error) {
showLog(`Import host packages — ${profile} (error)`, `Host query failed:\n${preview.error}\n\nHint: ${preview.hint || 'ensure pacman/apt/dnf is installed on the host.'}`);
return;
}
const cnt = preview.package_count || 0;
const src = preview.source || 'unknown';
const distro = preview.host_distro || 'unknown';
const truncated = preview.truncated
? `\n(showing first 200 of ${cnt}; full list will be written on confirm)`
: '';
const sample = (preview.packages || []).join('\n');
// Step 2: confirm and write.
const go = window.confirm(
`Import ${cnt} explicitly-installed packages from this host (${distro})\n` +
`into profile '${profile}' in APPEND mode?\n\n` +
`Source: ${src}\n\n` +
`Sample (first ${Math.min(cnt, 200)}):\n${sample}${truncated}`
);
if (!go) {
showLog(`Import host packages — ${profile} (cancelled)`, 'Operator cancelled. Profile file untouched.');
return;
}
btn.textContent = '⏳ Writing...';
const r = await bridge.builder.profileImportPackages(profile, 'append', false);
const lines = [];
if (r.imported) {
lines.push(`Imported ${r.count || 0} packages from ${r.source} into '${r.profile}'.`);
lines.push(`Backend: ${r.backend}`);
lines.push(`Mode: ${r.mode}`);
lines.push(`Path: ${r.path}`);
} else if (r.error) {
lines.push(`Import failed: ${r.error}`);
}
showLog(`Import host packages — ${profile} — ${r.imported ? 'done' : 'error'}`, lines.join('\n'));
EventBus.emit('builder.import-done', r);
setTimeout(() => mount(panel, { bridge, EventBus }), 1000);
} catch (err) {
showLog(`Import host packages — ${profile} — error`, String(err.message || err));
} finally {
btn.disabled = false;
btn.textContent = '⇩ Import host pkgs';
}
});
});
// Create Profile form.
panel.querySelector('#btn-builder-create')?.addEventListener('click', async () => {
const nameInput = panel.querySelector('#cp-name');
const backendSelect = panel.querySelector('#cp-backend');
const name = nameInput?.value?.trim();
const backend = backendSelect?.value;
if (!name) { showLog('Create profile — error', 'Profile name required.'); return; }
if (!backend) { showLog('Create profile — error', 'Backend selection required.'); return; }
// v0.0.49: read optional package list + merge mode.
const packagesText = panel.querySelector('#cp-create-packages')?.value || '';
const mode = panel.querySelector('#cp-create-mode')?.value || 'replace';
const pkgSummary = packagesText.trim()
? ` (${mode} mode, ${packagesText.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#')).length} packages)`
: '';
showLog('Create profile', `Scaffolding ${backend} profile '${name}'${pkgSummary} via cockpit superuser channel...`);
try {
const r = await bridge.builder.profileCreate(name, backend, null,
packagesText.trim() || null, mode);
if (r.created) {
const lines = [
`Created ${r.backend} profile '${r.name}'.`,
'',
`Path: ${r.path}`,
`Template: ${r.template}`,
];
if (r.packages) {
lines.push(`Packages: ${r.packages.count} (${r.packages.mode} mode)`);
lines.push(`Package file: ${r.packages.path}`);
} else if (r.packages_error) {
lines.push(`Packages: ERROR — ${r.packages_error}`);
}
lines.push('', 'Edit the file(s) before building.');
showLog('Create profile — success', lines.join('\n'));
setTimeout(() => mount(panel, { bridge, EventBus }), 1200);
} else {
showLog('Create profile — failed', `Error: ${r.error || 'unknown'}\n\n${r.hint || ''}`);
}
} catch (err) {
showLog('Create profile — error', String(err.message || err));
}
});
// v0.0.48: Copy shipped profile form (archiso / live-build).
panel.querySelector('#btn-builder-copy')?.addEventListener('click', async () => {
const srcSelect = panel.querySelector('#cp-copy-src');
const nameInput = panel.querySelector('#cp-copy-name');
const newName = nameInput?.value?.trim();
if (!newName) { showLog('Copy profile — error', 'New profile name required.'); return; }
// The option value is "<src-name>|<backend>".
const raw = srcSelect?.value || '';
const sepIdx = raw.lastIndexOf('|');
if (sepIdx < 0) { showLog('Copy profile — error', 'Select a source profile.'); return; }
const srcName = raw.slice(0, sepIdx);
const backend = raw.slice(sepIdx + 1);
// v0.0.49: read optional package list + merge mode.
const packagesText = panel.querySelector('#cp-copy-packages')?.value || '';
const mode = panel.querySelector('#cp-copy-mode')?.value || 'append';
const pkgSummary = packagesText.trim()
? ` (${mode} mode, ${packagesText.split(/\r?\n/).filter(l => l.trim() && !l.trim().startsWith('#')).length} packages)`
: '';
showLog('Copy profile',
`Copying ${backend} profile '${srcName}' → '${newName}'${pkgSummary} via cockpit superuser channel...`);
try {
const r = await bridge.builder.profileCopy(srcName, newName, backend,
packagesText.trim() || null, mode);
if (r.copied) {
const lines = [
`Copied ${r.backend} profile '${r.source}' → '${r.name}'.`,
'',
`Source: ${r.source_path}`,
`Destination: ${r.path}`,
];
if (r.packages) {
lines.push(`Packages: ${r.packages.count} (${r.packages.mode} mode)`);
lines.push(`Package file: ${r.packages.path}`);
} else if (r.packages_error) {
lines.push(`Packages: ERROR — ${r.packages_error}`);
}
lines.push('', 'Edit the files in the destination directory before building.');
showLog('Copy profile — success', lines.join('\n'));
setTimeout(() => mount(panel, { bridge, EventBus }), 1200);
} else {
showLog('Copy profile — failed', `Error: ${r.error || 'unknown'}\n\n${r.hint || ''}`);
}
} catch (err) {
showLog('Copy profile — error', String(err.message || err));
}
});
// v0.0.49: file-upload handlers for both forms. When the operator
// picks a file, read it as text and populate the corresponding
// textarea. The textarea is the source of truth — the operator
// can review/edit the uploaded content before submitting.
const wireFileInput = (fileInputId, textareaId, logLabel) => {
const fileInput = panel.querySelector(fileInputId);
const textarea = panel.querySelector(textareaId);
if (!fileInput || !textarea) return;
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
// 1 MB cap — anything larger is probably not a package list.
if (file.size > 1_000_000) {
showLog(logLabel, `File ${file.name} is ${formatSize(file.size)} — too large (1 MB cap).`);
fileInput.value = '';
return;
}
const reader = new FileReader();
reader.onload = (ev) => {
textarea.value = ev.target.result;
showLog(logLabel, `Loaded ${file.name} (${formatSize(file.size)}) into the textarea — review and edit before submitting.`);
};
reader.onerror = () => {
showLog(logLabel, `Failed to read ${file.name}: ${reader.error || 'unknown error'}`);
};
reader.readAsText(file);
});
};
wireFileInput('#cp-create-packages-file', '#cp-create-packages', 'Create profile — file upload');
wireFileInput('#cp-copy-packages-file', '#cp-copy-packages', 'Copy profile — file upload');
// Per-build log buttons.
panel.querySelectorAll('.btn-builder-log').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.buildId;
showLog(`Build log — ${id}`, 'Loading log ...');
try {
const r = await bridge.builder.buildLog(id);
if (r.log) showLog(`Build log — ${id}`, r.log);
else showLog(`Build log — ${id}`, `Error: ${r.error || 'no log'}`);
} catch (err) {
showLog(`Build log — ${id}`, String(err.message || err));
}
});
});
// Refresh button.
panel.querySelector('#btn-builder-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
// v0.1.3: artifact download buttons. Uses cockpit.spawn(["cat", path])
// to read the file as binary, then creates a Blob + download link.
// The file is read via the superuser channel so it works even when
// the artifacts dir is root-owned.
panel.querySelectorAll('.btn-artifact-download').forEach((btn) => {
btn.addEventListener('click', async () => {
const profile = btn.dataset.profile;
const name = btn.dataset.name;
const path = btn.dataset.path;
btn.disabled = true;
btn.textContent = '⏳ ...';
try {
// Read the file via cockpit.spawn cat. We use binary mode
// by reading as a binary stream. cockpit.spawn returns a
// channel that we collect into a byte array.
const channel = cockpit.spawn(["cat", path], {
superuser: "try",
binary: true,
});
const chunks = [];
channel.ondata = (data) => { chunks.push(data); };
await new Promise((resolve, reject) => {
channel.onclose = (resp) => {
if (resp.exit_status === 0 || resp.exit_status === null) resolve();
else reject(new Error(`cat exited ${resp.exit_status}`));
};
});
const blob = new Blob(chunks, { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showLog(`Download — ${name}`, `Downloaded ${name} (${formatSize(blob.size)}).\nPath: ${path}`);
} catch (err) {
showLog(`Download — ${name} — error`, String(err.message || err));
} finally {
btn.disabled = false;
btn.textContent = '⬇ Download';
}
});
});
// v0.1.3: artifact delete buttons (per-file).
panel.querySelectorAll('.btn-artifact-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
const profile = btn.dataset.profile;
const name = btn.dataset.name;
if (!window.confirm(`Delete artifact '${name}' from profile '${profile}'?`)) return;
btn.disabled = true;
try {
const r = await bridge.builder.artifactDelete(profile, name);
if (r.deleted) {
showLog(`Delete artifact — ${name}`, `Deleted ${name} (freed ${formatSize(r.size || 0)}).`);
setTimeout(() => mount(panel, { bridge, EventBus }), 500);
} else {
showLog(`Delete artifact — ${name} — error`, r.error || 'unknown error');
}
} catch (err) {
showLog(`Delete artifact — ${name} — error`, String(err.message || err));
} finally {
btn.disabled = false;
}
});
});
// v0.1.3: clear all artifacts for a profile.
panel.querySelectorAll('.btn-artifacts-clear').forEach((btn) => {
btn.addEventListener('click', async () => {
const profile = btn.dataset.profile;
if (!window.confirm(`Delete ALL artifacts for profile '${profile}'?\nThis cannot be undone.`)) return;
btn.disabled = true;
try {
const r = await bridge.builder.artifactsClear(profile);
if (r.cleared) {
showLog(`Clear artifacts — ${profile}`,
`Cleared ${r.files_deleted} files (${formatSize(r.bytes_freed || 0)} freed).`);
setTimeout(() => mount(panel, { bridge, EventBus }), 500);
} else {
showLog(`Clear artifacts — ${profile} — error`, r.error || 'unknown error');
}
} catch (err) {
showLog(`Clear artifacts — ${profile} — error`, String(err.message || err));
} finally {
btn.disabled = false;
}
});
});
// v0.1.3: build delete buttons (removes state + log, optionally artifacts).
panel.querySelectorAll('.btn-builder-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
const buildId = btn.dataset.buildId;
const profile = btn.dataset.profile;
const deleteArtifacts = window.confirm(
`Delete build record '${buildId}'?\n\n` +
`Click OK to delete state + log files only.\n` +
`Click Cancel to also delete the artifacts for profile '${profile}'.`
);
// If user clicked Cancel on the first confirm, ask again with
// the "also delete artifacts" option.
let withArtifacts = false;
if (!deleteArtifacts) {
withArtifacts = window.confirm(
`Also delete ALL artifacts for profile '${profile}'?\n\n` +
`Click OK to delete state + log + artifacts.\n` +
`Click Cancel to abort.`
);
if (!withArtifacts) return;
}
btn.disabled = true;
try {
const r = await bridge.builder.buildDelete(buildId, withArtifacts);
if (r.deleted) {
const lines = [`Deleted build ${buildId}.`, 'Files removed:'];
(r.files || []).forEach(f => lines.push(` ${f}`));
if (r.errors && r.errors.length) {
lines.push('', 'Errors:');
r.errors.forEach(e => lines.push(` ${e}`));
}
showLog(`Delete build — ${buildId}`, lines.join('\n'));
setTimeout(() => mount(panel, { bridge, EventBus }), 500);
} else {
showLog(`Delete build — ${buildId} — error`, r.error || 'unknown error');
}
} catch (err) {
showLog(`Delete build — ${buildId} — error`, String(err.message || err));
} finally {
btn.disabled = false;
}
});
});
}
// ── Utilities ───────────────────────────────────────────────────────
function formatSize(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let sz = bytes;
while (sz >= 1024 && i < units.length - 1) { sz /= 1024; i++; }
return `${sz.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton"><div class="suite-skeleton-line w-1/3"></div></div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Image Builder</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./builder.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,28 @@
{
"version": 0,
"name": "sysdeck-builder",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Image Builder",
"order": 30,
"keywords": [
{
"matches": [
"builder",
"mkosi",
"vmdb2",
"archiso",
"live-build",
"image",
"compose",
"blueprint"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,146 @@
/*
* SysDeck - Containers Panel (v0.0.35)
* Author: Jeremy Anderson (https://dcos.net)
*
* Podman container management panel. The Kata portion of the
* former merged "Containers & VMs" module is now its own sidebar
* entry — `SysDeck Kata` (plugins/sysdeck-kata/) — per the v0.0.35
* user directive: "kata containers should be called SysDeck Kata and
* moved out of the tools area."
*
* v0.0.34 was a merged two-tab panel (Podman + Kata iframe). The
* v0.0.35 split restores the one-module-one-concern shape: this
* panel manages Podman containers only; the SysDeck Kata plugin
* hosts the pre-built cockpit-kata React app for Kata sandboxes & VMs.
*
* Bridge surface (see shared/bridge.js → bridge.containers):
* list() → podman ps --format json (normalized)
* inspect(id) → podman inspect <id>
* action(id, action) → podman stop|restart|rm <id>
* count() → derived from list()
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
panel.innerHTML = renderShell();
await renderPodmanTable(panel, { bridge, EventBus });
EventBus.emit('containers.loaded', {});
}
// ── Shell ──────────────────────────────────────────────────────────
function renderShell() {
return `
<header>
<h2 class="suite-panel-title">Containers</h2>
<p class="suite-panel-subtitle">Podman runtime — list, inspect, stop / restart / remove</p>
</header>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title" id="containers-summary">Loading containers…</h3>
<button class="suite-btn suite-btn-ghost" id="btn-containers-refresh">↻ Refresh</button>
</div>
<div id="containers-table-host"></div>
</div>
<div class="suite-card">
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
Kata Containers (hardware-virtualized OCI sandboxes) is now a
standalone sidebar entry — <strong>SysDeck Kata</strong>. Open it
to manage Kata sandboxes & VMs from the pre-built React app.
</p>
</div>
</div>
`;
}
async function renderPodmanTable(panel, { bridge, EventBus }) {
const host = panel.querySelector('#containers-table-host');
const summary = panel.querySelector('#containers-summary');
if (!host || !summary) return;
host.innerHTML = `<div class="suite-skeleton"><div class="suite-skeleton-line w-1/3"></div></div>`;
let containers = [];
try {
containers = await bridge.containers.list();
} catch (err) {
summary.textContent = 'Podman unavailable';
host.innerHTML = renderPodmanError(err);
return;
}
summary.textContent = `Podman runtime — ${containers.length} container${containers.length === 1 ? '' : 's'}`;
host.innerHTML = `
<table class="suite-table">
<thead>
<tr><th>ID</th><th>Name</th><th>Image</th><th>Status</th><th>Ports</th><th>Actions</th></tr>
</thead>
<tbody>
${containers.map(renderPodmanRow).join('') || '<tr><td colspan="6" class="suite-muted">No containers. Run `podman run -d --name hello alpine sleep 9999` to create one.</td></tr>'}
</tbody>
</table>
`;
host.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
const action = btn.dataset.action;
btn.disabled = true;
try {
await bridge.containers.action(id, action);
EventBus.emit('container.action', { id, action });
} catch (err) {
EventBus.emit('container.error', { id, error: err.message });
}
renderPodmanTable(panel, { bridge, EventBus });
});
});
}
function renderPodmanRow(c) {
const statusClass = (c.status || '').startsWith('Up') ? 'success' : 'warn';
const id = (c.id || '').substring(0, 12);
const name = c.name || '—';
const image = c.image || '—';
const status = c.status || '—';
const ports = c.ports || '—';
return `
<tr>
<td class="suite-table-mono">${escapeHtml(id)}</td>
<td>${escapeHtml(name)}</td>
<td class="suite-table-mono">${escapeHtml(image)}</td>
<td><span class="suite-badge ${statusClass}">${escapeHtml(status)}</span></td>
<td class="suite-table-mono suite-muted">${escapeHtml(ports)}</td>
<td>
<button class="suite-btn suite-btn-ghost" data-id="${escapeHtml(c.id)}" data-action="stop">stop</button>
<button class="suite-btn suite-btn-ghost" data-id="${escapeHtml(c.id)}" data-action="restart">restart</button>
<button class="suite-btn suite-btn-ghost" data-id="${escapeHtml(c.id)}" data-action="rm">rm</button>
</td>
</tr>
`;
}
function renderPodmanError(err) {
return `<div class="suite-card">
<h3 class="suite-card-title">Podman unavailable</h3>
<p class="suite-card-body suite-muted">${escapeHtml(err.message || String(err))}. Install podman to manage containers from this panel.</p>
<p class="suite-muted">Arch: <code>pacman -S podman</code> · Debian: <code>apt install podman</code> · Fedora: <code>dnf install podman</code></p>
</div>`;
}
// ── Utilities ───────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Containers</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./containers.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,30 @@
{
"version": 0,
"name": "sysdeck-containers",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Containers & VMs",
"order": 20,
"keywords": [
{
"matches": [
"containers",
"podman",
"docker",
"oci",
"images",
"pods",
"kata",
"sandbox",
"vm",
"isolation"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self'"
}

324
plugins/sysdeck-db/db.js Executable file
View File

@ -0,0 +1,324 @@
/*
* SysDeck - Databases Panel (v0.0.33)
* Author: Jeremy Anderson (https://dcos.net)
*
* DB Control module — unified control for SQL / NoSQL / vector /
* time-series / graph / embedded / cloud / AI database engines.
* The bridge helper bridge/db.py surfaces 32+ engines with
* summary / status / start / stop / restart / connections / query
* subcommands.
*
* The cockpit-way pattern (v0.0.31+): the bridge runs systemctl
* directly via subprocess; the JS panel passes { superuser: 'try' }
* to cockpit.spawn so the cockpit bridge prompts the operator via
* polkit for the org.sysdeck.db.modify action.
*
* The panel surfaces:
* - Summary card: total engines, running count, total data size,
* total memory, total connections.
* - Engines table: per-engine id, name, family, status, version,
* port, size, memory, connections, with Start/Stop/Restart
* buttons per row.
* - Query runner: SQL-family engines only — input a SQL string
* and execute against the selected engine.
* - Connections viewer: list active TCP connections to an engine.
*
* Bridge surface (see shared/bridge.js → bridge.db):
* summary() → {engines, totalEngines, runningCount, ...}
* status(id) → single-engine detail dict
* start(id) → {action, engine, rc, success, output, stderr}
* stop(id) → same shape
* restart(id) → same shape
* connections(id) → {connections, count}
* query(id, sql) → {output, engine, query}
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
let summary = {};
try {
summary = await bridge.db.summary();
} catch (err) {
panel.innerHTML = renderError(err);
return;
}
const engines = summary.engines || [];
const running = engines.filter((e) => e.status === 'running');
const totalSize = summary.totalSizeMB || 0;
const totalMem = summary.totalMemoryMB || 0;
const totalConn = summary.totalConnections || 0;
// Group engines by family for cleaner rendering.
const byFamily = new Map();
for (const e of engines) {
if (!byFamily.has(e.family)) byFamily.set(e.family, []);
byFamily.get(e.family).push(e);
}
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Database Control</h2>
<p class="suite-panel-subtitle">
${summary.totalEngines || engines.length} engines across ${byFamily.size} families
· ${running.length} running
· ${(totalSize / 1024).toFixed(1)} GB data
· ${(totalMem / 1024).toFixed(1)} GB memory
· ${totalConn} connections
</p>
</header>
<div class="suite-row">
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">Engines</h3>
<div class="suite-stat-value">${summary.totalEngines || engines.length}</div>
<div class="suite-stat-label">total (${byFamily.size} families)</div>
</div>
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">Running</h3>
<div class="suite-stat-value ${running.length ? 'suite-warn' : ''}">${running.length}</div>
<div class="suite-stat-label">${running.length ? 'active services' : 'all stopped'}</div>
</div>
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">Data size</h3>
<div class="suite-stat-value">${(totalSize / 1024).toFixed(1)}</div>
<div class="suite-stat-label">GB across all engines</div>
</div>
</div>
${renderEnginesTable(engines, byFamily)}
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Run SQL Query</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
SQL-family engines only (postgresql, mysql/mariadb, sqlite, cockroachdb, clickhouse, duckdb).
The query is run via the engine's CLI client (<code>psql -tAc</code>, <code>mysql -e</code>, etc.).
</p>
<div class="suite-row" style="gap:0.5rem;margin-bottom:0.5rem">
<select class="suite-input" id="db-query-engine" style="width:200px">
${engines.filter((e) => e.family === 'sql' || ['clickhouse', 'duckdb', 'timescaledb'].includes(e.id)).map((e) => `<option value="${escapeHtml(e.id)}">${escapeHtml(e.name)}</option>`).join('') || '<option value="">(no SQL engines detected)</option>'}
</select>
<button class="suite-btn suite-btn-primary" id="btn-db-query">Execute</button>
<button class="suite-btn suite-btn-ghost" id="btn-db-connections">List Connections</button>
</div>
<textarea class="suite-input" id="db-query-sql" rows="3" placeholder="SELECT * FROM users LIMIT 10;" style="width:100%;font-family:monospace"></textarea>
</div>
</div>
<div class="suite-card" id="db-output-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title" id="db-output-title">Output</h3>
<button class="suite-btn suite-btn-ghost" id="btn-db-output-close">✕</button>
</div>
<pre class="suite-mono" id="db-output-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:400px"></pre>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Engine Detail</h3>
<button class="suite-btn suite-btn-ghost" id="btn-db-refresh">↻ Refresh</button>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
Click ▶/■/↻ on a row above to start / stop / restart an engine.
Click 🔍 to view the engine's full status (version, port, data dir,
config path, log path, connections, memory).
</p>
</div>
</div>
`;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('db.loaded', { engineCount: engines.length, running: running.length });
}
// ── Render helpers ───────────────────────────────────────────────────
function renderEnginesTable(engines, byFamily) {
if (!engines.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Engines (0)</h3>
<div class="suite-card-body">
<p class="suite-muted">No database engines detected on this host.</p>
</div>
</div>
`;
}
// Render a table per family for clearer grouping.
const cards = [];
for (const [family, items] of byFamily) {
const running = items.filter((e) => e.status === 'running').length;
cards.push(`
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">${escapeHtml(family)} (${items.length})</h3>
<span class="suite-muted" style="font-size:0.85rem">${running} running</span>
</div>
<table class="suite-table">
<thead><tr><th>Engine</th><th>Status</th><th>Version</th><th>Port</th><th>Size</th><th>Mem</th><th>Conns</th><th>Actions</th></tr></thead>
<tbody>
${items.map((e) => `
<tr>
<td class="suite-table-mono"><strong>${escapeHtml(e.name)}</strong><div class="suite-muted" style="font-size:0.75rem">${escapeHtml(e.id)}</div></td>
<td>${statusBadge(e.status)}</td>
<td class="suite-muted suite-mono" style="font-size:0.8rem">${escapeHtml((e.version || '').slice(0, 30))}</td>
<td class="suite-table-mono">${e.port || '—'}</td>
<td class="suite-muted">${formatSize(e.sizeMB)}</td>
<td class="suite-muted">${formatSize(e.memoryMB)}</td>
<td class="suite-table-mono">${e.connections || 0}</td>
<td>
<div class="suite-row" style="gap:0.25rem">
<button class="suite-btn suite-btn-ghost btn-db-start" data-id="${escapeHtml(e.id)}" ${e.status !== 'stopped' ? 'disabled' : ''}>▶</button>
<button class="suite-btn suite-btn-ghost btn-db-stop" data-id="${escapeHtml(e.id)}" ${e.status !== 'running' ? 'disabled' : ''}>■</button>
<button class="suite-btn suite-btn-ghost btn-db-restart" data-id="${escapeHtml(e.id)}" ${e.status !== 'running' ? 'disabled' : ''}>↻</button>
<button class="suite-btn suite-btn-ghost btn-db-status" data-id="${escapeHtml(e.id)}">🔍</button>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`);
}
return cards.join('');
}
function statusBadge(status) {
const map = {
'running': '<span class="suite-badge success">running</span>',
'starting': '<span class="suite-badge warn">starting</span>',
'stopped': '<span class="suite-badge">stopped</span>',
'error': '<span class="suite-badge danger">error</span>',
'uninstalled': '<span class="suite-badge">uninstalled</span>',
};
return map[status] || `<span class="suite-badge">${escapeHtml(status)}</span>`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
const outputCard = panel.querySelector('#db-output-card');
const outputPre = panel.querySelector('#db-output-pre');
const outputTitle = panel.querySelector('#db-output-title');
const showOutput = (title, text, isError = false) => {
if (!outputCard || !outputPre) return;
outputCard.style.display = 'block';
outputTitle.textContent = title;
outputPre.textContent = text;
outputPre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)';
};
panel.querySelector('#btn-db-output-close')?.addEventListener('click', () => {
if (outputCard) outputCard.style.display = 'none';
});
const startStopRestart = async (btn, method, label) => {
const id = btn.dataset.id;
if (!id) return;
btn.disabled = true;
showOutput(`${label} ${id}`, `${label} engine ${id} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.db[method](id);
const ok = r.success ?? (r.rc === 0);
showOutput(`${label} ${id} — ${ok ? 'success' : 'failed'}`,
`rc=${r.rc}\nsuccess=${ok}\noutput: ${r.output || '(empty)'}\nstderr: ${r.stderr || '(empty)'}`,
!ok);
if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 1200);
} catch (err) {
showOutput(`${label} ${id} — error`, String(err.message || err), true);
} finally {
setTimeout(() => { btn.disabled = false; }, 1200);
}
};
panel.querySelectorAll('.btn-db-start').forEach((btn) => {
btn.addEventListener('click', () => startStopRestart(btn, 'start', 'Start'));
});
panel.querySelectorAll('.btn-db-stop').forEach((btn) => {
btn.addEventListener('click', () => startStopRestart(btn, 'stop', 'Stop'));
});
panel.querySelectorAll('.btn-db-restart').forEach((btn) => {
btn.addEventListener('click', () => startStopRestart(btn, 'restart', 'Restart'));
});
panel.querySelectorAll('.btn-db-status').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.id;
showOutput(`Status: ${id}`, 'Loading ...');
try {
const r = await bridge.db.status(id);
if (r.error) { showOutput(`Status: ${id} — error`, r.error, true); return; }
showOutput(`Status: ${id}`, JSON.stringify(r, null, 2));
} catch (err) { showOutput(`Status: ${id} — error`, String(err.message || err), true); }
});
});
panel.querySelector('#btn-db-query')?.addEventListener('click', async () => {
const engineId = panel.querySelector('#db-query-engine')?.value;
const sql = panel.querySelector('#db-query-sql')?.value?.trim();
if (!engineId) { showOutput('Query', 'Select an engine first.', true); return; }
if (!sql) { showOutput('Query', 'Enter a SQL query first.', true); return; }
showOutput(`Query: ${engineId}`, `Running query on ${engineId} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.db.query(engineId, sql);
if (r.error) {
showOutput(`Query: ${engineId} — error`, r.error, true);
} else {
showOutput(`Query: ${engineId}`, `query: ${r.query || sql}\n\noutput:\n${r.output || '(empty)'}`);
}
} catch (err) { showOutput(`Query: ${engineId} — error`, String(err.message || err), true); }
});
panel.querySelector('#btn-db-connections')?.addEventListener('click', async () => {
const engineId = panel.querySelector('#db-query-engine')?.value;
if (!engineId) { showOutput('Connections', 'Select an engine first.', true); return; }
showOutput(`Connections: ${engineId}`, 'Loading ...');
try {
const r = await bridge.db.connections(engineId);
if (r.error) { showOutput(`Connections: ${engineId} — error`, r.error, true); return; }
const lines = [`count: ${r.count}`];
for (const c of (r.connections || [])) lines.push(c);
showOutput(`Connections: ${engineId}`, lines.join('\n'));
} catch (err) { showOutput(`Connections: ${engineId} — error`, String(err.message || err), true); }
});
panel.querySelector('#btn-db-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
}
// ── Utilities ───────────────────────────────────────────────────────
function formatSize(mb) {
if (!mb) return '0 MB';
if (mb < 1024) return `${mb} MB`;
return `${(mb / 1024).toFixed(1)} GB`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}
function renderError(err) {
return `<div class="suite-card">
<h3 class="suite-card-title">Database bridge unavailable</h3>
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.</p>
</div>`;
}

64
plugins/sysdeck-db/index.html Executable file
View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Databases</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./db.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,37 @@
{
"version": 0,
"name": "sysdeck-db",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Databases",
"order": 39,
"keywords": [
{
"matches": [
"database",
"db",
"sql",
"postgresql",
"mysql",
"mariadb",
"sqlite",
"mongodb",
"redis",
"valkey",
"influxdb",
"neo4j",
"clickhouse",
"milvus",
"qdrant",
"weaviate",
"duckdb"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

692
plugins/sysdeck-fester/fester.js Executable file
View File

@ -0,0 +1,692 @@
/*
* SysDeck - Fester Panel (v0.2.0)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.2.0 REAL INTEGRATION — replaces the v0.1.3 build-jobs stub (which
* listed systemd units whose name contained "fester"/"build" and never
* talked to an orchestrator). This panel is now a live client of the
* vendored fester service — the distributed DAG build orchestrator
* from web/mini-services/fester, REST + WebSocket on 127.0.0.1:3010 —
* through bridge/fester.py:
*
* status / metrics / builds / nodes / targets / sessions read polls
* startBuild → POST /api/build (build_id)
* cancel → POST /api/builds/<id>/cancel
* replay → POST /api/sessions (session)
* timeline → GET /api/timeline/<id>
*
* Layout: service status card + stat grid, cluster nodes table,
* builds table (live first, then history) with per-row Cancel /
* Replay / Timeline actions, and a Start-a-Build form fed by the
* project/target catalog. Auto-refreshes every 5s; the refresh loop
* re-renders only the status/nodes/builds containers — the form is
* rendered once, so its state survives every tick. If the service is
* down the panel shows the bridge's error message verbatim (it carries
* the remediation hint) and keeps retrying, flipping back online on
* its own.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const ctx = {
panel,
bridge,
EventBus,
laidOut: false, // full layout rendered (vs offline card)
expanded: new Set(), // build ids whose timeline row is open
firstRenderDone: false,
targetsResp: null, // project catalog for the start form
};
// The status probe decides online vs offline — its error message
// carries the remediation hint, shown verbatim when offline.
let status = null;
let statusErr = null;
try {
status = await bridge.fester.status();
} catch (err) {
statusErr = err;
}
if (statusErr || !status || status.ok === false) {
renderOffline(ctx, errMessage(statusErr, status));
EventBus.emit('fester.offline', {});
} else {
await mountLayout(ctx, status);
}
// ── auto-refresh (5s) ─────────────────────────────────────────
// Re-fetches status + metrics + builds + nodes and re-renders only
// the status/nodes/builds containers. Also watches for the service
// going away (→ offline card) or coming back (→ full layout).
if (panel._festerInterval) clearInterval(panel._festerInterval);
panel._festerInterval = setInterval(() => { poll(ctx); }, 5000);
// Clean up the interval when the panel leaves the DOM
// (house pattern from netsec.js).
const observer = new MutationObserver(() => {
if (!document.body.contains(panel)) {
clearInterval(panel._festerInterval);
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
// ── refresh loop ────────────────────────────────────────────────────
async function poll(ctx) {
let status = null;
let statusErr = null;
try {
status = await ctx.bridge.fester.status();
} catch (err) {
statusErr = err;
}
const online = !statusErr && status && status.ok !== false;
if (online && !ctx.laidOut) {
// Service came back after an offline render — build the layout.
await mountLayout(ctx, status);
return;
}
if (!online && ctx.laidOut) {
// Service dropped — swap to the offline card.
renderOffline(ctx, errMessage(statusErr, status));
ctx.EventBus.emit('fester.offline', {});
return;
}
if (!online) return; // still offline — the card is already shown
await refreshData(ctx, status);
}
async function refreshNow(ctx) {
// Immediate re-fetch after a user action (start/cancel/manual).
let status = null;
let statusErr = null;
try {
status = await ctx.bridge.fester.status();
} catch (err) {
statusErr = err;
}
if (!statusErr && status && status.ok !== false && ctx.laidOut) {
await refreshData(ctx, status);
}
}
async function refreshData(ctx, status) {
if (!ctx.laidOut) return;
const { bridge, panel } = ctx;
// Per-call try/catch: one failing endpoint must not sink the panel.
const metricsResp = await safe(bridge.fester.metrics());
const buildsResp = await safe(bridge.fester.builds());
const nodesResp = await safe(bridge.fester.nodes());
const statusEl = panel.querySelector('#fester-status');
const nodesEl = panel.querySelector('#fester-nodes');
const buildsEl = panel.querySelector('#fester-builds');
if (statusEl) statusEl.innerHTML = renderStatus(status, metricsResp, nodesResp);
if (nodesEl) nodesEl.innerHTML = renderNodes(nodesResp);
if (buildsEl) buildsEl.innerHTML = renderBuilds(buildsResp);
wireStatusAndRows(ctx);
// Re-open timeline rows that were expanded before the re-render
// (the events re-fetch keeps them live).
await reopenExpanded(ctx);
if (!ctx.firstRenderDone) {
ctx.firstRenderDone = true;
ctx.EventBus.emit('fester.loaded', { builds: countBuilds(buildsResp) });
}
}
// ── layout ──────────────────────────────────────────────────────────
async function mountLayout(ctx, status) {
const { panel, bridge } = ctx;
ctx.laidOut = true;
// Target catalog feeds the Start-a-Build form. Fetched once per
// layout — the form is static for the panel's lifetime, which is
// what preserves its state across refresh ticks.
let targetsResp = null;
let targetsErr = null;
try {
targetsResp = await bridge.fester.targets();
} catch (err) {
targetsErr = err;
}
ctx.targetsResp = targetsResp;
const baseUrl = (status && status.base_url) || 'http://127.0.0.1:3010';
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">SysDeck Fester</h2>
<p class="suite-panel-subtitle">Distributed DAG build orchestration — vendored fester service (${escapeHtml(baseUrl)} · REST+WS)</p>
</header>
<div id="fester-status"></div>
<div id="fester-nodes"></div>
<div id="fester-builds"></div>
<div id="fester-form">
${renderStartForm(targetsResp, targetsErr)}
</div>
`;
wireStartForm(ctx);
await refreshData(ctx, status);
}
function renderOffline(ctx, message) {
ctx.laidOut = false;
ctx.panel.innerHTML = `
<header>
<h2 class="suite-panel-title">SysDeck Fester</h2>
<p class="suite-panel-subtitle">Distributed DAG build orchestration — vendored fester service (web/mini-services/fester · REST+WS)</p>
</header>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Fester Service Offline</h3>
<span class="suite-badge danger">offline</span>
</div>
<p class="suite-card-body suite-mono">${escapeHtml(message)}</p>
<p class="suite-muted">Retrying every 5 seconds — the panel reconnects automatically when the service is back.</p>
</div>
`;
}
// ── status card + stat grid ─────────────────────────────────────────
function renderStatus(status, metricsResp, nodesResp) {
const m = (metricsResp && metricsResp.ok !== false) ? metricsResp : null;
const metrics = (m && m.metrics) || {};
const builds = metrics.builds || {};
const actions = metrics.actions || {};
const nodesOk = nodesResp && nodesResp.ok !== false;
const nodeCount = nodesOk && Array.isArray(nodesResp.nodes)
? nodesResp.nodes.length
: (status && typeof status.nodes === 'number' ? status.nodes : 0);
const stat = (v) => (m ? n(v) : '—');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Service</h3>
<div class="suite-row" style="gap:0.5rem">
<span class="suite-badge success">online</span>
<button class="suite-btn suite-btn-ghost" id="btn-fester-refresh">↻ Refresh</button>
</div>
</div>
<div class="suite-card-body">
<div class="suite-row-between">
<span class="suite-muted">fester v${escapeHtml(status && status.version)}</span>
<span class="suite-muted suite-mono">${escapeHtml(status && status.base_url)} · ${escapeHtml(status && status.transport)} · clock ${escapeHtml(status && status.clock)}</span>
</div>
<div class="suite-row-between">
<span class="suite-muted">${nodeCount} cluster nodes</span>
<span class="suite-muted">up ${escapeHtml(fmtDuration(status && status.uptime_s))}</span>
</div>
${m ? '' : `<p class="suite-muted">metrics unavailable${metricsResp && metricsResp.error ? ': ' + escapeHtml(metricsResp.error) : ''}</p>`}
</div>
</div>
<div class="suite-grid cols-3">
<div class="suite-card">
<div class="suite-stat-value">${stat(builds.total)}</div>
<div class="suite-stat-label">builds total</div>
</div>
<div class="suite-card">
<div class="suite-stat-value ${n(builds.running) > 0 ? 'suite-warn' : ''}">${stat(builds.running)}</div>
<div class="suite-stat-label">running</div>
</div>
<div class="suite-card">
<div class="suite-stat-value">${stat(builds.succeeded)}</div>
<div class="suite-stat-label">succeeded</div>
</div>
<div class="suite-card">
<div class="suite-stat-value">${stat(builds.failed)}</div>
<div class="suite-stat-label">failed</div>
</div>
<div class="suite-card">
<div class="suite-stat-value">${m ? fmtNum(actions.cache_hit_rate, 1) + '%' : '—'}</div>
<div class="suite-stat-label">cache-hit rate (${stat(actions.cache_hits)}/${stat(actions.total)} actions)</div>
</div>
<div class="suite-card">
<div class="suite-stat-value">${nodesOk ? nodeCount : '—'}</div>
<div class="suite-stat-label">cluster nodes</div>
</div>
</div>
`;
}
// ── cluster nodes table ─────────────────────────────────────────────
function renderNodes(nodesResp) {
const nodes = (nodesResp && Array.isArray(nodesResp.nodes)) ? nodesResp.nodes : null;
if (!nodes) {
const msg = (nodesResp && nodesResp.error)
? nodesResp.error
: 'No cluster nodes registered.';
return `
<div class="suite-card">
<h3 class="suite-card-title">Cluster Nodes</h3>
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
</div>`;
}
const rows = nodes.map((nd) => `<tr>
<td class="suite-table-mono">${escapeHtml(nd.name)}</td>
<td>${nodeStateBadge(nd.state)}</td>
<td class="suite-mono">${fmtNum(nd.cpu_load, 1)}%</td>
<td class="suite-mono">${fmtNum(nd.temp, 1)}°C</td>
<td class="suite-mono">${n(nd.active_jobs)}/${n(nd.max_jobs)}</td>
</tr>`).join('');
return `
<div class="suite-card">
<h3 class="suite-card-title">Cluster Nodes</h3>
<table class="suite-table">
<thead><tr><th>Node</th><th>State</th><th>CPU load</th><th>Temp</th><th>Jobs (active/max)</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function nodeStateBadge(state) {
const s = String(state || '');
const cls = s === 'online' ? 'suite-badge success'
: s === 'degraded' ? 'suite-badge warn'
: 'suite-badge';
return `<span class="${cls}">${escapeHtml(s || '—')}</span>`;
}
// ── builds table ────────────────────────────────────────────────────
function renderBuilds(buildsResp) {
if (!buildsResp || buildsResp.ok === false) {
const msg = (buildsResp && buildsResp.error)
? buildsResp.error
: 'build list unavailable';
return `
<div class="suite-card">
<h3 class="suite-card-title">Builds</h3>
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
</div>`;
}
const live = Array.isArray(buildsResp.builds) ? buildsResp.builds : [];
const history = Array.isArray(buildsResp.history) ? buildsResp.history : [];
const seen = new Set(live.map((b) => String(b.build_id || '')));
const rows = [
...live.map((b) => buildRow(b, true)),
...history
.filter((b) => !seen.has(String(b.build_id || '')))
.map((b) => buildRow(b, false)),
];
return `
<div class="suite-card">
<h3 class="suite-card-title">Builds</h3>
<table class="suite-table">
<thead><tr>
<th>Build</th><th>Project</th><th>State</th><th>Actions</th>
<th>Cache hits</th><th>Critical path</th><th>Started</th><th></th>
</tr></thead>
<tbody>
${rows.join('') || '<tr><td colspan="8" class="suite-muted">No builds yet — start one below.</td></tr>'}
</tbody>
</table>
<p class="suite-muted">Live builds first, then history (${live.length} live · ${history.length} stored).</p>
</div>`;
}
function buildRow(b, live) {
const id = String(b.build_id || '');
const state = String(b.state || '');
const running = state === 'running' || state === 'queued';
const done = live ? null : b.actions_done;
const total = live ? b.actions : b.actions_total;
const cacheHits = live ? null : b.cache_hits;
const criticalMs = live ? null : b.critical_path_ms;
const buttons = [
running ? `<button class="suite-btn" data-fx="cancel" data-build="${escapeHtml(id)}">Cancel</button>` : '',
!running ? `<button class="suite-btn" data-fx="replay" data-build="${escapeHtml(id)}">Replay</button>` : '',
`<button class="suite-btn suite-btn-ghost" data-fx="timeline" data-build="${escapeHtml(id)}">Timeline</button>`,
].filter(Boolean).join(' ');
return `<tr data-build="${escapeHtml(id)}">
<td class="suite-table-mono">${escapeHtml(id)}</td>
<td>${escapeHtml(b.project || '')}</td>
<td>${stateBadge(state)}</td>
<td class="suite-mono">${done == null ? '—' : n(done)}/${n(total)}</td>
<td class="suite-mono">${cacheHits == null ? '—' : n(cacheHits)}</td>
<td class="suite-mono">${criticalMs == null ? '—' : n(criticalMs) + ' ms'}</td>
<td class="suite-muted">${escapeHtml(fmtTime(b.started_at))}</td>
<td>
<div class="suite-row" style="gap:0.35rem;flex-wrap:nowrap">${buttons}</div>
<div class="suite-muted suite-mono" data-note="${escapeHtml(id)}" style="font-size:0.75rem;margin-top:0.25rem"></div>
</td>
</tr>`;
}
function stateBadge(state) {
const s = String(state || '');
const cls = (s === 'running' || s === 'queued') ? 'suite-badge warn'
: s === 'succeeded' ? 'suite-badge success'
: s === 'failed' ? 'suite-badge danger'
: 'suite-badge'; // cancelled / unknown → neutral gray
return `<span class="${cls}">${escapeHtml(s || '—')}</span>`;
}
// ── timeline expansion ──────────────────────────────────────────────
async function toggleTimeline(ctx, buildId) {
if (ctx.expanded.has(buildId)) {
ctx.expanded.delete(buildId);
const row = findTimelineRow(ctx, buildId);
if (row) row.remove();
return;
}
ctx.expanded.add(buildId);
await insertTimelineRow(ctx, buildId);
}
async function insertTimelineRow(ctx, buildId) {
const buildRowEl = findBuildRow(ctx, buildId);
if (!buildRowEl) return;
const tr = document.createElement('tr');
tr.setAttribute('data-timeline', buildId);
tr.innerHTML = `<td colspan="8" class="suite-muted">loading timeline…</td>`;
buildRowEl.after(tr);
try {
const resp = await ctx.bridge.fester.timeline(buildId);
tr.innerHTML = timelineTd(resp);
} catch (err) {
tr.innerHTML = `<td colspan="8" class="suite-muted">${escapeHtml(errMessage(err, null))}</td>`;
}
}
async function reopenExpanded(ctx) {
for (const id of ctx.expanded) {
if (!findBuildRow(ctx, id)) continue;
await insertTimelineRow(ctx, id);
}
}
function timelineTd(resp) {
const events = (resp && Array.isArray(resp.events)) ? resp.events : [];
if (!events.length) {
return `<td colspan="8" class="suite-muted">No timeline events.</td>`;
}
const lines = events.slice(-15).map((ev) =>
`<div class="suite-mono" style="font-size:0.8rem">${escapeHtml(fmtEvent(ev))}</div>`
).join('');
return `<td colspan="8" style="padding:0.5rem 0.75rem;background:rgba(255,255,255,0.03)">${lines}</td>`;
}
function fmtEvent(ev) {
let line = `#${n(ev.id)} ${fmtTimeShort(ev.ts)} ${String(ev.type || '?')}`;
if (ev.state != null) line += `/${String(ev.state)}`;
if (ev.action) line += ` — ${ev.action}`;
return line;
}
// ── start-a-build form ──────────────────────────────────────────────
function renderStartForm(targetsResp, targetsErr) {
const projects = (targetsResp && Array.isArray(targetsResp.projects)) ? targetsResp.projects : null;
if (targetsErr || !projects) {
const msg = targetsErr ? errMessage(targetsErr, null)
: (targetsResp && targetsResp.error) || 'target catalog unavailable';
return `
<div class="suite-card">
<h3 class="suite-card-title">Start a Build</h3>
<p class="suite-card-body suite-muted">${escapeHtml(msg)}</p>
</div>`;
}
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Start a Build</h3>
<span class="suite-muted">POST /api/build via bridge.fester.startBuild</span>
</div>
<div class="suite-card-body">
<div class="suite-row-between" style="margin-bottom:0.5rem">
<label class="suite-muted" for="fester-project">Project</label>
<select class="suite-input" id="fester-project">
${projects.map((p) =>
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${(p.targets || []).length} targets)</option>`
).join('')}
</select>
</div>
<div class="suite-muted" style="margin-bottom:0.25rem">Targets</div>
<div class="suite-row" id="fester-targets" style="margin-bottom:0.5rem"></div>
<div class="suite-row" style="align-items:center;margin-bottom:0.75rem">
<label class="suite-row" style="gap:0.35rem;align-items:center">
<input type="checkbox" id="fester-nocache" />
<span>no-cache</span>
</label>
<label class="suite-row" style="gap:0.35rem;align-items:center">
<span>retries</span>
<select class="suite-input" id="fester-retries">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
</select>
</label>
</div>
<button class="suite-btn suite-btn-primary" id="btn-fester-start">▶ Start Build</button>
<div class="suite-muted suite-mono" id="fester-start-result" style="margin-top:0.5rem"></div>
</div>
</div>`;
}
function wireStartForm(ctx) {
const { panel } = ctx;
const select = panel.querySelector('#fester-project');
const targetsBox = panel.querySelector('#fester-targets');
if (!select || !targetsBox) return;
const renderTargetOptions = () => {
const name = select.value;
const project = ((ctx.targetsResp && ctx.targetsResp.projects) || [])
.find((p) => p.name === name);
const list = (project && project.targets) || [];
targetsBox.innerHTML = list.map((t) => `
<label class="suite-row" style="gap:0.35rem;align-items:center;margin-right:0.75rem">
<input type="checkbox" class="fester-target-cb" value="${escapeHtml(t.name)}" />
<span>${escapeHtml(t.name)} <span class="suite-muted">${escapeHtml(t.system)}/${escapeHtml(t.arch)}</span></span>
</label>`).join('') || '<span class="suite-muted">No targets for this project.</span>';
};
renderTargetOptions();
select.addEventListener('change', renderTargetOptions);
panel.querySelector('#btn-fester-start')?.addEventListener('click', async () => {
const result = panel.querySelector('#fester-start-result');
const btn = panel.querySelector('#btn-fester-start');
const project = select.value;
const targets = Array.from(panel.querySelectorAll('.fester-target-cb:checked'))
.map((el) => el.value);
const noCache = !!(panel.querySelector('#fester-nocache')?.checked);
const retries = Number(panel.querySelector('#fester-retries')?.value || '0');
if (!project || targets.length === 0) {
if (result) result.textContent = 'Pick a project and at least one target.';
return;
}
if (btn) btn.disabled = true;
if (result) result.textContent = `starting ${project} (${targets.join(', ')})…`;
try {
const res = await ctx.bridge.fester.startBuild(project, targets, { noCache, retries });
if (result) {
result.textContent = (res && res.ok)
? `started build ${res.build_id} (retries ${res.retries != null ? res.retries : 0})`
: `start failed: ${(res && res.error) || 'unknown error'}`;
}
} catch (err) {
if (result) result.textContent = `start failed: ${errMessage(err, null)}`;
}
if (btn) btn.disabled = false;
// Immediate refresh so the new build shows up right away.
refreshNow(ctx);
});
}
// ── row actions (cancel / replay / timeline) ────────────────────────
function wireStatusAndRows(ctx) {
const { panel } = ctx;
panel.querySelector('#btn-fester-refresh')?.addEventListener('click', () => {
refreshNow(ctx);
});
panel.querySelectorAll('#fester-builds button[data-fx]').forEach((btn) => {
btn.addEventListener('click', () => {
const fx = btn.getAttribute('data-fx');
const id = btn.getAttribute('data-build');
if (!id) return;
if (fx === 'cancel') doCancel(ctx, id, btn);
else if (fx === 'replay') doReplay(ctx, id, btn);
else if (fx === 'timeline') toggleTimeline(ctx, id);
});
});
}
async function doCancel(ctx, buildId, btn) {
btn.disabled = true;
let res = null;
let err = null;
try {
res = await ctx.bridge.fester.cancel(buildId);
} catch (e) {
err = e;
}
// A 409-style {ok:false, error:"build not running"} is surfaced
// as-is — the bridge prints the service's JSON verbatim.
const msg = err ? errMessage(err, null)
: (res && res.ok) ? 'cancel requested'
: `cancel failed: ${(res && res.error) || 'unknown error'}`;
setRowNote(ctx, buildId, msg);
refreshNow(ctx);
}
async function doReplay(ctx, buildId, btn) {
btn.disabled = true;
let res = null;
let err = null;
try {
res = await ctx.bridge.fester.replay(buildId);
} catch (e) {
err = e;
}
let msg;
if (err) {
msg = `replay failed: ${errMessage(err, null)}`;
} else if (res && res.ok) {
const sid = res.session && res.session.session_id;
msg = sid ? `session ${sid}` : 'session created';
} else {
msg = `replay failed: ${(res && res.error) || 'unknown error'}`;
}
setRowNote(ctx, buildId, msg);
btn.disabled = false;
}
function setRowNote(ctx, buildId, msg) {
ctx.panel.querySelectorAll('[data-note]').forEach((el) => {
if (el.getAttribute('data-note') === buildId) el.textContent = msg;
});
}
function findBuildRow(ctx, buildId) {
let found = null;
ctx.panel.querySelectorAll('#fester-builds tr[data-build]').forEach((el) => {
if (el.getAttribute('data-build') === buildId) found = el;
});
return found;
}
function findTimelineRow(ctx, buildId) {
let found = null;
ctx.panel.querySelectorAll('#fester-builds tr[data-timeline]').forEach((el) => {
if (el.getAttribute('data-timeline') === buildId) found = el;
});
return found;
}
// ── utilities ───────────────────────────────────────────────────────
async function safe(p) {
try {
const v = await p;
return v ?? null;
} catch {
return null;
}
}
function errMessage(err, resp) {
if (resp && resp.error) return String(resp.error);
if (err && err.message) return String(err.message);
if (err) return String(err);
return 'fester service unreachable';
}
function countBuilds(buildsResp) {
if (!buildsResp || buildsResp.ok === false) return 0;
const live = Array.isArray(buildsResp.builds) ? buildsResp.builds.length : 0;
const hist = Array.isArray(buildsResp.history) ? buildsResp.history.length : 0;
return live + hist;
}
function n(v) {
const num = Number(v);
return Number.isFinite(num) ? num : 0;
}
function fmtNum(v, digits) {
const num = Number(v);
return Number.isFinite(num) ? num.toFixed(digits) : '—';
}
function fmtTime(ts) {
const num = Number(ts);
if (!Number.isFinite(num) || num <= 0) return '—';
return new Date(num * 1000).toLocaleString();
}
function fmtTimeShort(ts) {
const num = Number(ts);
if (!Number.isFinite(num) || num <= 0) return '--:--:--';
return new Date(num * 1000).toLocaleTimeString();
}
function fmtDuration(s) {
const num = Number(s);
if (!Number.isFinite(num) || num < 0) return '—';
const h = Math.floor(num / 3600);
const m = Math.floor((num % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${Math.floor(num % 60)}s`;
return `${Math.floor(num)}s`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Fester</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./fester.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"version": 0,
"name": "sysdeck-fester",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Fester",
"order": 28,
"keywords": [
{
"matches": [
"fester",
"build",
"orchestration",
"dag",
"compose"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,811 @@
/*
* SysDeck - Firewall Panel (v0.0.45)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.45 TRADEMARK SCRUB. Wording-only release — no functional changes.
* Per user directive: "you cannot say smoothwall and ipfire where merged
* into our fw script either. you can say logic derived from or influenced
* by these projects." Every reference to Smoothwall Express or IPFire now
* uses "takes influence from" / "logic derived from" instead of "merged"
* or "shipped". The sysdeck-fw backend, the 7 firewall templates, and the
* v0.0.44 service/port editor are unchanged. All 141 unit tests pass.
*
* v0.0.47 — SERVICE/PORT EDITOR MOVED TO ITS OWN SIDEBAR ENTRY.
* Per user directive: "we should move the service/ports editor to its
* own module entry for ease of access." The editor card that lived at
* the bottom of this panel since v0.0.44 has been lifted out into a
* first-class plugin — sysdeck-services at order 45. The bridge surface
* (bridge.firewall.services / service-info / set-service-port /
* restart-service) is unchanged; a new bridge.services proxy was added
* to bridge.js so the new panel has a clean API. The services() Promise
* in the parallel load below was removed because this panel no longer
* needs the inventory — it lives in the new Services panel. The
* renderServicePortEditor() function and the .btn-svc-save / .btn-svc-
* restart wireEvents handlers were removed from this file.
*
* v0.0.44 PUBLIC-SERVER VARIANTS + SERVICE/PORT EDITOR.
*
* - Three new public-server templates ship in this release:
* remote-admin.sh (SSH + Cockpit), public-webserver.sh
* (Caddy + Varnish + MariaDB), ai-llm.sh (Ollama + OpenWebUI
* + Hermes + Odysseus). They appear in the existing Templates
* card when the 'custom' backend is active — no new UI surface
* needed for selection.
* - Service/Port Editor card — runs bridge.firewall.services() to
* enumerate listening TCP ports on the host and cross-reference
* against the SERVICES_REGISTRY (ssh, cockpit, caddy, varnish,
* mariadb, ollama, openwebui, hermes, odysseus). Each registered
* service shows: current port from its config file, the listening
* ports actually active, the systemd unit, and an editable port
* input. Clicking Save edits the config file atomically (tmpfile
* + fsync + rename) and runs `systemctl restart` on the service.
* Unmapped listeners (ports with no matching registry entry) are
* shown in a separate block so the operator can spot services
* the editor doesn't yet know about.
*
* v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY.
*
* The v0.0.30 panel could only list active nftables rules. v0.0.31
* turns it into a full firewall manager:
*
* - Template selector — operator picks from installed templates
* under /usr/share/sysdeck/firewall/templates/ (vps-webserver.sh,
* no-services.sh, plus any operator-dropped *.sh). Each template
* is shown with its description and detected services.
* - Apply / Stop / Restart buttons — invoke the template's start /
* stop / restart action via the bridge under the cockpit
* superuser channel (polkit). No `sudo` shell-out from JS.
* - Service detection preview — runs the template's `detect`
* action and renders the inventory (OS, interface, services
* detected) before applying.
* - Live ban-list table — ssh_abuse / port_scanners / connlimit_abuse
* sets with per-IP Unban buttons and a Clear All button.
* - Active ruleset table — refreshed after each mutating operation
* so the operator sees the new state immediately.
*
* v0.0.36 BACKEND DROPDOWN + SECURITY CARD.
*
* - Backend selector — operator picks between custom / cilium /
* sysdeck-fw. The bridge probes availability (cilium
* installed? nftables installed? kernel BPF features?) and shows
* an install hint if missing. The "Install" button triggers
* bridge.firewall.installBackend (delegates to packages module).
* - Cilium-specific sections — when cilium is the active backend,
* the panel renders Cilium Status / Endpoints / Policy cards in
* place of the nftables ruleset table.
* - Security Card — renders the CVE-derived hardening checklist
* (bridge.firewall.securityHardening). Documents the lessons
* applied from Webmin, Cockpit, Ajenti, ISPConfig, Virtualmin,
* cPanel, Plesk, CyberPanel, aaPanel, CloudPanel, HestiaCP,
* VestaCP, Froxlor, InterWorx, BrainyCP, DirectAdmin, CWP CVE
* disclosures. Full table in docs/SECURITY-HARDENING.md.
* - Excluded backends info — explains why UFW, fwbuilder, iptables-
* legacy, iptables-nft, Shorewall, Smoothwall Express (trademark),
* and IPFire (trademark) are not in the dropdown. We took influence
* from Smoothwall Express and IPFire for the sysdeck-fw backend;
* we do not ship templates called "smoothwall" or "ipfire".
*
* Mutating ops go through bridge.firewall.apply / stop / restart / ban /
* unban / clearBans / switchBackend / installBackend / ciliumPolicyApply,
* which pass { superuser: 'try' } to cockpit.spawn. The cockpit bridge
* prompts the operator for auth via polkit; the org.sysdeck.firewall.modify
* action (shipped since v0.0.17, extended in v0.0.36 to authorize cilium
* + cilium-agent + helm) authorizes the binaries. This is the "cockpit
* way" per user directive v0.0.31.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
// v0.0.47: services inventory no longer loaded here — the editor
// moved to its own sidebar entry (sysdeck-services at order 45).
const [backendsResp, templates, status, rules, chains, hardening] = await Promise.all([
safe(bridge.firewall.backends(), { active: 'custom', backends: [], excluded: [] }),
safe(bridge.firewall.templates(), []),
safe(bridge.firewall.status(), { state: 'unavailable', bans: {} }),
safe(bridge.firewall.listRules(), []),
safe(bridge.firewall.listChains(), []),
safe(bridge.firewall.securityHardening(), { applied: [], cves_reviewed: [] }),
]);
const activeBackend = backendsResp?.active || 'custom';
const backends = backendsResp?.backends || [];
const excluded = backendsResp?.excluded || [];
const activeTemplate = status?.active_template || null;
const state = status?.state || 'unavailable';
const bans = status?.bans || {};
const bannedIpCount = status?.banned_ip_count || 0;
const isCilium = activeBackend === 'cilium';
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Firewall Control</h2>
<p class="suite-panel-subtitle">
<span class="suite-badge ${isCilium ? 'info' : 'success'}">${escapeHtml(activeBackend)}</span>
· ${isCilium ? 'eBPF datapath' : 'nftables'}
· <span class="suite-badge ${state === 'running' ? 'success' : 'danger'}">${state}</span>
${!isCilium ? ` · ${chains.length} chains · ${rules.length} rules` : ''}
· ${bannedIpCount} banned IP${bannedIpCount === 1 ? '' : 's'}
${activeTemplate ? ` · active: <code>${escapeHtml(activeTemplate)}</code>` : ''}
</p>
</header>
${renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate)}
${isCilium ? await renderCiliumSections(bridge) : ''}
${!isCilium ? renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) : ''}
${renderControls(state, isCilium, activeBackend, backends)}
${renderDetectionSection()}
${!isCilium ? renderBans(bans, bannedIpCount) : ''}
${!isCilium ? renderRuleset(rules, chains) : ''}
${renderSecurityCard(hardening)}
${renderServicesLinkCard()}
<div id="fw-output" class="suite-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title">Operation Output</h3>
<button class="suite-btn suite-btn-ghost" id="btn-fw-output-close">✕</button>
</div>
<pre class="suite-mono" id="fw-output-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
</div>
`;
// v0.0.43: Store backends data for getSelectedTemplate() to access
// (the Apply button handler needs to know which backend is active
// to determine which template to apply).
window.__sysdeckFirewallBackends = backends;
window.__sysdeckFirewallActiveBackend = activeBackend;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('firewall.loaded', {
ruleCount: rules.length, state, bannedIpCount, activeBackend,
});
}
// ── Render helpers ──────────────────────────────────────────────────
function renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate) {
if (!backends || !backends.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Firewall Backend</h3>
<div class="suite-card-body">
<p class="suite-muted">
No firewall backends available. This indicates a
broken install — re-install the <code>sysdeck</code>
package.
</p>
</div>
</div>
`;
}
// v0.0.43: Find the active backend object to determine whether it
// has its own template (cilium → cilium, sysdeck-fw → sysdeck-fw).
// If it does, the template selector is hidden — the backend IS the
// template. Only 'custom' shows the template selector.
const activeBackendObj = backends.find((b) => b.id === activeBackend) || backends[0];
const backendHasTemplate = activeBackendObj && activeBackendObj.template;
const options = backends.map((b) => {
const isActive = b.id === activeBackend;
const installed = b.available?.installed;
const techBadge = b.ebpf
? '<span class="suite-badge info" style="margin-left:0.5rem">eBPF</span>'
: '<span class="suite-badge" style="margin-left:0.5rem">nftables</span>';
const statusBadge = installed
? '<span class="suite-badge success" style="margin-left:0.25rem">installed</span>'
: '<span class="suite-badge danger" style="margin-left:0.25rem">not installed</span>';
return `
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(6,102,204,0.08);' : ''}">
<input type="radio" name="fw-backend" value="${escapeHtml(b.id)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
<strong>${escapeHtml(b.name)}</strong>
${techBadge}
${statusBadge}
${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''}
<div class="suite-muted" style="margin-top:0.25rem">${escapeHtml(b.description || '')}</div>
${!installed && b.available?.install_hint
? `<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:6px;border-radius:4px;font-size:0.75rem;white-space:pre-wrap">${escapeHtml(b.available.install_hint)}</pre>`
: ''}
${!installed && b.install_packages && b.install_packages.length
? `<button class="suite-btn suite-btn-ghost btn-fw-install-backend" data-backend="${escapeHtml(b.id)}" style="margin-top:0.5rem">Install via packages module</button>`
: ''}
</label>
`;
}).join('');
const excludedItems = excluded.map((e) => `
<li><code>${escapeHtml(e.id)}</code> — ${escapeHtml(e.reason)}</li>
`).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Firewall Backend (${backends.length})</h3>
<button class="suite-btn suite-btn-ghost" id="btn-fw-switch-backend">Switch Backend</button>
</div>
<div class="suite-card-body">
${options}
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
Switching backend stops the previous backend cleanly
before applying the new one.
${backendHasTemplate
? `The <strong>${escapeHtml(activeBackendObj.name)}</strong> backend uses its own template (<code>${escapeHtml(activeBackendObj.template)}</code>) — no template selection needed.`
: 'The <strong>custom</strong> backend lets you pick from the basic nftables templates below.'}
</p>
${excluded.length ? `
<details style="margin-top:0.75rem">
<summary class="suite-muted" style="cursor:pointer;font-size:0.8rem">
Excluded backends (${excluded.length}) — click to expand
</summary>
<ul class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem;padding-left:1.5rem">
${excludedItems}
</ul>
</details>
` : ''}
</div>
</div>
`;
}
async function renderCiliumSections(bridge) {
// Fetch Cilium status + endpoints + policy in parallel.
const [statusResp, endpointsResp, policyResp] = await Promise.all([
safe(bridge.firewall.ciliumStatus(), { installed: false, output: '', stderr: '' }),
safe(bridge.firewall.ciliumEndpoints(), { installed: false, endpoints: [] }),
safe(bridge.firewall.ciliumPolicy(), { installed: false, policies: [] }),
]);
const installed = statusResp?.installed || false;
if (!installed) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Cilium eBPF Backend</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
cilium-cli is not installed. Use the "Install via
packages module" button on the backend card above,
or install manually:
</p>
<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:8px;border-radius:4px;font-size:0.8rem">sudo pacman -S cilium-cli # Arch
sudo apt install cilium-cli # Debian
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium -n kube-system</pre>
</div>
</div>
`;
}
const endpointCount = Array.isArray(endpointsResp?.endpoints) ? endpointsResp.endpoints.length : 0;
const policyCount = Array.isArray(policyResp?.policies) ? policyResp.policies.length : 0;
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Cilium Status</h3>
</div>
<div class="suite-card-body">
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(statusResp?.output || statusResp?.stderr || '(no output)')}</pre>
</div>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Cilium Endpoints (${endpointCount})</h3>
</div>
<div class="suite-card-body">
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(JSON.stringify(endpointsResp?.endpoints || [], null, 2))}</pre>
</div>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Cilium Policies (${policyCount})</h3>
<button class="suite-btn suite-btn-ghost" id="btn-fw-cilium-apply-policy">Apply Default Policy</button>
</div>
<div class="suite-card-body">
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(JSON.stringify(policyResp?.policies || [], null, 2))}</pre>
</div>
</div>
`;
}
function renderServicesLinkCard() {
// v0.0.47: the Service / Port Editor moved to its own sidebar entry
// (sysdeck-services at order 45). This card is a signpost — it tells
// the operator where to find the editor and what it does. Keeping
// a stub here preserves the workflow for operators who used to
// scroll to the bottom of the Firewall panel for port edits.
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Service / Port Editor</h3>
<span class="suite-badge info">moved</span>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="margin-bottom:0.5rem;font-size:0.9rem">
The service/port editor has been promoted to its own
sidebar entry — <strong>Service / Ports</strong> at
order 45 — for ease of access. It enumerates every
listening TCP socket on the host via
<code>ss -tlnp</code> (with a
<code>/proc/net/tcp</code> fallback), cross-references
against the <code>SERVICES_REGISTRY</code> in
<code>bridge/firewall.py</code>, and lets you edit
the port in the service's config file with an atomic
write + <code>systemctl restart</code>.
</p>
<p class="suite-muted" style="font-size:0.85rem">
Click <strong>Service / Ports</strong> in the sidebar
to open the editor. The bridge surface
(<code>bridge.firewall.services</code> /
<code>service-info</code> /
<code>set-service-port</code> /
<code>restart-service</code>) is unchanged from
v0.0.44; a new <code>bridge.services</code> proxy
was added in v0.0.47 so the new panel has a clean
API surface.
</p>
</div>
</div>
`;
}
function renderSecurityCard(hardening) {
if (!hardening || !hardening.applied || !hardening.applied.length) {
return '';
}
const rows = hardening.applied.map((h) => `
<tr>
<td class="suite-table-mono">${escapeHtml(h.id || '')}</td>
<td><strong>${escapeHtml(h.title || '')}</strong><div class="suite-muted" style="font-size:0.8rem">${escapeHtml(h.detail || '')}</div></td>
<td class="suite-table-mono suite-muted">${escapeHtml(h.cve || '')}</td>
</tr>
`).join('');
const cveBadges = (hardening.cves_reviewed || []).slice(0, 12).map((cve) =>
`<span class="suite-badge" style="margin-right:0.25rem;font-size:0.7rem">${escapeHtml(cve)}</span>`
).join('');
const moreCount = (hardening.cves_reviewed || []).length - 12;
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Security Hardening (v0.0.36)</h3>
<span class="suite-muted" style="font-size:0.8rem">${hardening.applied.length} lessons applied</span>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="margin-bottom:0.5rem;font-size:0.85rem">
Each hardening item maps to a real CVE disclosure in
Webmin, Cockpit, Ajenti, ISPConfig, or Virtualmin.
Full checklist in <code>docs/SECURITY-HARDENING.md</code>.
</p>
<table class="suite-table">
<thead><tr><th>ID</th><th>Hardening</th><th>CVE</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div style="margin-top:0.75rem">
<span class="suite-muted" style="font-size:0.8rem">CVEs reviewed: </span>
${cveBadges}
${moreCount > 0 ? `<span class="suite-muted" style="font-size:0.8rem">+ ${moreCount} more</span>` : ''}
</div>
</div>
</div>
`;
}
function renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) {
// v0.0.43: If the active backend has its own template (cilium → cilium,
// sysdeck-fw → sysdeck-fw), DON'T render the template selector at all —
// the backend IS the template. This fixes the v0.0.36 logic flaw where
// two independent lists (backend + template) didn't coordinate.
const activeBackendObj = backends?.find((b) => b.id === activeBackend);
if (activeBackendObj?.template) {
return ''; // backend has its own template — selector not needed
}
// For the 'custom' backend, filter templates to show ONLY the basic
// nftables templates (vps-webserver, no-services). Exclude cilium +
// sysdeck-fw — those are backend-specific and would conflict if applied
// while the custom backend is active.
const backendTemplates = new Set(
(backends || [])
.filter((b) => b.template) // backends with their own template
.map((b) => b.template)
);
const filteredTemplates = (templates || []).filter((t) =>
!backendTemplates.has(t.name)
);
if (!filteredTemplates.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Templates</h3>
<div class="suite-card-body">
<p class="suite-muted">
No firewall templates found under
<code>/usr/share/sysdeck/firewall/templates/</code>.
Install the <code>sysdeck</code> package to ship the
default templates (vps-webserver.sh, no-services.sh),
or drop your own <code>*.sh</code> file there.
</p>
</div>
</div>
`;
}
const items = filteredTemplates.map((t) => {
const isActive = t.name === activeTemplate;
return `
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(6,102,204,0.08);' : ''}">
<input type="radio" name="fw-template" value="${escapeHtml(t.name)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
<strong>${escapeHtml(t.name)}</strong>
${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''}
<div class="suite-muted" style="margin-top:0.25rem">${escapeHtml(t.description || '')}</div>
${t.services && t.services.length ? `<div class="suite-muted" style="margin-top:0.25rem">Services: ${t.services.map((s) => `<span class="suite-badge info" style="margin-right:0.25rem">${escapeHtml(s)}</span>`).join('')}</div>` : ''}
${t.distros && t.distros.length ? `<div class="suite-muted" style="margin-top:0.25rem">Distros: ${t.distros.map((d) => `<span class="suite-badge" style="margin-right:0.25rem">${escapeHtml(d)}</span>`).join('')}</div>` : ''}
</label>
`;
}).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Templates (${filteredTemplates.length})</h3>
</div>
<div class="suite-card-body">
${items}
</div>
</div>
`;
}
function renderControls(state, isCilium, activeBackend, backends) {
const isRunning = state === 'running';
// v0.0.43: backend-aware Apply button label.
let applyLabel;
if (isCilium) {
applyLabel = '▶ Apply Cilium Policy';
} else {
const backend = backends?.find((b) => b.id === activeBackend);
if (backend?.template) {
applyLabel = `▶ Apply ${backend.template}`;
} else {
applyLabel = '▶ Apply Template';
}
}
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Controls</h3>
</div>
<div class="suite-card-body">
<div class="suite-row" style="gap:0.5rem">
<button class="suite-btn suite-btn-primary" id="btn-fw-apply" ${isRunning ? 'disabled' : ''}>${escapeHtml(applyLabel)}</button>
<button class="suite-btn" id="btn-fw-restart" ${!isRunning ? 'disabled' : ''}>↻ Restart</button>
<button class="suite-btn" id="btn-fw-stop" ${!isRunning ? 'disabled' : ''}>■ Stop</button>
<button class="suite-btn suite-btn-ghost" id="btn-fw-detect">🔎 Detect Services</button>
<button class="suite-btn suite-btn-ghost" id="btn-fw-check">✓ Validate</button>
<button class="suite-btn suite-btn-ghost" id="btn-fw-refresh">↻ Refresh</button>
</div>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
Apply / Restart / Stop prompt for the cockpit superuser
password via polkit. The
<code>org.sysdeck.firewall.modify</code> action authorizes
<code>/usr/bin/nft</code>${isCilium ? ', <code>/usr/bin/cilium</code>, <code>/usr/bin/cilium-agent</code>, <code>/usr/bin/helm</code>' : ''}.
</p>
</div>
</div>
`;
}
function renderDetectionSection() {
return `
<div class="suite-card" id="fw-detect-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title">Service Detection</h3>
</div>
<pre class="suite-mono" id="fw-detect-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
</div>
`;
}
function renderBans(bans, total) {
const sets = Object.keys(bans);
if (!sets.length) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Banned IPs (0)</h3>
<div class="suite-card-body">
<p class="suite-muted">No ban sets available — firewall is not running.</p>
</div>
</div>
`;
}
const allBanned = new Set();
for (const set of sets) {
for (const ip of (bans[set] || [])) allBanned.add(ip);
}
const banRows = [...allBanned].map((ip) => {
const setsWithIp = sets.filter((s) => (bans[s] || []).includes(ip));
return `
<tr>
<td class="suite-table-mono">${escapeHtml(ip)}</td>
<td>${setsWithIp.map((s) => `<span class="suite-badge info" style="margin-right:0.25rem">${escapeHtml(s)}</span>`).join('')}</td>
<td><button class="suite-btn suite-btn-ghost btn-fw-unban" data-ip="${escapeHtml(ip)}">Unban</button></td>
</tr>
`;
}).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Banned IPs (${allBanned.size})</h3>
<button class="suite-btn suite-btn-ghost" id="btn-fw-clear-bans" ${!allBanned.size ? 'disabled' : ''}>Clear All</button>
</div>
<table class="suite-table">
<thead><tr><th>IP</th><th>In sets</th><th>Action</th></tr></thead>
<tbody>
${banRows || '<tr><td colspan="3" class="suite-muted">No banned IPs.</td></tr>'}
</tbody>
</table>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
Ban sets: <code>${sets.join(', ')}</code>.
ssh_abuse = SSH brute-force ban (1h timeout),
port_scanners = port scan detection (1h timeout),
connlimit_abuse = connection rate limit exceeded (10m timeout).
</p>
</div>
`;
}
function renderRuleset(rules, chains) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Active Ruleset</h3>
<span class="suite-muted" style="font-size:0.8rem">${rules.length} rules in ${chains.length} chains</span>
</div>
<table class="suite-table">
<thead>
<tr><th>Chain</th><th>Rule</th><th>Handle</th></tr>
</thead>
<tbody>
${rules.slice(0, 100).map((r) => `
<tr>
<td><span class="suite-badge info">${escapeHtml(r.chain)}</span></td>
<td class="suite-table-mono">${escapeHtml(r.spec)}</td>
<td class="suite-table-mono suite-muted">${r.handle}</td>
</tr>
`).join('') || '<tr><td colspan="3" class="suite-muted">No rules — firewall is not running.</td></tr>'}
</tbody>
</table>
${rules.length > 100 ? `<p class="suite-muted">Showing first 100 of ${rules.length} rules.</p>` : ''}
</div>
`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
const output = (msg, isError = false) => {
const card = panel.querySelector('#fw-output');
const pre = panel.querySelector('#fw-output-pre');
if (!card || !pre) return;
card.style.display = 'block';
pre.textContent = msg;
pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)';
};
panel.querySelector('#btn-fw-output-close')?.addEventListener('click', () => {
const card = panel.querySelector('#fw-output');
if (card) card.style.display = 'none';
});
const getSelectedTemplate = () => {
// v0.0.43: The template to apply depends on the active backend.
// If the backend has its own template (cilium → cilium, sysdeck-fw
// → sysdeck-fw), use that — the template selector is hidden.
// If the backend is 'custom', use the operator's selection from
// the template radio buttons.
const backendsResp = window.__sysdeckFirewallBackends;
const activeBackend = window.__sysdeckFirewallActiveBackend;
if (backendsResp && activeBackend) {
const backend = backendsResp.find((b) => b.id === activeBackend);
if (backend?.template) return backend.template;
}
// 'custom' backend — use the radio button selection.
const checked = panel.querySelector('input[name="fw-template"]:checked');
if (checked) return checked.value;
// If only one template, return it.
const only = panel.querySelector('input[name="fw-template"]');
return only ? only.value : null;
};
const getSelectedBackend = () => {
const checked = panel.querySelector('input[name="fw-backend"]:checked');
return checked ? checked.value : null;
};
// v0.0.36: backend dropdown — switch backend (stops old, applies new).
panel.querySelector('#btn-fw-switch-backend')?.addEventListener('click', async () => {
const backend = getSelectedBackend();
if (!backend) { output('Select a backend first.', true); return; }
output(`Switching firewall backend to ${backend} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.firewall.switchBackend(backend);
const steps = (r.steps || []).map((s) =>
` [${s.step}] rc=${s.rc} ${s.output ? '· ' + s.output.split('\n')[0] : ''}${s.stderr ? ' · stderr: ' + s.stderr.split('\n')[0] : ''}`
).join('\n');
output(r.switched
? `Backend switched: ${r.previous} → ${r.current}\n\nSteps:\n${steps}\n\nAvailable: ${JSON.stringify(r.available)}`
: `Backend switch FAILED: ${JSON.stringify(r, null, 2)}`,
!r.switched);
if (r.switched) setTimeout(() => mount(panel, { bridge, EventBus }), 1200);
} catch (err) { output(`Switch-backend error: ${err.message || err}`, true); }
});
// v0.0.36: install backend deps (delegates to packages module).
panel.querySelectorAll('.btn-fw-install-backend').forEach((btn) => {
btn.addEventListener('click', async (ev) => {
ev.preventDefault();
ev.stopPropagation();
const backend = btn.dataset.backend;
if (!backend) return;
output(`Installing packages for backend ${backend} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.firewall.installBackend(backend);
output(r.installed
? `Backend ${backend} installed.\nPackages: ${(r.packages || []).join(', ')}\n\n${r.output || ''}`
: `Install FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`,
!r.installed);
if (r.installed) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Install-backend error: ${err.message || err}`, true); }
});
});
// v0.0.36: apply Cilium default policy.
panel.querySelector('#btn-fw-cilium-apply-policy')?.addEventListener('click', async () => {
output('Applying Cilium default policy ... (cockpit will prompt for auth)');
try {
const r = await bridge.firewall.ciliumPolicyApply('cilium-default.yaml');
output(r.applied
? `Cilium policy applied: ${r.policy}\n\n${r.output || ''}`
: `Cilium policy apply FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`,
!r.applied);
if (r.applied) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Cilium-policy-apply error: ${err.message || err}`, true); }
});
panel.querySelector('#btn-fw-apply')?.addEventListener('click', async () => {
const template = getSelectedTemplate();
if (!template) { output('Select a template first.', true); return; }
output(`Applying template ${template} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.firewall.apply(template);
const msg = r.success
? `Template ${r.template} applied successfully.\n\n${r.output || ''}`
: `Apply FAILED (rc=${r.rc}).\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`;
output(msg, !r.success);
if (r.success) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Apply error: ${err.message || err}`, true); }
});
panel.querySelector('#btn-fw-restart')?.addEventListener('click', async () => {
output('Restarting firewall ... (cockpit will prompt for auth)');
try {
const r = await bridge.firewall.restart();
const ok = r.restarted;
output(ok
? `Firewall restarted (template: ${r.template || 'none'}).\n\n${JSON.stringify(r.apply_result || {}, null, 2)}`
: `Restart did not complete: ${JSON.stringify(r, null, 2)}`,
!ok);
if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Restart error: ${err.message || err}`, true); }
});
panel.querySelector('#btn-fw-stop')?.addEventListener('click', async () => {
output('Stopping firewall ... (cockpit will prompt for auth)');
try {
const r = await bridge.firewall.stop();
output(r.stopped
? `Firewall stopped (method: ${r.method}).\n\n${r.output || ''}`
: `Stop FAILED.\n\nstderr: ${r.stderr || '(empty)'}`,
!r.stopped);
if (r.stopped) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Stop error: ${err.message || err}`, true); }
});
panel.querySelector('#btn-fw-detect')?.addEventListener('click', async () => {
const card = panel.querySelector('#fw-detect-card');
const pre = panel.querySelector('#fw-detect-pre');
if (!card || !pre) return;
card.style.display = 'block';
pre.textContent = 'Running service detection ...';
try {
const r = await bridge.firewall.detect();
pre.textContent = r.output || `(no output)\n\nstderr: ${r.stderr || ''}`;
EventBus.emit('firewall.detected', { template: r.active_template });
} catch (err) {
pre.textContent = `Detect error: ${err.message || err}`;
}
});
panel.querySelector('#btn-fw-check')?.addEventListener('click', async () => {
output('Validating ruleset ...');
try {
const r = await bridge.firewall.check();
output(r.valid
? 'Ruleset is valid.'
: `Validation FAILED.\n\nstderr: ${r.stderr || '(empty)'}`,
!r.valid);
} catch (err) { output(`Check error: ${err.message || err}`, true); }
});
panel.querySelector('#btn-fw-clear-bans')?.addEventListener('click', async () => {
output('Clearing all ban lists ... (cockpit will prompt for auth)');
try {
const r = await bridge.firewall.clearBans();
output(r.cleared
? 'All ban lists cleared.'
: `Clear-bans partial failure: ${JSON.stringify(r.sets)}`,
!r.cleared);
if (r.cleared) setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Clear-bans error: ${err.message || err}`, true); }
});
panel.querySelectorAll('.btn-fw-unban').forEach((btn) => {
btn.addEventListener('click', async () => {
const ip = btn.dataset.ip;
output(`Unbanning ${ip} ... (cockpit will prompt for auth)`);
try {
const r = await bridge.firewall.unban(ip);
output(r.unbanned
? `${ip} unbanned from sets: ${Object.entries(r.sets).filter(([k, v]) => v).map(([k]) => k).join(', ') || '(was not in any set)'}`
: `${ip} was not found in any ban set.`,
!r.unbanned);
setTimeout(() => mount(panel, { bridge, EventBus }), 800);
} catch (err) { output(`Unban error: ${err.message || err}`, true); }
});
});
panel.querySelector('#btn-fw-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
// v0.0.47: the .btn-svc-save / .btn-svc-restart handlers and the
// .svc-port-input wiring were removed from this file — the entire
// Service / Port Editor card moved to the new sysdeck-services
// plugin at sidebar order 45. See plugins/sysdeck-services/services.js.
// systemd unit subscription (kept from v0.0.30 — best-effort).
if (panel._unsubscribeUnit) panel._unsubscribeUnit();
try {
if (bridge.dbusProxies?.systemd) {
panel._unsubscribeUnit = bridge.dbusProxies.systemd.subscribeToUnit(
'nftables.service',
() => mount(panel, { bridge, EventBus }),
);
}
} catch {
// systemd proxy unavailable — manual refresh still works.
}
}
// ── Utilities ───────────────────────────────────────────────────────
async function safe(p, fallback) {
try {
const v = await p;
return v ?? fallback;
} catch {
return fallback;
}
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Firewall</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./firewall.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,50 @@
{
"version": 0,
"name": "sysdeck-firewall",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Firewall",
"order": 21,
"keywords": [
{
"matches": [
"firewall",
"nftables",
"iptables",
"rules",
"filter",
"ban",
"unban",
"template",
"ssh",
"rate limit",
"cilium",
"ebpf",
"xdp",
"sysdeck-fw",
"zone",
"color zone",
"airwall",
"backend",
"security",
"hardening",
"remote-admin",
"public-webserver",
"ai-llm",
"ollama",
"openwebui",
"hermes",
"odysseus",
"caddy",
"varnish",
"mariadb"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,52 @@
/*
* SysDeck - Firmware Panel
* Author: Jeremy Anderson (https://dcos.net)
*
* Uses fwupdmgr to enumerate firmware devices and tpm2_pcrread to dump
* the first PCR register (boot chain proof). Both calls fail closed
* with informative cards when the underlying tools are absent.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const [devices, tpmPcr0] = await Promise.allSettled([
bridge.firmware.devices(),
bridge.firmware.tpmInfo(),
]);
const deviceList = devices.value?.Devices ?? [];
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Firmware Control</h2>
<p class="suite-panel-subtitle">fwupd + TPM 2.0</p>
</header>
<div class="suite-card">
<h3 class="suite-card-title">fwupd Devices (${deviceList.length})</h3>
<table class="suite-table">
<thead><tr><th>Name</th><th>Vendor</th><th>Version</th><th>Flags</th></tr></thead>
<tbody>
${deviceList.map((d) => `
<tr>
<td>${d.Name}</td>
<td class="suite-muted">${d.Vendor ?? '—'}</td>
<td class="suite-table-mono">${d.Version ?? '—'}</td>
<td class="suite-muted">${(d.Flags ?? []).join(', ') || '—'}</td>
</tr>
`).join('') || '<tr><td colspan="4" class="suite-muted">No fwupd devices.</td></tr>'}
</tbody>
</table>
</div>
<div class="suite-card">
<h3 class="suite-card-title">TPM 2.0 — PCR 0 (SHA256)</h3>
<pre class="suite-card-body suite-mono">${escapeHtml(tpmPcr0.value ?? 'tpm2-tools not installed')}</pre>
</div>
`;
EventBus.emit('firmware.loaded', { deviceCount: deviceList.length });
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
function renderSkeleton() {
return `<div class="suite-skeleton"><div class="suite-skeleton-line w-1/3"></div><div class="suite-skeleton-line w-2/3"></div></div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Firmware</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./firmware.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"version": 0,
"name": "sysdeck-firmware",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Firmware",
"order": 29,
"keywords": [
{
"matches": [
"firmware",
"fwupd",
"tpm",
"bios",
"update"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

103
plugins/sysdeck-fleet/fleet.js Executable file
View File

@ -0,0 +1,103 @@
/*
* SysDeck - Fleet Compute Panel (v0.0.10)
* Author: Jeremy Anderson (https://dcos.net)
*
* Subscribes to the bridge metrics tap for live CPU + memory samples.
* The tap is a cockpit.metrics channel that emits derive samples at
* the bridge's update interval; no polling required.
*
* Also surfaces the local host uptime via `uptime` and peer-host
* guidance via the cockpit multi-host dashboard contract.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const [uptime] = await Promise.allSettled([bridge.fleet.uptime()]);
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Fleet Compute</h2>
<p class="suite-panel-subtitle">Local host (fleet-of-one)</p>
</header>
<div class="suite-grid cols-2">
<div class="suite-card">
<h3 class="suite-card-title">Uptime</h3>
<div class="suite-card-body suite-mono">${escapeHtml(uptime.value ?? 'unavailable')}</div>
</div>
<div class="suite-card">
<h3 class="suite-card-title">Live CPU / Memory</h3>
<div class="suite-card-body">
<div class="suite-row-between">
<span>CPU user</span>
<span class="suite-mono" id="fleet-cpu-user">—</span>
</div>
<div class="suite-progress-bar" style="margin-top:4px">
<div class="suite-progress-fill" id="fleet-cpu-bar" style="width:0%"></div>
</div>
<div class="suite-row-between" style="margin-top:12px">
<span>Memory used</span>
<span class="suite-mono" id="fleet-mem-used">—</span>
</div>
<div class="suite-progress-bar" style="margin-top:4px">
<div class="suite-progress-fill" id="fleet-mem-bar" style="width:0%"></div>
</div>
</div>
</div>
</div>
<div class="suite-card">
<h3 class="suite-card-title">Peer Hosts</h3>
<div class="suite-card-body suite-muted">
Multi-host dashboard not enabled. Configure <code>/etc/cockpit/machines.d/</code> to surface peer hosts.
</div>
</div>
`;
// Subscribe to the live metrics tap. The unsubscribe function is
// stashed on the panel so a re-mount can clean up.
if (panel._unsubscribeMetrics) panel._unsubscribeMetrics();
try {
panel._unsubscribeMetrics = bridge.fleet.subscribeLoadAvg((samples) => {
if (!Array.isArray(samples)) return;
const [cpuUser, _cpuSys, memUsed, memTotal] = samples;
if (typeof cpuUser === 'number') {
const cpuEl = panel.querySelector('#fleet-cpu-user');
const cpuBar = panel.querySelector('#fleet-cpu-bar');
if (cpuEl) cpuEl.textContent = `${cpuUser.toFixed(1)}%`;
if (cpuBar) cpuBar.style.width = `${Math.min(cpuUser, 100)}%`;
}
if (typeof memUsed === 'number' && typeof memTotal === 'number' && memTotal > 0) {
const pct = (memUsed / memTotal) * 100;
const memEl = panel.querySelector('#fleet-mem-used');
const memBar = panel.querySelector('#fleet-mem-bar');
if (memEl) memEl.textContent = `${formatBytes(memUsed)} / ${formatBytes(memTotal)}`;
if (memBar) {
memBar.style.width = `${pct.toFixed(1)}%`;
memBar.classList.toggle('warn', pct > 75);
memBar.classList.toggle('danger', pct > 90);
}
}
});
} catch {
// Metrics channel unavailable — bars stay at 0%.
}
EventBus.emit('fleet.loaded');
}
function formatBytes(bytes) {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
function renderSkeleton() {
return `<div class="suite-skeleton"><div class="suite-skeleton-line w-1/3"></div></div>`;
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Fleet</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./fleet.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"version": 0,
"name": "sysdeck-fleet",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Fleet",
"order": 26,
"keywords": [
{
"matches": [
"fleet",
"uptime",
"load",
"hosts",
"machines"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,388 @@
/*
* SysDeck - Glances Panel (v0.0.47)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.47 — DEFAULT-ON EMBEDDED WEBUI. Per user directive: "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." v0.0.34 added the webui integration but kept it opt-in (the
* operator had to click "Start Web UI" each time they opened the panel)
* and rendered the legacy SysDeck snapshot cards above the iframe.
* v0.0.47 flips the default:
*
* 1. AUTO-START: when the panel mounts 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.
* 2. 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 so they don't push the iframe below the fold. The
* operator can still expand them for a quick numeric read, but
* the default view is the Glances web UI.
* 3. STOP ON UNMOUNT (best-effort): when the panel is unmounted
* (operator navigates away), we don't stop the webserver — it's
* cheap to keep running and the operator may re-open the panel
* soon. The existing Stop button is still there for explicit
* shutdown. (If we wanted to be aggressive we could stop on
* pagehide, but that would slow re-entry.)
*
* v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI as a module.
* The user directive: "glances is not integrated yet i just assumed
* you would integrate the built in webui as a module." Glances ships
* a webserver via `glances -w` (default 127.0.0.1:61208) that serves
* the full Glances web UI — every chart, every sensor, every top
* process, every history graph. SysDeck starts that webserver as a
* background process via bridge.glances.startWeb() and iframes the
* running web UI into this panel. No SysDeck-side reimplementation
* of the Glances UI.
*
* Glances is GPL-3.0 licensed by Nicolargo. The bridge helper invokes
* it as a separate process via subprocess — the suite (MIT) and
* Glances (GPL-3.0) remain independent programs. No Glances code is
* bundled.
*/
// v0.0.47: how long to wait between calling startWeb() and re-checking
// web-status. Glances typically takes <1s on a warm start, but we give
// it a small grace period before re-rendering so the iframe doesn't
// load a half-up webserver.
const WEB_START_POLL_MS = 800;
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
let webStatus = await safe(bridge.glances.webStatus(), {});
let snapshot = await safe(bridge.glances.snapshot(), {});
// v0.0.47: AUTO-START. If glances is installed and the webserver
// isn't running, start it automatically. The operator gets the
// full Glances web UI on first paint instead of an empty iframe
// and a "click here" prompt.
if (webStatus?.available !== false && !webStatus?.running) {
panel.innerHTML = renderStartingCard(webStatus);
try {
const startResp = await bridge.glances.startWeb();
if (startResp?.started || startResp?.already_running) {
// Poll web-status after a short grace period so the
// webserver has time to bind the socket.
await new Promise((r) => setTimeout(r, WEB_START_POLL_MS));
webStatus = await safe(bridge.glances.webStatus(), webStatus);
// Re-fetch the snapshot too — it's now backed by the
// running webserver's data.
snapshot = await safe(bridge.glances.snapshot(), snapshot);
} else if (startResp?.error) {
console.warn('glances.startWeb returned error:', startResp.error);
}
} catch (err) {
console.warn('glances.startWeb threw:', err);
}
}
const webRunning = webStatus?.running === true;
const webUrl = webStatus?.url || 'http://127.0.0.1:61208';
const glancesAvailable = webStatus?.available !== false;
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">System Monitor</h2>
<p class="suite-panel-subtitle">
Glances — real-time system overview
· <span class="suite-badge info">GPL-3.0 · Nicolargo</span>
${webRunning
? ` · <span class="suite-badge success">web UI running</span>`
: (glancesAvailable
? ` · <span class="suite-badge">web UI stopped</span>`
: ` · <span class="suite-badge danger">glances not installed</span>`)}
${webRunning && webStatus?.pid ? ` · PID ${webStatus.pid}` : ''}
</p>
</header>
${renderWebControls(webStatus, webRunning, webUrl)}
${webRunning ? renderWebIframe(webUrl) : ''}
${!webRunning && glancesAvailable ? renderStartPrompt(webUrl) : ''}
${glancesAvailable ? renderLegacySnapshotDetails(snapshot) : ''}
`;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('glances.loaded', { webRunning, autoStarted: webRunning });
}
// ── Skeleton + starting states ──────────────────────────────────────
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}
function renderStartingCard(webStatus) {
// v0.0.47: shown while we're auto-starting the webserver.
const url = webStatus?.url || 'http://127.0.0.1:61208';
return `
<header>
<h2 class="suite-panel-title">System Monitor</h2>
<p class="suite-panel-subtitle">
Glances — real-time system overview
· <span class="suite-badge info">GPL-3.0 · Nicolargo</span>
· <span class="suite-badge">starting web UI…</span>
</p>
</header>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Starting Glances web UI…</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
v0.0.47 auto-start: the panel is launching the
built-in Glances webserver at
<code>${escapeHtml(url)}</code> via
<code>glances -w --bind 127.0.0.1 --port 61208</code>.
The full web UI will appear here in a moment — every
chart, every sensor, every top process, every history
graph, without SysDeck re-implementing any of it.
</p>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.85rem">
If this takes more than a few seconds, click
<strong>↻ Refresh</strong> or check the cockpit bridge
log. The first start may be slow if glances needs to
warm its import cache.
</p>
</div>
</div>
`;
}
// ── Web UI controls ─────────────────────────────────────────────────
function renderWebControls(webStatus, webRunning, webUrl) {
if (webStatus?.available === false) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Glances Web UI</h3>
<div class="suite-card-body">
<p class="suite-muted">
${escapeHtml(webStatus.reason || 'Glances is not installed.')}
</p>
${webStatus.install ? `<p class="suite-muted" style="margin-top:0.5rem"><code>${escapeHtml(webStatus.install)}</code></p>` : ''}
<p class="suite-muted" style="margin-top:0.5rem">
Glances is GPL-3.0 licensed by Nicolargo —
<a href="https://github.com/nicolargo/glances" style="color:var(--sysdeck-accent)">https://github.com/nicolargo/glances</a>
</p>
</div>
</div>
`;
}
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Glances Web UI</h3>
<div class="suite-row" style="gap:0.5rem">
<button class="suite-btn ${webRunning ? '' : 'suite-btn-primary'}" id="btn-glances-start-web" ${webRunning ? 'disabled' : ''}>▶ Start Web UI</button>
<button class="suite-btn" id="btn-glances-stop-web" ${!webRunning ? 'disabled' : ''}>■ Stop Web UI</button>
<button class="suite-btn suite-btn-ghost" id="btn-glances-refresh">↻ Refresh</button>
</div>
</div>
<div class="suite-card-body">
<p class="suite-muted" style="font-size:0.85rem">
${webRunning
? `Running at <code>${escapeHtml(webUrl)}</code>${webStatus.pid ? ` (PID ${webStatus.pid})` : ''}. The iframe below loads the full Glances web UI — every chart, every sensor, every top process, every history graph. No SysDeck-side reimplementation. v0.0.47 auto-starts this on panel mount; click Stop to disable.`
: `Stopped. v0.0.47 default is auto-start on panel mount — click <strong>▶ Start Web UI</strong> to launch it manually, or <strong>↻ Refresh</strong> to re-trigger the auto-start. The bridge runs <code>glances -w --bind 127.0.0.1 --port 61208</code> as a background process via the cockpit superuser channel.`}
</p>
</div>
</div>
`;
}
function renderWebIframe(webUrl) {
// v0.0.47: iframe is now the primary view — sized to fill the
// viewport. min-height uses calc(100vh - 200px) so the iframe
// extends to just above the page footer, leaving room for the
// controls card above and the legacy snapshot <details> below.
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Web UI — ${escapeHtml(webUrl)}</h3>
<a href="${escapeHtml(webUrl)}" target="_blank" class="suite-btn suite-btn-ghost">↗ Open in new tab</a>
</div>
<iframe src="${escapeHtml(webUrl)}"
style="width:100%;min-height:calc(100vh - 200px);height:calc(100vh - 200px);border:1px solid var(--sysdeck-border);border-radius:6px;background:#1e1e1e;"
id="glances-iframe"
title="Glances Web UI"></iframe>
</div>
`;
}
function renderStartPrompt(webUrl) {
// Shown when the webserver isn't running and didn't auto-start
// (e.g. glances is installed but startWeb returned an error).
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Web UI not running</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
The auto-start didn't bring up the Glances webserver.
Click <strong>▶ Start Web UI</strong> above to try
again, or <strong>↻ Refresh</strong> to re-run the
auto-start sequence. Expected URL:
<code>${escapeHtml(webUrl)}</code>.
</p>
</div>
</div>
`;
}
// ── Legacy snapshot (collapsed <details>) ───────────────────────────
function renderLegacySnapshotDetails(snapshot) {
// v0.0.47: the snapshot cards are kept (they're a useful numeric
// read) but moved into a collapsed <details> so the iframe is the
// primary view. The operator can expand for the SysDeck-rendered
// CPU/Memory/Swap/Network/Disk/Processes summary.
return `
<details class="suite-card" style="margin-top:0.75rem">
<summary class="suite-card-header" style="cursor:pointer;list-style:none">
<h3 class="suite-card-title" style="display:inline">Legacy SysDeck Snapshot (collapsed — iframe above is the primary view)</h3>
</summary>
<div class="suite-card-body">
${renderSnapshotCards(snapshot)}
</div>
</details>
`;
}
function renderSnapshotCards(snapshot) {
const cpu = snapshot.cpu || {};
const mem = snapshot.mem || {};
const swap = snapshot.memswap || {};
const network = snapshot.network || {};
const fs = snapshot.fs || {};
const procs = snapshot.processcount || {};
return `
<div class="suite-row">
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">CPU</h3>
<div class="suite-stat-value">${(cpu.total || 0).toFixed(1)}%</div>
<div class="suite-stat-label">user: ${(cpu.user || 0).toFixed(1)}% · system: ${(cpu.system || 0).toFixed(1)}% · idle: ${(cpu.idle || 0).toFixed(1)}%</div>
<div class="suite-progress"><div class="suite-progress-bar" style="width:${Math.min(cpu.total || 0, 100)}%"></div></div>
</div>
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">Memory</h3>
<div class="suite-stat-value">${(mem.percent || 0).toFixed(1)}%</div>
<div class="suite-stat-label">${fmtMB(mem.used || 0)} / ${fmtMB(mem.total || 0)}</div>
<div class="suite-progress"><div class="suite-progress-bar" style="width:${Math.min(mem.percent || 0, 100)}%"></div></div>
</div>
<div class="suite-card suite-col-3">
<h3 class="suite-card-title">Swap</h3>
<div class="suite-stat-value">${(swap.percent || 0).toFixed(1)}%</div>
<div class="suite-stat-label">${fmtMB(swap.used || 0)} / ${fmtMB(swap.total || 0)}</div>
<div class="suite-progress"><div class="suite-progress-bar" style="width:${Math.min(swap.percent || 0, 100)}%"></div></div>
</div>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Network Interfaces</h3>
</div>
<table class="suite-table">
<thead><tr><th>Interface</th><th>Rx/s</th><th>Tx/s</th><th>Rx Err</th><th>Tx Err</th></tr></thead>
<tbody>
${Object.entries(network).map(([iface, n]) => `<tr>
<td class="suite-table-mono">${escapeHtml(iface)}</td>
<td>${fmtKB(n.rx || 0)}/s</td>
<td>${fmtKB(n.tx || 0)}/s</td>
<td>${n.rx_errors || 0}</td>
<td>${n.tx_errors || 0}</td>
</tr>`).join('') || '<tr><td colspan="5" class="suite-muted">No network data.</td></tr>'}
</tbody>
</table>
</div>
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Disk I/O</h3>
</div>
<table class="suite-table">
<thead><tr><th>Device</th><th>Read/s</th><th>Write/s</th></tr></thead>
<tbody>
${Object.entries(fs).map(([dev, d]) => `<tr>
<td class="suite-table-mono">${escapeHtml(dev)}</td>
<td>${fmtKB(d.r_bytes_ps || 0)}/s</td>
<td>${fmtKB(d.w_bytes_ps || 0)}/s</td>
</tr>`).join('') || '<tr><td colspan="3" class="suite-muted">No disk data.</td></tr>'}
</tbody>
</table>
</div>
<div class="suite-card">
<p class="suite-muted">Processes: ${procs.total || 0} total · ${procs.running || 0} running · ${procs.sleeping || 0} sleeping · ${procs.thread || 0} threads</p>
</div>
`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
panel.querySelector('#btn-glances-start-web')?.addEventListener('click', async () => {
const btn = panel.querySelector('#btn-glances-start-web');
if (btn) { btn.disabled = true; btn.textContent = 'Starting ...'; }
try {
const r = await bridge.glances.startWeb();
if (r.started || r.already_running) {
// Give the webserver a moment to bind before re-mount.
await new Promise((res) => setTimeout(res, WEB_START_POLL_MS));
mount(panel, { bridge, EventBus });
} else {
if (btn) { btn.disabled = false; btn.textContent = '▶ Start Web UI'; }
alert(`Failed to start Glances web UI:\n${r.error || r.reason || 'unknown'}`);
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = '▶ Start Web UI'; }
alert(`Start error: ${err.message || err}`);
}
});
panel.querySelector('#btn-glances-stop-web')?.addEventListener('click', async () => {
const btn = panel.querySelector('#btn-glances-stop-web');
if (btn) { btn.disabled = true; }
try {
await bridge.glances.stopWeb();
setTimeout(() => mount(panel, { bridge, EventBus }), 500);
} catch (err) {
if (btn) { btn.disabled = false; }
alert(`Stop error: ${err.message || err}`);
}
});
panel.querySelector('#btn-glances-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
}
// ── Utilities ───────────────────────────────────────────────────────
async function safe(p, fallback) {
try {
const v = await p;
return v ?? fallback;
} catch {
return fallback;
}
}
function fmtMB(kb) { return (kb / 1024).toFixed(1) + ' MB'; }
function fmtKB(bytes) { return (bytes / 1024).toFixed(1) + ' KB'; }
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Glances</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./glances.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,31 @@
{
"version": 0,
"name": "sysdeck-glances",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Glances",
"order": 34,
"keywords": [
{
"matches": [
"glances",
"monitoring",
"cpu",
"memory",
"disk",
"network",
"webui",
"web ui",
"embed",
"iframe",
"real-time"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; frame-src 'self' http://127.0.0.1:61208 http://localhost:61208"
}

View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Integrity</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
// The user just sees the error on the page — no devtools required.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
// Pre-flight check: did cockpit.js actually load?
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./integrity.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,100 @@
/*
* SysDeck - Integrity Panel (v0.0.10)
* Author: Jeremy Anderson (https://dcos.net)
*
* Calls lynis audit system via cockpit.spawn. Falls back gracefully
* when lynis is absent — the trust score becomes null and the panel
* shows an install hint.
*
* Uses the systemd dbus proxy to surface the integrity-scanner service
* state without polling. The proxy fires 'changed' when the unit state
* transitions; the panel re-fetches the trust score on that signal.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const trust = await bridge.integrity.trustScore();
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Integrity Auditor</h2>
<p class="suite-panel-subtitle">Lynis system audit</p>
</header>
<div class="suite-grid cols-3">
<div class="suite-card">
<h3 class="suite-card-title">Trust Score</h3>
<div class="suite-card-body">
${trust !== null
? `<div style="font-size:48px;font-weight:700;color:${scoreColor(trust)}">${trust}<span style="font-size:18px;color:var(--suite-fg-muted)">/100</span></div>`
: '<p class="suite-muted">Lynis not installed.</p>'}
</div>
</div>
<div class="suite-card">
<h3 class="suite-card-title">Quick Actions</h3>
<div class="suite-card-body suite-row">
<button class="suite-btn suite-btn-primary" id="btn-run-lynis">Run Full Audit</button>
</div>
</div>
<div class="suite-card">
<h3 class="suite-card-title">Scanner Status</h3>
<div class="suite-card-body">
<div class="suite-row-between"><span>lynis</span><span class="suite-badge ${trust !== null ? 'success' : 'danger'}">${trust !== null ? 'ready' : 'missing'}</span></div>
<div class="suite-row-between" style="margin-top:8px"><span>rkhunter</span><span class="suite-badge info">deferred</span></div>
<div class="suite-row-between" style="margin-top:8px"><span>chkrootkit</span><span class="suite-badge info">deferred</span></div>
</div>
</div>
</div>
`;
const runBtn = panel.querySelector('#btn-run-lynis');
if (runBtn) {
runBtn.addEventListener('click', async () => {
runBtn.disabled = true;
runBtn.textContent = 'Running audit…';
try {
await bridge.integrity.runLynis();
EventBus.emit('integrity.scan.complete');
mount(panel, { bridge, EventBus });
} catch (err) {
runBtn.textContent = 'Run Full Audit';
runBtn.disabled = false;
EventBus.emit('integrity.scan.error', { error: err.message });
}
});
}
// Subscribe to lynis.service state changes via the systemd dbus proxy.
// On any transition, re-fetch the trust score so the panel reflects
// the most recent audit result without manual refresh.
if (panel._unsubscribeUnit) panel._unsubscribeUnit();
try {
if (bridge.dbusProxies?.systemd) {
panel._unsubscribeUnit = bridge.dbusProxies.systemd.subscribeToUnit(
'lynis.service',
() => mount(panel, { bridge, EventBus }),
);
}
} catch {
// systemd proxy unavailable — manual refresh still works.
}
}
const SCORE_COLORS = [
{ min: 90, color: 'var(--suite-accent-success)' },
{ min: 70, color: 'var(--suite-accent-warn)' },
{ min: 0, color: 'var(--suite-accent-danger)' },
];
function scoreColor(score) {
// Step-down: lookup table over if-ladder. First match wins.
const entry = SCORE_COLORS.find((band) => score >= band.min);
return entry?.color ?? 'var(--suite-accent-danger)';
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
</div>`;
}

View File

@ -0,0 +1,25 @@
{
"version": 0,
"name": "sysdeck-integrity",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Integrity",
"order": 22,
"keywords": [
{
"matches": [
"integrity",
"lynis",
"audit",
"hardening",
"trust"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

View File

@ -0,0 +1,64 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Jellyfin</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code> to be loaded first. ' +
'The &lt;script src&gt; tag in this page\'s &lt;head&gt; either failed to fetch the file, or the file is not at the expected path. ' +
'Check that <code>/usr/share/cockpit/base1/cockpit.js</code> exists (installed by the <code>cockpit-bridge</code> package).</p>' +
'<p class="sysdeck-muted">Run: ls -l /usr/share/cockpit/base1/cockpit.js</p>' +
'</div>';
throw new Error('window.cockpit is undefined — ../base1/cockpit.js failed to load');
}
try {
const { mount } = await import("./jellyfin.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

View File

@ -0,0 +1,254 @@
/*
* SysDeck - Jellyfin Panel (v0.0.35)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.35 directive: "next we will integrate a jellyfin management
* module where it starts, stops, and loads the admin panel in the
* module." Jellyfin ships a single systemd unit (jellyfin.service)
* and serves a full admin web UI on http://127.0.0.1:8096. The
* bridge starts/stops/restarts the service via systemctl; the panel
* iframes the running admin UI — same pattern as the v0.0.34 Glances
* integration.
*
* The panel surfaces:
* - Service summary card: status badge, version, port, uptime
* - Service controls: Start / Stop / Restart (polkit prompts)
* - Admin UI iframe: loads http://127.0.0.1:8096 when running
* - Library list: best-effort GET /Library/VirtualFolders
* - Install hint when jellyfin is not installed
*
* Jellyfin is GPL-2.0 licensed by the Jellyfin contributors. The
* bridge helper invokes it as a separate process via subprocess —
* the suite (MIT) and Jellyfin (GPL-2.0) remain independent
* programs. No Jellyfin code is bundled.
*
* Bridge surface (see shared/bridge.js → bridge.jellyfin):
* summary() → {available, status, version, port, url, ...}
* status() → service state dict
* start() → {action, rc, success, output, stderr}
* stop() → same shape
* restart() → same shape
* webStatus() → {running, url, port, ...}
* libraries() → {libraries: [...], count: N}
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const [summary, webStatus, libraries] = await Promise.all([
safe(bridge.jellyfin.summary(), {}),
safe(bridge.jellyfin.webStatus(), {}),
safe(bridge.jellyfin.libraries(), { libraries: [], count: 0 }),
]);
const running = webStatus?.running === true || summary?.status === 'running';
const available = webStatus?.available !== false && summary?.available !== false;
const webUrl = webStatus?.url || summary?.url || 'http://127.0.0.1:8096';
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">Jellyfin Media Server</h2>
<p class="suite-panel-subtitle">
Self-hosted media streaming · movies · TV · music
· <span class="suite-badge info">GPL-2.0 · Jellyfin contributors</span>
${available
? (running
? ` · <span class="suite-badge success">running</span>`
: ` · <span class="suite-badge">stopped</span>`)
: ` · <span class="suite-badge danger">not installed</span>`}
</p>
</header>
${renderServiceCard(summary, available, running, webUrl)}
${available && running ? renderWebIframe(webUrl) : ''}
${available ? renderLibrariesCard(libraries) : ''}
`;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('jellyfin.loaded', { running, available });
}
// ── Service card ────────────────────────────────────────────────────
function renderServiceCard(summary, available, running, webUrl) {
if (!available) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Jellyfin Service</h3>
<div class="suite-card-body">
<p class="suite-muted">
${escapeHtml(summary?.reason || webStatus?.reason || 'Jellyfin is not installed.')}
</p>
${(summary?.install || webStatus?.install)
? `<p class="suite-muted" style="margin-top:0.5rem"><code>${escapeHtml(summary?.install || webStatus?.install)}</code></p>`
: ''}
<p class="suite-muted" style="margin-top:0.5rem">
Jellyfin is GPL-2.0 licensed by the Jellyfin contributors —
<a href="https://jellyfin.org/" style="color:var(--sysdeck-accent)">https://jellyfin.org/</a>
</p>
</div>
</div>
`;
}
const uptime = formatUptime(summary?.uptime_seconds || 0);
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Service — <code>${escapeHtml(summary?.service || 'jellyfin.service')}</code></h3>
<div class="suite-row" style="gap:0.5rem">
<button class="suite-btn ${running ? '' : 'suite-btn-primary'}" id="btn-jellyfin-start" ${running ? 'disabled' : ''}>▶ Start</button>
<button class="suite-btn" id="btn-jellyfin-stop" ${!running ? 'disabled' : ''}>■ Stop</button>
<button class="suite-btn" id="btn-jellyfin-restart" ${!running ? 'disabled' : ''}>↻ Restart</button>
<button class="suite-btn suite-btn-ghost" id="btn-jellyfin-refresh">↻ Refresh</button>
</div>
</div>
<div class="suite-card-body">
<table class="suite-table">
<tbody>
<tr><th>Status</th><td>${statusBadge(summary?.status)}</td></tr>
<tr><th>ActiveState</th><td class="suite-table-mono">${escapeHtml(summary?.active || '—')}</td></tr>
<tr><th>SubState</th><td class="suite-table-mono">${escapeHtml(summary?.sub || '—')}</td></tr>
<tr><th>Uptime</th><td class="suite-muted">${running ? escapeHtml(uptime) : '—'}</td></tr>
<tr><th>Version</th><td class="suite-muted">${escapeHtml(summary?.version || '—')}</td></tr>
<tr><th>Port</th><td class="suite-table-mono">${summary?.port || 8096}</td></tr>
<tr><th>URL</th><td class="suite-table-mono"><a href="${escapeHtml(webUrl)}" target="_blank" style="color:var(--sysdeck-accent)">${escapeHtml(webUrl)}</a></td></tr>
</tbody>
</table>
<p class="suite-muted" style="font-size:0.85rem;margin-top:0.5rem">
The bridge runs <code>systemctl start/stop/restart jellyfin.service</code>
via the cockpit superuser channel (polkit <code>org.sysdeck.jellyfin.modify</code>).
</p>
</div>
</div>
`;
}
function renderWebIframe(webUrl) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Admin Panel — ${escapeHtml(webUrl)}</h3>
<a href="${escapeHtml(webUrl)}" target="_blank" class="suite-btn suite-btn-ghost">↗ Open in new tab</a>
</div>
<iframe src="${escapeHtml(webUrl)}"
style="width:100%;height:800px;border:1px solid var(--sysdeck-border);border-radius:6px;background:#1e1e1e;"
id="jellyfin-iframe"
title="Jellyfin Admin Panel"
allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
allowfullscreen></iframe>
</div>
`;
}
function renderLibrariesCard(libraries) {
const libs = libraries?.libraries || [];
if (libraries?.error) {
return `
<div class="suite-card">
<h3 class="suite-card-title">Libraries</h3>
<div class="suite-card-body">
<p class="suite-muted">${escapeHtml(libraries.error)}</p>
</div>
</div>
`;
}
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Libraries (${libraries?.count || 0})</h3>
</div>
<table class="suite-table">
<thead><tr><th>Name</th><th>Type</th><th>Paths</th></tr></thead>
<tbody>
${libs.map((lib) => `
<tr>
<td><strong>${escapeHtml(lib.name)}</strong></td>
<td class="suite-table-mono">${escapeHtml(lib.type || 'mixed')}</td>
<td class="suite-muted suite-table-mono">${escapeHtml((lib.paths || []).join(' · ') || '—')}</td>
</tr>
`).join('') || '<tr><td colspan="3" class="suite-muted">No libraries configured. Open the admin panel above to add media folders.</td></tr>'}
</tbody>
</table>
</div>
`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
const action = async (btn, method, label) => {
if (!btn) return;
btn.disabled = true;
const original = btn.textContent;
btn.textContent = `${label} ...`;
try {
const r = await bridge.jellyfin[method]();
if (!r?.success && method !== 'restart') {
alert(`${label} failed:\n${r?.stderr || r?.output || 'unknown'}`);
}
setTimeout(() => mount(panel, { bridge, EventBus }), 1000);
} catch (err) {
btn.disabled = false;
btn.textContent = original;
alert(`${label} error: ${err.message || err}`);
}
};
panel.querySelector('#btn-jellyfin-start')?.addEventListener('click', (ev) => action(ev.currentTarget, 'start', 'Start'));
panel.querySelector('#btn-jellyfin-stop')?.addEventListener('click', (ev) => action(ev.currentTarget, 'stop', 'Stop'));
panel.querySelector('#btn-jellyfin-restart')?.addEventListener('click', (ev) => action(ev.currentTarget, 'restart', 'Restart'));
panel.querySelector('#btn-jellyfin-refresh')?.addEventListener('click', () => {
mount(panel, { bridge, EventBus });
});
}
// ── Utilities ───────────────────────────────────────────────────────
async function safe(p, fallback) {
try {
const v = await p;
return v ?? fallback;
} catch {
return fallback;
}
}
function statusBadge(status) {
const map = {
'running': '<span class="suite-badge success">running</span>',
'starting': '<span class="suite-badge warn">starting</span>',
'stopped': '<span class="suite-badge">stopped</span>',
'error': '<span class="suite-badge danger">error</span>',
'unknown': '<span class="suite-badge">unknown</span>',
};
return map[status] || `<span class="suite-badge">${escapeHtml(status || 'unknown')}</span>`;
}
function formatUptime(seconds) {
if (!seconds || seconds <= 0) return '—';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m`;
return `${mins}m`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}

View File

@ -0,0 +1,30 @@
{
"version": 0,
"name": "sysdeck-jellyfin",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Jellyfin",
"order": 40,
"keywords": [
{
"matches": [
"jellyfin",
"media",
"server",
"movies",
"tv",
"music",
"streaming",
"transcoding",
"subsonic",
"emby"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval' frame-src 'self' http://127.0.0.1:8096 http://127.0.0.1:*; connect-src 'self' http://127.0.0.1:8096 http://127.0.0.1:*; img-src 'self' data: http://127.0.0.1:8096 http://127.0.0.1:*"
}

63
plugins/sysdeck-kata/index.html Executable file
View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SysDeck Kata</title>
<link rel="stylesheet" href="../sysdeck-common/sysdeck.css" />
<script src="../base1/cockpit.js"></script>
<script>
// Visible error reporting — replaces "Loading…" with the actual
// error message if anything fails. Without this, a JS error leaves
// the page stuck on "Loading…" with no clue what went wrong.
function __sysdeckShowError(title, msg, detail) {
var root = document.getElementById('root');
if (!root) return;
var html = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">' + title + '</h3>' +
'<p class="sysdeck-card-body sysdeck-mono">' + String(msg).replace(/</g, '&lt;') + '</p>';
if (detail) {
html += '<pre class="sysdeck-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px">' +
String(detail).replace(/</g, '&lt;') + '</pre>';
}
html += '<p class="sysdeck-muted">Paste this error back so we can fix it. ' +
'Also open browser devtools (F12) → Console for more detail.</p>' +
'</div>';
root.innerHTML = html;
}
window.addEventListener('error', function(ev) {
__sysdeckShowError('Page error', ev.message || 'Unknown error',
(ev.error && ev.error.stack) ? ev.error.stack :
(ev.filename ? 'at ' + ev.filename + ':' + ev.lineno + ':' + ev.colno : null));
});
window.addEventListener('unhandledrejection', function(ev) {
var r = ev.reason;
__sysdeckShowError('Unhandled promise rejection',
(r && r.message) ? r.message : String(r),
(r && r.stack) ? r.stack : null);
});
</script>
</head>
<body>
<main id="root" class="sysdeck-page">
<div class="sysdeck-loading">Loading…</div>
</main>
<script type="module">
if (!window.cockpit) {
document.getElementById('root').innerHTML = '<div class="sysdeck-card">' +
'<h3 class="sysdeck-card-title">cockpit.js not loaded</h3>' +
'<p class="sysdeck-card-body">This plugin requires <code>../base1/cockpit.js</code>.</p>' +
'</div>';
throw new Error('window.cockpit is undefined');
}
try {
const { mount } = await import("./kata.js");
const { bridge, EventBus } = await import("../sysdeck-common/bridge.js");
const root = document.getElementById('root');
await mount(root, { bridge, EventBus });
} catch (err) {
__sysdeckShowError('Module load failed', err.message || String(err),
err.stack || null);
}
</script>
</body>
</html>

423
plugins/sysdeck-kata/kata.js Executable file
View File

@ -0,0 +1,423 @@
/*
* SysDeck - Kata Panel (v0.0.43)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.43 PRODUCTION REWRITE — PREVIOUS VERSION WAS MOCK DATA.
*
* The v0.0.35-v0.0.43 Kata panel shipped a pre-built React bundle
* from the upstream cockpit-kata sub-project. That bundle displayed
* HARDCODED MOCK DATA:
* - 5 fake sandboxes (web-frontend-prod, api-gateway-staging, etc.)
* with synthetic UUIDs and createdAt:"2026-07-15..." timestamps
* - fake per-sandbox metrics (cpuUsagePercent, memoryUsageMB,
* historyCpu/historyMemory arrays)
* - a fake QCrows bundle catalog
* - a fake PXE status (always dnsmasqRunning:true)
* The only real features were the QCrows kernel-bundle extraction
* (qcrows-export / qcrows-initrd-regen via cockpit.spawn) and the
* kata-runtime check call.
*
* v0.0.43 deletes the React bundle and ships this vanilla-JS panel
* backed by bridge/kata.py. Every value displayed is REAL:
* - Sandbox list comes from kata-monitor /sandboxes + filesystem
* enumeration of /run/vc/sbs/ (Go shim) + /run/kata/ (Rust shim).
* - Per-sandbox metrics come from kata-monitor /metrics?sandbox=<id>
* (Prometheus text, parsed).
* - Runtime version comes from `kata-runtime version` + `kata-runtime
* env --json`.
* - Host capability comes from `kata-runtime check` (exit code).
* - PXE status comes from `systemctl is-active dnsmasq` + real
* filesystem probes of /srv/tftp/.
* - QCrows bundle list comes from real filesystem enumeration of
* /usr/share/sysdeck/kata/qcrows/.
*
* When no sandboxes are running, the panel shows an EMPTY STATE
* (not mock data). When kata-runtime is not installed, the panel
* shows an install hint. When kata-monitor is not running, the
* metrics card shows a hint to start it.
*
* Security hardening (v0.0.36 + v0.0.43):
* - Sandbox IDs validated with ^[0-9a-f]{64}$ in the bridge before
* any subprocess or HTTP call. CVE-2024-2947 lesson.
* - All bridge output rendered with escapeHtml() / textContent.
* CVE-2022-36446 lesson.
* - No innerHTML on bridge data.
* - HTTP to kata-monitor is 127.0.0.1-only, no redirects (SSRF
* defense). CVE-2020-35850 lesson.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
// Load summary + pxe-status + qcrows-list in parallel.
const [summary, pxeStatus, qcrowsList] = await Promise.all([
safe(bridge.kata.summary(), {
total_sandboxes: 0, running_sandboxes: 0, sandboxes: [],
kata_monitor: { running: false }, kata_runtime: { installed: false },
host_capable: false, check_message: 'unknown',
}),
safe(bridge.kata.pxeStatus(), {
dnsmasq_running: false, tftp_dir_exists: false,
tftp_dir_writable: false, pxelinux_entries: [],
}),
safe(bridge.kata.qcrowsList(), []),
]);
panel.innerHTML = `
<header>
<h2 class="suite-panel-title">SysDeck Kata</h2>
<p class="suite-panel-subtitle">
Kata Containers — hardware-virtualized OCI sandboxes
· <span class="suite-badge ${summary.host_capable ? 'success' : 'danger'}">${summary.host_capable ? 'host capable' : 'host not capable'}</span>
· ${summary.total_sandboxes} sandbox${summary.total_sandboxes === 1 ? '' : 'es'}
${summary.running_sandboxes !== summary.total_sandboxes ? ` (${summary.running_sandboxes} running)` : ''}
${summary.kata_runtime?.installed ? ` · kata-runtime ${escapeHtml(summary.kata_runtime.version || '?')}` : ' · kata-runtime not installed'}
${summary.kata_monitor?.running ? ' · kata-monitor running' : ' · kata-monitor not running'}
</p>
</header>
${renderRuntimeCard(summary)}
${renderSandboxList(summary.sandboxes, summary.kata_monitor)}
${renderPxeCard(pxeStatus)}
${renderQcrowsCard(qcrowsList)}
<div id="kata-output" class="suite-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title">Operation Output</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-output-close">✕</button>
</div>
<pre class="suite-mono" id="kata-output-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
</div>
`;
wireEvents(panel, { bridge, EventBus });
EventBus.emit('kata.loaded', {
totalSandboxes: summary.total_sandboxes,
runningSandboxes: summary.running_sandboxes,
hostCapable: summary.host_capable,
});
}
// ── Render helpers ──────────────────────────────────────────────────
function renderRuntimeCard(summary) {
const rt = summary.kata_runtime || {};
if (!rt.installed) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Runtime</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
kata-runtime is not installed. Install Kata Containers 3.x:
</p>
<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:8px;border-radius:4px;font-size:0.8rem"># Arch (AUR):
yay -S kata-runtime kata-containers-image
# Debian/Ubuntu (official repo):
sudo apt install kata-runtime kata-containers-image
# Or build from source (you mentioned compiling yesterday):
# https://github.com/kata-containers/kata-containers/blob/main/docs/install/</pre>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
After install, run <code>kata-runtime check</code> to verify
host capability (nested virt, KVM, etc.).
</p>
</div>
</div>
`;
}
const env = rt.env || {};
const host = env.Host || {};
const hypervisor = env.Hypervisor || {};
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Runtime</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-refresh">↻ Refresh</button>
</div>
<div class="suite-card-body">
<table class="suite-table">
<tbody>
<tr><td>kata-runtime version</td><td class="suite-table-mono">${escapeHtml(rt.version || '?')}</td></tr>
<tr><td>commit</td><td class="suite-table-mono suite-muted">${escapeHtml(rt.commit || '?')}</td></tr>
<tr><td>OCI specs</td><td class="suite-table-mono">${escapeHtml(rt.oci || '?')}</td></tr>
<tr><td>host capable</td><td>${summary.host_capable ? '<span class="suite-badge success">yes</span>' : '<span class="suite-badge danger">no</span>'}</td></tr>
<tr><td>check message</td><td class="suite-muted">${escapeHtml(summary.check_message || '')}</td></tr>
${host.Kernel ? `<tr><td>host kernel</td><td class="suite-table-mono">${escapeHtml(host.Kernel)}</td></tr>` : ''}
${host.Architecture ? `<tr><td>architecture</td><td class="suite-table-mono">${escapeHtml(host.Architecture)}</td></tr>` : ''}
${hypervisor.Path ? `<tr><td>hypervisor</td><td class="suite-table-mono">${escapeHtml(hypervisor.Path)}</td></tr>` : ''}
${hypervisor.MachineType ? `<tr><td>machine type</td><td class="suite-table-mono">${escapeHtml(hypervisor.MachineType)}</td></tr>` : ''}
</tbody>
</table>
</div>
</div>
`;
}
function renderSandboxList(sandboxes, kataMonitor) {
if (!sandboxes || !sandboxes.length) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Sandboxes (0)</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
No kata sandboxes running. This is the real empty state —
not mock data. Sandboxes are enumerated from:
</p>
<ul class="suite-muted" style="margin-top:0.5rem;font-size:0.85rem;padding-left:1.5rem">
<li><code>kata-monitor /sandboxes</code> (HTTP, port 8090)${kataMonitor?.running ? ' ✓ running' : ' — not running'}</li>
<li><code>/run/vc/sbs/&lt;id&gt;/</code> (Go shim filesystem)</li>
<li><code>/run/kata/&lt;id&gt;/</code> (Rust shim filesystem)</li>
</ul>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.85rem">
To create a sandbox, use <code>ctr</code>, <code>crictl</code>,
or <code>kubectl</code> with RuntimeClass <code>kata</code>.
</p>
</div>
</div>
`;
}
const rows = sandboxes.map((sb) => {
const idShort = sb.id.substring(0, 12);
const isRunning = sb.agent_url || sb.shim_socket;
return `
<tr>
<td class="suite-table-mono"><abbr title="${escapeHtml(sb.id)}">${escapeHtml(idShort)}…</abbr></td>
<td>${isRunning ? '<span class="suite-badge success">running</span>' : '<span class="suite-badge">unknown</span>'}</td>
<td class="suite-muted">${escapeHtml(sb.source || '?')}</td>
<td class="suite-table-mono suite-muted">${sb.agent_url ? escapeHtml(sb.agent_url) : '—'}</td>
<td>
<button class="suite-btn suite-btn-ghost btn-kata-inspect" data-id="${escapeHtml(sb.id)}">Inspect</button>
<button class="suite-btn suite-btn-ghost btn-kata-metrics" data-id="${escapeHtml(sb.id)}">Metrics</button>
</td>
</tr>
`;
}).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">Sandboxes (${sandboxes.length})</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-refresh">↻ Refresh</button>
</div>
<table class="suite-table">
<thead>
<tr><th>ID</th><th>Status</th><th>Source</th><th>Agent URL</th><th>Actions</th></tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
<div class="suite-card" id="kata-inspect-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title">Inspect</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-inspect-close">✕</button>
</div>
<pre class="suite-mono" id="kata-inspect-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
</div>
<div class="suite-card" id="kata-metrics-card" style="display:none">
<div class="suite-card-header">
<h3 class="suite-card-title">Metrics</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-metrics-close">✕</button>
</div>
<pre class="suite-mono" id="kata-metrics-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
</div>
`;
}
function renderPxeCard(pxe) {
const dnsmasqBadge = pxe.dnsmasq_running
? '<span class="suite-badge success">running</span>'
: '<span class="suite-badge danger">not running</span>';
const tftpExistsBadge = pxe.tftp_dir_exists
? '<span class="suite-badge success">exists</span>'
: '<span class="suite-badge danger">missing</span>';
const tftpWritableBadge = pxe.tftp_dir_writable
? '<span class="suite-badge success">writable</span>'
: '<span class="suite-badge">not writable</span>';
const entries = (pxe.pxelinux_entries || []).length
? pxe.pxelinux_entries.map((e) => `<span class="suite-badge info" style="margin-right:0.25rem">${escapeHtml(e)}</span>`).join('')
: '<span class="suite-muted">(no entries)</span>';
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">PXE / TFTP Boot</h3>
<button class="suite-btn suite-btn-ghost" id="btn-kata-pxe-refresh">↻ Refresh</button>
</div>
<div class="suite-card-body">
<table class="suite-table">
<tbody>
<tr><td>dnsmasq</td><td>${dnsmasqBadge}</td></tr>
<tr><td>/srv/tftp</td><td>${tftpExistsBadge} ${tftpWritableBadge}</td></tr>
<tr><td>pxelinux.cfg/ entries</td><td>${entries}</td></tr>
</tbody>
</table>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
PXE boot configuration for network-booting kata sandboxes.
dnsmasq serves DHCP + TFTP; pxelinux.cfg/ holds per-host
boot configs (named by MAC address or "default").
</p>
</div>
</div>
`;
}
function renderQcrowsCard(qcrows) {
if (!qcrows || !qcrows.length) {
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">QCrows Kernel Bundles (0)</h3>
</div>
<div class="suite-card-body">
<p class="suite-muted">
No QCrows kernel bundles found at
<code>/usr/share/sysdeck/kata/qcrows/</code>.
This is the real empty state — not mock data.
</p>
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.85rem">
QCrows bundles are pre-built kata kernel + initrd +
rootfs images. Build one with:
</p>
<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:8px;border-radius:4px;font-size:0.8rem">qcrows-export --kernel /path/to/vmlinuz --initrd /path/to/initrd \\
--rootfs /path/to/rootfs --name alpine-3.20-kata</pre>
</div>
</div>
`;
}
const totalSize = qcrows.reduce((sum, q) => sum + (q.size_bytes || 0), 0);
const totalMb = (totalSize / (1024 * 1024)).toFixed(1);
const rows = qcrows.map((q) => `
<tr>
<td class="suite-table-mono">${escapeHtml(q.filename)}</td>
<td class="suite-muted">${q.size_mb} MB</td>
<td class="suite-table-mono suite-muted">${new Date(q.mtime * 1000).toISOString().split('T')[0]}</td>
</tr>
`).join('');
return `
<div class="suite-card">
<div class="suite-card-header">
<h3 class="suite-card-title">QCrows Kernel Bundles (${qcrows.length}, ${totalMb} MB total)</h3>
</div>
<table class="suite-table">
<thead><tr><th>Filename</th><th>Size</th><th>Modified</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
}
// ── Event wiring ────────────────────────────────────────────────────
function wireEvents(panel, { bridge, EventBus }) {
const output = (msg, isError = false) => {
const card = panel.querySelector('#kata-output');
const pre = panel.querySelector('#kata-output-pre');
if (!card || !pre) return;
card.style.display = 'block';
pre.textContent = msg;
pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)';
};
panel.querySelector('#btn-kata-output-close')?.addEventListener('click', () => {
const card = panel.querySelector('#kata-output');
if (card) card.style.display = 'none';
});
// Refresh buttons (multiple — runtime card + sandbox card).
panel.querySelectorAll('#btn-kata-refresh, #btn-kata-pxe-refresh').forEach((btn) => {
btn.addEventListener('click', () => mount(panel, { bridge, EventBus }));
});
// Inspect buttons.
panel.querySelectorAll('.btn-kata-inspect').forEach((btn) => {
btn.addEventListener('click', async () => {
const sid = btn.dataset.id;
const card = panel.querySelector('#kata-inspect-card');
const pre = panel.querySelector('#kata-inspect-pre');
if (!card || !pre) return;
card.style.display = 'block';
pre.textContent = `Inspecting ${sid.substring(0, 12)}…`;
try {
const r = await bridge.kata.inspect(sid);
pre.textContent = JSON.stringify(r, null, 2);
} catch (err) {
pre.textContent = `Inspect error: ${err.message || err}`;
}
});
});
panel.querySelector('#btn-kata-inspect-close')?.addEventListener('click', () => {
const card = panel.querySelector('#kata-inspect-card');
if (card) card.style.display = 'none';
});
// Metrics buttons.
panel.querySelectorAll('.btn-kata-metrics').forEach((btn) => {
btn.addEventListener('click', async () => {
const sid = btn.dataset.id;
const card = panel.querySelector('#kata-metrics-card');
const pre = panel.querySelector('#kata-metrics-pre');
if (!card || !pre) return;
card.style.display = 'block';
pre.textContent = `Fetching metrics for ${sid.substring(0, 12)}…`;
try {
const r = await bridge.kata.metrics(sid);
if (r.error) {
pre.textContent = `Metrics error: ${r.error}`;
} else if (r.parsed && r.summary) {
const s = r.summary;
pre.textContent = [
`Sandbox: ${r.id}`,
`CPU: ${s.cpu_usage_percent ?? 'n/a'}%`,
`Memory: ${s.memory_usage_bytes != null ? (s.memory_usage_bytes / 1048576).toFixed(1) + ' MB' : 'n/a'}`,
`Network RX: ${s.network_rx_bytes ?? 'n/a'} bytes`,
`Network TX: ${s.network_tx_bytes ?? 'n/a'} bytes`,
`Uptime: ${s.uptime_seconds ?? 'n/a'} s`,
'',
'--- Raw metric families ---',
JSON.stringify(r.families, null, 2),
].join('\n');
} else {
pre.textContent = r.raw || '(no metrics — kata-monitor not running or sandbox not found)';
}
} catch (err) {
pre.textContent = `Metrics error: ${err.message || err}`;
}
});
});
panel.querySelector('#btn-kata-metrics-close')?.addEventListener('click', () => {
const card = panel.querySelector('#kata-metrics-card');
if (card) card.style.display = 'none';
});
}
// ── Utilities ───────────────────────────────────────────────────────
async function safe(p, fallback) {
try {
const v = await p;
return v ?? fallback;
} catch {
return fallback;
}
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderSkeleton() {
return `<div class="suite-skeleton">
<div class="suite-skeleton-line w-1/3"></div>
<div class="suite-skeleton-line w-2/3"></div>
<div class="suite-skeleton-line w-1/2"></div>
</div>`;
}

View File

@ -0,0 +1,36 @@
{
"version": 0,
"name": "sysdeck-kata",
"requires": {
"cockpit": "239"
},
"menu": {
"index": {
"label": "Kata",
"order": 27,
"keywords": [
{
"matches": [
"kata",
"containers",
"sandbox",
"vm",
"isolation",
"kata-runtime",
"kata-containers",
"kata-monitor",
"microvm",
"hardware-virtualization",
"qcrows",
"pxe",
"tftp",
"cloud-hypervisor",
"firecracker",
"qemu"
]
}
]
}
},
"content-security-policy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'"
}

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