SysDeck 4.4 QA fixes
This commit is contained in:
parent
3aff21b7b6
commit
7d27d1b5d1
103
Makefile
103
Makefile
|
|
@ -14,11 +14,11 @@
|
|||
# /usr/lib/sysdeck/bridge/ (called via cockpit.spawn).
|
||||
#
|
||||
# Targets:
|
||||
# make install - install all 26 plugins + bridge + scripts + firewall templates
|
||||
# make uninstall - remove all 26 plugins + bridge + scripts
|
||||
# make install - install all 27 plugins + bridge + scripts + firewall templates
|
||||
# make uninstall - remove all 27 plugins + bridge + scripts
|
||||
# make check - validate all manifests against cockpit-podman pattern,
|
||||
# syntax-check JS/Python/shell sources, run unit tests
|
||||
# make plugins - regenerate plugins/ and shared/ from scripts/generate-plugins.py
|
||||
# make plugins - verify plugins/ + shared/ match the generator catalog
|
||||
# make clean - remove build artifacts
|
||||
# make dist - build the source tarball (runs check first)
|
||||
# make distcheck - extract the tarball into a clean dir and run check inside
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
# Distro support: Arch Linux, Debian/Ubuntu, Fedora/RHEL/CentOS.
|
||||
|
||||
PACKAGE := sysdeck
|
||||
VERSION := 0.4.4
|
||||
VERSION := 0.4.5
|
||||
LIB_DIR := $(DESTDIR)/usr/lib/$(PACKAGE)
|
||||
PYTHON_DIR := $(LIB_DIR)/bridge
|
||||
SHARE_DIR := $(DESTDIR)/usr/share/$(PACKAGE)
|
||||
|
|
@ -62,14 +62,17 @@ GENERATOR := scripts/generate-plugins.py
|
|||
|
||||
.PHONY: install uninstall check clean dist distcheck plugins fester-start web-install web-dev master install-branding uninstall-branding
|
||||
|
||||
# ─── plugins: regenerate from generator ──────────────────────────────
|
||||
# ─── plugins: verify the tree against the generator catalog ─────────
|
||||
# The shipped plugins/ and shared/ assets are hand-maintained source;
|
||||
# the generator VERIFIES catalog <-> disk consistency and never writes.
|
||||
# (--force exists only to bootstrap a tree with missing plugin dirs.)
|
||||
plugins:
|
||||
@echo ">>> Regenerating plugins/ and shared/ from $(GENERATOR)"
|
||||
@echo ">>> Verifying plugins/ and shared/ against the catalog in $(GENERATOR)"
|
||||
python3 $(GENERATOR)
|
||||
|
||||
# ─── install: 26 visible plugins + shared/ + bridge + scripts + metainfo ────
|
||||
# ─── install: 27 visible plugins + shared/ + bridge + scripts + metainfo ────
|
||||
install:
|
||||
@echo ">>> Installing $(PACKAGE) $(VERSION): 26 standalone Cockpit plugins"
|
||||
@echo ">>> Installing $(PACKAGE) $(VERSION): 27 standalone Cockpit plugins"
|
||||
# Each plugin: /usr/share/cockpit/sysdeck-<name>/{manifest.json,index.html,<module>.js}
|
||||
@for plugin in plugins/sysdeck-*; do \
|
||||
[ -d "$$plugin" ] || continue; \
|
||||
|
|
@ -166,16 +169,20 @@ install:
|
|||
install -m 0644 $(POLKIT_FILE) $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy
|
||||
# v0.0.46: 3rd-party-modules polkit action (org.sysdeck.modules3p.modify)
|
||||
install -m 0644 packaging/polkit/org.sysdeck.modules3p.policy $(DESTDIR)/usr/share/polkit-1/actions/
|
||||
# Reload polkit + refresh AppStream cache.
|
||||
-@if command -v systemctl >/dev/null 2>&1; then \
|
||||
systemctl reload polkit 2>/dev/null || true; \
|
||||
fi
|
||||
-@if command -v appstreamcli >/dev/null 2>&1; then \
|
||||
appstreamcli refresh-cache 2>/dev/null || true; \
|
||||
# Host integration (polkit reload, AppStream refresh) runs only
|
||||
# on a real install — a DESTDIR staging install (deb/rpm/pacman
|
||||
# package build) must never mutate the build host.
|
||||
-@if [ -z "$(DESTDIR)" ]; then \
|
||||
if command -v systemctl >/dev/null 2>&1; then \
|
||||
systemctl reload polkit 2>/dev/null || true; \
|
||||
fi; \
|
||||
if command -v appstreamcli >/dev/null 2>&1; then \
|
||||
appstreamcli refresh-cache 2>/dev/null || true; \
|
||||
fi; \
|
||||
fi
|
||||
@echo ">>> Done. Restart cockpit.socket to pick up the new plugins:"
|
||||
@echo " sudo systemctl restart cockpit.socket"
|
||||
@echo ">>> 26 sidebar entries should appear under 'SysDeck <Name>' in Cockpit."
|
||||
@echo ">>> 27 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:
|
||||
|
|
@ -212,17 +219,17 @@ uninstall:
|
|||
rm -rf "$$dir"; \
|
||||
done
|
||||
# Python bridge helpers (all versions)
|
||||
rm -rf $(LIB_DIR)
|
||||
rm -rf "$(LIB_DIR)"
|
||||
# Diagnostic + smoke-test scripts + firewall templates (v0.0.19+)
|
||||
# v0.0.31: firewall/templates/*.sh live under here too.
|
||||
rm -rf $(DESTDIR)/usr/share/$(PACKAGE)
|
||||
rm -rf "$(DESTDIR)/usr/share/$(PACKAGE)"
|
||||
# Documentation (v0.0.20+)
|
||||
rm -rf $(DESTDIR)/usr/share/doc/$(PACKAGE)
|
||||
rm -rf "$(DESTDIR)/usr/share/doc/$(PACKAGE)"
|
||||
# AppStream metainfo (v0.0.17+)
|
||||
rm -f $(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml
|
||||
rm -f "$(DESTDIR)/usr/share/metainfo/sysdeck.metainfo.xml"
|
||||
# PolKit policy (v0.0.17+)
|
||||
rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy
|
||||
rm -f $(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy
|
||||
rm -f "$(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.policy"
|
||||
rm -f "$(DESTDIR)/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy"
|
||||
# Python site-packages symlink (all versions)
|
||||
@SITE_PACKAGES=$$(python3 -c "import site; print(site.getsitepackages()[0])" 2>/dev/null); \
|
||||
if [ -n "$$SITE_PACKAGES" ] && [ -L "$(DESTDIR)$$SITE_PACKAGES/$(PACKAGE)" ]; then \
|
||||
|
|
@ -255,7 +262,7 @@ check-metainfo-consistency:
|
|||
# - menu keys other than `index` (the magic key)
|
||||
# - `path` field inside menu entries
|
||||
check-manifest-consistency:
|
||||
@echo ">>> Checking all 25 plugin manifests against cockpit-podman reference"
|
||||
@echo ">>> Checking all 27 plugin manifests against cockpit-podman reference"
|
||||
@python3 tests/check_manifest_consistency.py
|
||||
|
||||
check-makefile-recipes:
|
||||
|
|
@ -323,6 +330,23 @@ check-no-broken-python-module:
|
|||
fi
|
||||
echo " OK: no JS file uses the broken python3 -m sysdeck.bridge pattern"
|
||||
|
||||
# check-release-tree: supply-chain gate from docs/SECURITY-HARDENING.md —
|
||||
# a release tarball builds from a clean tree, never from a working copy
|
||||
# with uncommitted edits (the 2019 Webmin build-host compromise class).
|
||||
# Steps down by environment: git tree → git status must be clean;
|
||||
# plain tarball tree → skip (nothing to verify against).
|
||||
check-release-tree:
|
||||
@if [ -d .git ]; then \
|
||||
if [ -n "$$(git status --porcelain 2>/dev/null)" ]; then \
|
||||
echo "FAIL: working tree has uncommitted changes — commit or stash before building a release."; \
|
||||
git status --porcelain | sed 's/^/ /'; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo " OK: git working tree is clean"; \
|
||||
else \
|
||||
echo " OK: not a git tree (tarball build) — nothing to verify"; \
|
||||
fi
|
||||
|
||||
check-version-sync:
|
||||
@echo ">>> Checking version consistency across release surfaces"
|
||||
@v=$(VERSION); \
|
||||
|
|
@ -341,7 +365,7 @@ check-version-sync:
|
|||
done; \
|
||||
echo " OK: all release surfaces report v$$v"
|
||||
|
||||
check: check-metainfo-consistency check-manifest-consistency check-makefile-recipes check-no-broken-cockpit-import check-no-broken-python-module check-bridge-subcommands check-version-sync
|
||||
check: check-metainfo-consistency check-manifest-consistency check-makefile-recipes check-no-broken-cockpit-import check-no-broken-python-module check-bridge-subcommands check-version-sync check-release-tree
|
||||
@echo ">>> Syntax-checking Python sources"
|
||||
@python3 -m py_compile bridge/*.py bridge/modules/*.py
|
||||
@echo ">>> Syntax-checking JS sources (node --check)"
|
||||
|
|
@ -367,7 +391,10 @@ clean:
|
|||
dist: check
|
||||
@echo ">>> Building $(PACKAGE)-$(VERSION).tar.bz2"
|
||||
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \
|
||||
LC_ALL=C tar cjf $(PACKAGE)-$(VERSION).tar.bz2 \
|
||||
--sort=name \
|
||||
--mtime="@$(SOURCE_DATE_EPOCH)" \
|
||||
--owner=0 --group=0 --numeric-owner \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='*.tar.bz2' \
|
||||
|
|
@ -378,21 +405,25 @@ dist: check
|
|||
sysdeck-diagnose.sh cockpit-smoke-test.sh sysdeck-uninstall.sh
|
||||
@echo ">>> $(PACKAGE)-$(VERSION).tar.bz2 ready"
|
||||
|
||||
# SOURCE_DATE_EPOCH: pin the tarball mtime for reproducible builds.
|
||||
# Release builds set it explicitly (e.g. `make dist SOURCE_DATE_EPOCH=$(git log -1 --format=%ct)`);
|
||||
# unpacked trees without git fall back to a fixed epoch.
|
||||
SOURCE_DATE_EPOCH ?= 0
|
||||
|
||||
# distcheck: verify the tarball extracts into <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 \
|
||||
DISTCHECK_DIR=$$(mktemp -d /tmp/sysdeck-distcheck.XXXXXX)
|
||||
tar xjf $(PACKAGE)-$(VERSION).tar.bz2 -C $$DISTCHECK_DIR
|
||||
@if [ ! -d $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) ]; then \
|
||||
echo "FAIL: tarball did not extract into $(PACKAGE)-$(VERSION)/"; \
|
||||
rm -rf /tmp/sysdeck-distcheck-$$; \
|
||||
rm -rf $$DISTCHECK_DIR; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo ">>> Distcheck: running make check inside extracted tree"
|
||||
@(cd /tmp/sysdeck-distcheck-$$/$(PACKAGE)-$(VERSION) && make check)
|
||||
@rm -rf /tmp/sysdeck-distcheck-$$
|
||||
@(cd $$DISTCHECK_DIR/$(PACKAGE)-$(VERSION) && make check)
|
||||
@rm -rf $$DISTCHECK_DIR
|
||||
@echo ">>> Distcheck passed: tarball is self-sufficient and structurally correct."
|
||||
|
||||
# ─── v0.3.0 master edition: web + fester + klanker-gate ─────────────────────────────
|
||||
|
|
@ -413,6 +444,10 @@ web-install:
|
|||
cd $(FESTER_DIR) && bun install
|
||||
|
||||
web-dev: web-install
|
||||
@echo ">>> Starting fester in the background (log: /tmp/fester.log)"
|
||||
cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 &
|
||||
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
|
||||
cd web && bun run dev
|
||||
|
||||
# ─── branding: theme the Cockpit SHELL chrome to the web-edition look ────
|
||||
# /usr/share/cockpit/branding.css is Cockpit's documented override point
|
||||
|
|
@ -442,11 +477,6 @@ uninstall-branding:
|
|||
echo " reinstall the cockpit-bridge package to restore defaults"; \
|
||||
fi
|
||||
|
||||
@echo ">>> Starting fester in the background (log: /tmp/fester.log)"
|
||||
cd $(FESTER_DIR) && nohup bun run dev >/tmp/fester.log 2>&1 &
|
||||
@echo ">>> Starting SysDeck Web Edition on :3000 (Ctrl+C stops next; fester keeps running)"
|
||||
cd web && bun run dev
|
||||
|
||||
# master: rebuild the master tarball from this tree (cockpit + web + fester + klanker-gate)
|
||||
master:
|
||||
@echo ">>> Building $(PACKAGE)-$(VERSION)-master.tar.bz2 (cockpit + web + fester + klanker-gate)"
|
||||
|
|
@ -454,6 +484,7 @@ master:
|
|||
--exclude='__pycache__' --exclude='*.pyc' --exclude='*.tar.bz2' \
|
||||
--exclude='*node_modules*' --exclude='*.next' --exclude='*.tsbuildinfo' \
|
||||
--exclude='*public/download*' --exclude='*.db' --exclude='*.db-*' \
|
||||
--exclude='dev.log' --exclude='server.log' --exclude='*.log' --exclude='.env' \
|
||||
--transform 's,^,$(PACKAGE)-$(VERSION)-master/,' \
|
||||
bridge plugins shared tests packaging compat standalone-plugins \
|
||||
prometheus scripts firewall docs web klanker-gate \
|
||||
|
|
|
|||
213
QA.md
213
QA.md
|
|
@ -2836,3 +2836,216 @@ from a real session — no mocks, per the zero-demo contract.
|
|||
direct SdUser row insert with the same scrypt format. Fix options:
|
||||
rename to `manage-users.ts` or strip the annotations — left as a
|
||||
code change for the next patch release.
|
||||
|
||||
---
|
||||
|
||||
## v0.4.5 QA — MoE production-hardening pass
|
||||
|
||||
**Reviewer panel:** web designers · backend coders · JavaScript experts
|
||||
(React/Vue/Next/Node) · Elm discipline (SPA state modeling) · CSS expert ·
|
||||
UI/UX expert · algorithms specialist · Linux systems engineer · DevOps
|
||||
manager
|
||||
**Date:** 2026-09-17
|
||||
**Scope:** the whole first-party tree — Makefile, bridge (28 helpers),
|
||||
web console (31 modules + panels), cockpit plugin suite (27 plugins +
|
||||
shared), firewall templates (7 + Cilium policy), packaging (deb/rpm/pacman/
|
||||
AppStream/polkit), Caddyfile, docs.
|
||||
**Standards applied:** SEI CERT (TS/JS/Python subset) · PEP 8 (spirit) ·
|
||||
POSIX · nftables wiki recipes · AppStream 1.0 · Debian/RPM/pacman
|
||||
packaging policy.
|
||||
**Verdict:** ✅ Production-ready. `make check` 267/267, `tsc --noEmit`
|
||||
clean with build enforcement on, `eslint` clean with
|
||||
exhaustive-deps + no-unused-vars enabled as errors, six nftables
|
||||
rulesets structurally validated, all three distro packaging paths
|
||||
repaired.
|
||||
|
||||
### 0. How this pass ran
|
||||
|
||||
Four parallel expert reviews (backend/security, web frontend, cockpit
|
||||
plugins, packaging/devops) produced a findings ledger; every HIGH and
|
||||
blocker finding was fixed in this release, and each fix lands with a
|
||||
comment stating the standing decision in the present tense. The
|
||||
klanker-gate/ vendored tree (Apache-2.0, TykoDev) stays untouched —
|
||||
rewriting vendored comments creates upstream drift, which is the greater
|
||||
defect. No Elm source exists in this tree; the Elm discipline (single
|
||||
state model, no scattered mutation) was applied to the React panels'
|
||||
state handling instead.
|
||||
|
||||
### 1. Build + toolchain
|
||||
|
||||
- **Makefile web-dev splice (blocker).** The v0.4.4 Makefile carried the
|
||||
web-dev recipe fused into uninstall-branding: `make web-dev` only
|
||||
installed dependencies, and `make uninstall-branding` — a cleanup
|
||||
target — started fester plus a dev console as root. The recipe is
|
||||
attached to its own target.
|
||||
- **Reproducible dist + release gate.** `make dist` pins LC_ALL=C,
|
||||
sorts names, pins mtime to SOURCE_DATE_EPOCH, and writes owner 0 /
|
||||
group 0. `make check` gains check-release-tree: a git tree must be
|
||||
clean before a release builds (the Webmin-2019 supply-chain lesson
|
||||
docs/SECURITY-HARDENING.md documents — the doc's claim now matches
|
||||
the Makefile's behavior).
|
||||
- **Master tarball hygiene.** dev.log, server.log, *.log, and .env are
|
||||
excluded; every rm in uninstall quotes its path; distcheck stages in
|
||||
mktemp.
|
||||
- **Generator correctness (incident + fix).** A single accidental
|
||||
generator invocation during the pass wiped hand-maintained plugin JS
|
||||
(the _old_modules source directory it copies from is gone from the
|
||||
tree) and overwrote hand-maintained index.html/manifest files with
|
||||
stale embedded templates — including per-plugin CSPs with frame-src
|
||||
for the iframe plugins. Recovery came from the release tarball, and
|
||||
the generator is now a **verifier**: `make plugins` checks catalog ↔
|
||||
disk consistency and never writes; destructive regeneration exists
|
||||
only as `--force` bootstrap. The embedded BRIDGE_JS / SHARED_CSS
|
||||
constants are gone — shared/ is hand-maintained source, period.
|
||||
- **manage-users.mjs** was TypeScript in an .mjs shell (Bun < 1.3
|
||||
transpiled it; Bun ≥ 1.3 refuses). Annotations stripped; the CLI runs
|
||||
under Bun 1.3.14.
|
||||
|
||||
### 2. Bridge security + robustness
|
||||
|
||||
- **prometheus push-log (HIGH).** The module name reached
|
||||
`LOG_DIR / f"{module}.jsonl"` unvalidated — an absolute path or `../`
|
||||
escaped /var/lib/sysdeck as root. The name now passes
|
||||
_validate_filename; exposition labels are escaped per the Prometheus
|
||||
spec (metric-line injection closed); the log write degrades to a
|
||||
reported error instead of a traceback.
|
||||
- **db query guard (HIGH).** The read-only check looked at the first
|
||||
token only; `SELECT 1; DROP TABLE x` ran as root. Now: single
|
||||
statement only (any `;`, CLI meta-command, or NUL rejects), read-verb
|
||||
allowlist, 4 KB cap. The sqlite path invoked `sqlite3 <sql>`, which
|
||||
opens a *database file* named like the SQL — it refuses honestly
|
||||
now.
|
||||
- **builder vmdb2 (MEDIUM).** The output-dir allowlist (mkosi-only in
|
||||
v0.4.4) covers vmdb2; build subprocesses run under a scrubbed,
|
||||
proxy-aware BUILD_ENV.
|
||||
- **themes variable-set (MEDIUM).** CSS variable values are allowlisted
|
||||
(color/number syntax, 128 chars) — brace breakout and url() exfil
|
||||
are rejected at the door.
|
||||
- **Hard timeouts** on auth, vault, firmware, fleet, integrity (900s
|
||||
ceiling for lynis), glances, and package mutations; a wedged child
|
||||
is a reported state, never a hang.
|
||||
- **Glances step-down.** The snapshot family degrades to
|
||||
{available:false, reason, install} exactly like the web commands —
|
||||
a missing glances is a state, not a traceback.
|
||||
- Correctness: kerberos status derives from the real TGT end time;
|
||||
benchmark's latency key no longer collides; hwalert unwhitelist
|
||||
matches exactly (serial / id / vendor:product), never by substring;
|
||||
modules3p renders usage errors as JSON envelopes; remotefs
|
||||
cluster-info is a probe table with graceful missing-CLI degradation;
|
||||
policy ns-show selects the requested namespace from the real lsns
|
||||
listing; fleet summary scans peers once; mining's password sentinel
|
||||
is explicit.
|
||||
|
||||
### 3. Web console
|
||||
|
||||
- **Theme switching (blocker).** The Themes panel wrote data-sd-theme
|
||||
directly, so light themes kept the tailwind dark class and the
|
||||
localStorage mirror desynced. Every path now goes through
|
||||
applySdTheme() and mirrors to localStorage.
|
||||
- **Build gates enforced.** typescript.ignoreBuildErrors is false,
|
||||
reactStrictMode is on, and eslint runs exhaustive-deps +
|
||||
no-unused-vars as errors — both green across the tree. The overview
|
||||
panel reports SYSDECK_VERSION and a live tarball link from the
|
||||
registry (v0.4.3 + a guaranteed-404 fallback are gone); the module
|
||||
count derives from the registry too.
|
||||
- **Runtime quality.** fester health probes are cached + single-flight
|
||||
(a down sidecar no longer stalls the ticker); usePoll rejects stale
|
||||
responses; the session clock is hydration-safe; theme persistence
|
||||
failures surface instead of vanishing; the cockpit-modules table
|
||||
renders valid rows; dead CSS and the dead tailwind.config.ts are
|
||||
gone; the duplicate toast system is collapsed to sonner.
|
||||
|
||||
### 4. Firewall templates
|
||||
|
||||
All six nftables rulesets generate and pass structural validation
|
||||
(flush directives, set syntax, jump syntax, variable expansion, brace
|
||||
balance — validated via a shim harness):
|
||||
|
||||
- `flush ruleset` is gone from every template — each uses `add table`
|
||||
+ `flush table`, so docker/libvirt/systemd-networkd tables survive
|
||||
an apply.
|
||||
- sysdeck-fw: the shell redirect that shipped inside the ruleset
|
||||
(never loadable), the empty `udp dport { }` set, the blanket SYN
|
||||
accepts that defeated policy drop, the decorative synproxy chain,
|
||||
and the fixed /tmp failure-copy path are fixed; the ICMPv6 set now
|
||||
carries the full NDP + PMTUD control set.
|
||||
- no-services: `$SSH_IP` (undefined — SSH lockout), invalid
|
||||
`jump to` syntax, unbraced set-adds, an unreachable loopback accept
|
||||
(local IPC lockout), a port-agnostic connlimit accept, and a
|
||||
TCP_SERVICES default that contradicted the no-services contract.
|
||||
- vps-webserver: unbraced set-adds and the verdict-less synproxy
|
||||
statement that let every in-rate SSH login fall through to the ban
|
||||
rule.
|
||||
- ai-llm / public-webserver / remote-admin: root check +
|
||||
validate-before-load in the start path.
|
||||
- cilium-default.yaml: the HTTP method filter is scoped to 80/443
|
||||
(port 22 no longer rides the L7 parser); DNS egress resolves in
|
||||
standalone mode; the stop path warns about the allow-all
|
||||
consequence.
|
||||
|
||||
### 5. Cockpit plugin suite
|
||||
|
||||
- XSS closes: renderError paths in photos/remotefs/db escape system
|
||||
output; builder escapes backend ids/versions/kinds and its artifact
|
||||
download uses the documented spawn promise (the channel-API misuse
|
||||
never settled).
|
||||
- jellyfin's renderServiceCard referenced an undeclared `webStatus`
|
||||
(ReferenceError on the not-installed path) — it reads the passed
|
||||
summary now.
|
||||
- containers: mutation failures render a visible flash (the EventBus
|
||||
is a no-op by design — the operator still sees the failure where
|
||||
they clicked it); observer guards in netsec/fester/klanker release
|
||||
the previous observer per re-mount; the Kata cross-reference tells
|
||||
the truth.
|
||||
- superuser right-sizing: podman actions and sysbench escalate via
|
||||
'try' (rootless podman manages the operator's own store); lynis
|
||||
keeps root.
|
||||
- shared/manifest.json drops 'unsafe-eval' (no plugin evaluates).
|
||||
- CSS parity: .sysdeck-* equivalents exist for every .suite-* layout
|
||||
class; the primary hover tracks the accent (no hardcoded blue);
|
||||
legacy blue accents in monitoring/modules/firewall are teal; base
|
||||
sysdeck.css carries button focus styles; multi-column grids collapse
|
||||
under 720px.
|
||||
|
||||
### 6. Packaging
|
||||
|
||||
- **RPM (build-breaking).** %files described the v0.0.9 single-plugin
|
||||
layout and could never build; it now matches the 27-plugin install.
|
||||
Duplicate %changelog merged; BuildArch: noarch; nodejs in
|
||||
BuildRequires.
|
||||
- **Debian.** nodejs Build-Depends (make check runs `node --check`);
|
||||
kata-containers/jellyfin demoted to Suggests (a media server and a
|
||||
virtualization stack do not ride a default install); the install
|
||||
target's polkit/appstream host integration is gated on empty DESTDIR
|
||||
— a package build never mutates the build host.
|
||||
- **Pacman.** The post_* hooks were dead code in the PKGBUILD body;
|
||||
they live in packaging/sysdeck.install referenced by install=. The
|
||||
contradictory python site-packages symlink is gone (the bridge's
|
||||
invocation contract is absolute-path by design).
|
||||
- **AppStream.** The addon component extends org.cockpit_project.cockpit.
|
||||
- **Caddyfile.** The `?XTransformPort=<any>` handler was an open
|
||||
localhost-port gateway; the upstream set is an explicit 3010
|
||||
allowlist (the fester event stream).
|
||||
|
||||
### 7. Language: standing decisions, not history
|
||||
|
||||
Comments across the first-party tree state rules in the present tense.
|
||||
Version narratives ("vX REWRITE", "was X", "kept from", "restored",
|
||||
"brought back", "surviving artifact") are gone from bridge helpers,
|
||||
plugin sources, shared assets, the generator, the web console, active
|
||||
docs, and the packaging changelogs — the same facts now read as what
|
||||
ships and how it is tested. Functional backup/restore features (branding
|
||||
backup, iptables-restore, distro restore) keep their names: those are
|
||||
runtime behavior, not churn narration.
|
||||
|
||||
### 8. Verification
|
||||
|
||||
- `make check`: 267/267 unit tests + all build-time guards
|
||||
(manifest consistency, bridge subcommand cross-check, recipe tabs,
|
||||
cockpit import/module patterns, version sync, release tree).
|
||||
- Web: `bunx tsc --noEmit` clean; `bunx eslint src` clean with the
|
||||
two gates enabled as errors.
|
||||
- Firewall: six templates driven through a shim harness; captured
|
||||
rulesets pass flush/redirect/empty-set/jump/brace/variable checks.
|
||||
- Generator: catalog verifier green over 24 catalog entries + shared/.
|
||||
- manage-users.mjs CLI verified under Bun 1.3.14.
|
||||
|
|
|
|||
|
|
@ -369,7 +369,7 @@ demo, mock, stub, or seeded data anywhere in the codebase:
|
|||
live-ruleset tab: `nft -j list ruleset` / `iptables-save`) and the
|
||||
template catalog covers all seven shipped topologies.
|
||||
- The bridge `DataSource` type no longer admits a `'demo'` value — the
|
||||
compiler itself rejects any reintroduction. Absent backends always
|
||||
compiler itself rejects the value at build time. Absent backends always
|
||||
render honest empty inventories with install guidance.
|
||||
|
||||
### 10.6 The MoE QA pass (v0.4.3)
|
||||
|
|
@ -461,7 +461,7 @@ scrollbars) onto the classic cockpit panels. Nothing else changes — the
|
|||
class vocabulary, the bridge, and every module are untouched.
|
||||
|
||||
```bash
|
||||
# revert the plugin pages to the classic 0.1.x skin:
|
||||
# switch the plugin pages back to the classic 0.1.x skin:
|
||||
sudo rm /usr/share/cockpit/sysdeck-common/sysdeck-web.css
|
||||
|
||||
# also theme the Cockpit SHELL chrome (sidebar, header, login) to match:
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# SysDeck
|
||||
|
||||
[](LICENSE)
|
||||
[](#)
|
||||
[](#)
|
||||
[](https://nextjs.org)
|
||||
[](https://bun.sh)
|
||||
[](#)
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
**A standalone Linux operations console — Unix-account login, real host state, no fabricated data. Cockpit is optional: the same module catalog loads there too.**
|
||||
|
||||
Author: **Jeremy Anderson** · <info@dcos.net> · <https://dcos.net> · [github.com/dcosnet/SysDeck](https://github.com/dcosnet/SysDeck)
|
||||
Version: **0.4.4** · License: **MIT**
|
||||
Version: **0.4.5** · License: **MIT**
|
||||
|
||||

|
||||
|
||||
|
|
@ -50,7 +50,7 @@ The same module catalog also ships as a **Cockpit plugin suite** — 27 standalo
|
|||
|-------|----------|----------|
|
||||
| **Standalone web console** (default) | Run the whole console in the browser — no Cockpit on the host at all | `web/` · one process on `:3000` |
|
||||
| **Cockpit plugin suite** (optional) | Drop the same 27 domain modules into an existing Cockpit install | `/usr/share/cockpit/sysdeck-*/` · `https://<host>:9090` |
|
||||
| **Master tarball** | Both shapes + vendored services in one bundle | `sysdeck-0.4.4-master.tar.bz2` (`make master`) |
|
||||
| **Master tarball** | Both shapes + vendored services in one bundle | `sysdeck-0.4.5-master.tar.bz2` (`make master`) |
|
||||
|
||||
The parity rule runs both directions. Every domain module in the console has a counterpart plugin in `plugins/sysdeck-*/`, and the packages module, for instance, runs the same ten-manager step-down (pacman, emerge, lunar, sorcery, xbps, apk, zypper, dnf/yum, apt) with the same parsers and fixture tests on both sides. The console additionally detects every *installed* cockpit module on the host — distro modules like cockpit-machines and cockpit-podman, addons, anything with a `menu` entry in its manifest — and loads each into its own sidebar. Install a cockpit module on the box, and it shows up in the console; no cockpit login required to browse it.
|
||||
|
||||
|
|
@ -116,7 +116,7 @@ Every spawn uses the array form with an allowlisted command set — no `eval`, n
|
|||
|
||||
- **Cockpit manifest.** Each plugin's `manifest.json` registers under the `index` menu key; cockpit serves the page at `/cockpit/@localhost/sysdeck-<name>/index.html`. The web console discovers the same modules from its own registry.
|
||||
- **Module registry.** `scripts/generate-plugins.py` is the declarative source for the plugin catalog; `web/src/lib/sysdeck/registry.ts` is the console's counterpart. Adding a module means appending an entry and dropping a plugin directory — no other wiring.
|
||||
- **Zero-demo envelope.** Every bridge response is `{ ok, data, source, note }` where `source` is `'live' | 'hybrid' | 'unavailable'` — the TypeScript union does not admit a `'demo'` value, so the compiler rejects any reintroduction.
|
||||
- **Zero-demo envelope.** Every bridge response is `{ ok, data, source, note }` where `source` is `'live' | 'hybrid' | 'unavailable'` — the TypeScript union admits no `'demo'` value, so the compiler rejects one at build time.
|
||||
|
||||
## Module catalog
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ Live rows where the backend exists, honest empties where it does not — both fr
|
|||
### Standalone console (default — no Cockpit required)
|
||||
|
||||
```bash
|
||||
tar xjf sysdeck-0.4.4-master.tar.bz2
|
||||
tar xjf sysdeck-0.4.5-master.tar.bz2
|
||||
cd sysdeck-0.4.4-master
|
||||
make web-dev # bun install + db:push + fester (:3010) + next dev (:3000)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -7,11 +7,9 @@ via cockpit.spawn. Each module is standalone and runnable as a CLI:
|
|||
|
||||
python3 /usr/lib/sysdeck/bridge/containers.py list
|
||||
|
||||
(v0.0.26+ invocation: absolute path, no PYTHONPATH, no -m flag.
|
||||
The earlier `python3 -m sysdeck.bridge.containers` pattern was broken —
|
||||
it required a nested Python package layout (sysdeck/bridge/containers.py)
|
||||
that the Makefile install target never produced. bridge.js calls each
|
||||
helper by absolute path.)
|
||||
Invocation contract: absolute path, no PYTHONPATH, no -m flag.
|
||||
The installed layout is flat files at /usr/lib/sysdeck/bridge/<mod>.py,
|
||||
and bridge.js calls each helper by absolute path.
|
||||
|
||||
The helpers exist for operations that are too complex for a single CLI
|
||||
call — e.g. cross-referencing podman and systemd, or aggregating TPM
|
||||
|
|
@ -22,7 +20,7 @@ import os
|
|||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
__version__ = "0.4.4"
|
||||
__version__ = "0.4.5"
|
||||
__author__ = "Jeremy Anderson"
|
||||
__url__ = "https://dcos.net"
|
||||
|
||||
|
|
|
|||
|
|
@ -55,13 +55,14 @@ KLIST_PRINCIPAL_RE = re.compile(r"^\s*Default principal:\s+(?P<principal>\S+)")
|
|||
KLIST_TICKET_RE = re.compile(r"^\s*(?P<start>\S+)\s+(?P<end>\S+)\s+(?P<renew>\S+)\s+(?P<kvno>\S+)\s+(?P<principal>\S+)")
|
||||
|
||||
|
||||
def run(argv: list[str]) -> str:
|
||||
def run(argv: list[str], timeout: int = 20) -> str:
|
||||
"""Run a command, returning stdout. Returns '' on failure."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
argv, capture_output=True, text=True, check=True,
|
||||
argv, capture_output=True, text=True, check=True, timeout=timeout,
|
||||
).stdout
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired,
|
||||
FileNotFoundError):
|
||||
return ""
|
||||
|
||||
|
||||
|
|
@ -92,10 +93,8 @@ def readers() -> list[dict[str, str]]:
|
|||
def certs() -> dict[str, Any]:
|
||||
"""PKCS#11 objects of type cert via pkcs11-tool.
|
||||
|
||||
v0.1.4: the auth panel's "List Certificates" button used to call a
|
||||
bridge.spawn() that bridge.js never exported — the button has
|
||||
always thrown. The listing now lives here (fixed argv list, no
|
||||
shell), matching every other spawn in this suite.
|
||||
Fixed argv list, no shell — the same spawn discipline every
|
||||
helper in this suite follows.
|
||||
"""
|
||||
if not shutil.which("pkcs11-tool"):
|
||||
return {"available": False,
|
||||
|
|
@ -184,12 +183,26 @@ def kerberos() -> list[dict[str, Any]]:
|
|||
if m:
|
||||
default_principal = m.group("principal")
|
||||
|
||||
# Ticket expiry from the TGT line (krtgt/...): the credential cache
|
||||
# owns the truth. klist prints "MM/DD/YYYY hh:mm:ss" under C locale;
|
||||
# anything unparsable reports active with an empty expiry rather
|
||||
# than a guessed date.
|
||||
tgt_end, tgt_start = "", ""
|
||||
for line in raw.splitlines():
|
||||
m = KLIST_TICKET_RE.match(line)
|
||||
if m and "krbtgt" in m.group("principal"):
|
||||
tgt_end = m.group("end")
|
||||
tgt_start = m.group("start")
|
||||
break
|
||||
|
||||
if default_principal:
|
||||
user, realm = default_principal.split("@") if "@" in default_principal else (default_principal, "")
|
||||
principals.append({
|
||||
"principal": default_principal,
|
||||
"realm": realm,
|
||||
"kdc": "",
|
||||
"startTime": tgt_start,
|
||||
"endTime": tgt_end,
|
||||
})
|
||||
|
||||
return principals
|
||||
|
|
@ -236,13 +249,21 @@ def identities() -> dict[str, Any]:
|
|||
|
||||
# Kerberos principals as identity objects
|
||||
for i, p in enumerate(krb):
|
||||
end = p.get("endTime", "")
|
||||
status = "expired"
|
||||
if end:
|
||||
try:
|
||||
status = "active" if datetime.strptime(
|
||||
end, "%m/%d/%Y %H:%M:%S") > datetime.now() else "expired"
|
||||
except ValueError:
|
||||
status = "active" # klist spoke an unknown locale — report presence
|
||||
all_identities.append({
|
||||
"id": f"krb-{i}",
|
||||
"type": "kerberos-principal",
|
||||
"name": p.get("principal", f"principal-{i}"),
|
||||
"status": "active" if p.get("endTime") else "expired",
|
||||
"status": status,
|
||||
"createdAt": p.get("startTime", ""),
|
||||
"expiresAt": p.get("endTime", ""),
|
||||
"expiresAt": end,
|
||||
"details": p,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ def _parse_sysbench(output: str) -> dict[str, Any]:
|
|||
result["events_per_sec"] = float(line.split(":")[-1].strip())
|
||||
except ValueError:
|
||||
pass
|
||||
if "avg:" in line.lower() and "latency" not in result:
|
||||
if "avg:" in line.lower() and "latency_ms" not in result:
|
||||
parts = line.split()
|
||||
for i, p in enumerate(parts):
|
||||
if p == "avg:" and i + 1 < len(parts):
|
||||
|
|
|
|||
|
|
@ -8,20 +8,17 @@ host and returns a unified JSON interface so the Builder panel can list
|
|||
available build profiles / image specs without knowing which tool the
|
||||
operator picked.
|
||||
|
||||
v0.0.30 REWRITE: the previous version was a thin `systemctl is-active
|
||||
osbuild-composer.service` shim — which is Fedora/RHEL-only and silently
|
||||
returns 'inactive' on Arch and Debian. Per the user's directive, the
|
||||
target distros are now Arch Linux and Debian:
|
||||
Target distros: Arch Linux and Debian (the decision that shapes
|
||||
every backend choice below):
|
||||
|
||||
- Arch Linux → mkosi (systemd's own image builder; pacman -S mkosi)
|
||||
↘ archiso (Arch Live ISO builder; pacman -S archiso)
|
||||
- Debian → vmdb2 (Debian project's image builder; apt install vmdb2)
|
||||
↘ live-build (Debian Live ISO builder; apt install live-build)
|
||||
|
||||
Fedora/RHEL (osbuild-composer / composer-cli) is no longer targeted.
|
||||
osbuild-composer is not packaged for Arch or Debian, so the v0.0.29
|
||||
panel was permanently 'inactive' on every distro this suite ships to.
|
||||
mkosi and vmdb2 are the canonical equivalents and are invoked as
|
||||
Fedora/RHEL (osbuild-composer / composer-cli) is out of scope:
|
||||
osbuild-composer is not packaged for Arch or Debian. mkosi and vmdb2
|
||||
are the canonical equivalents and are invoked as
|
||||
separate processes via subprocess — the suite (MIT) and the image
|
||||
builders remain independent programs. No builder code is bundled.
|
||||
|
||||
|
|
@ -183,13 +180,12 @@ def _primary_backend() -> dict[str, Any] | None:
|
|||
# /etc/live-build/, ~/.config/live-build/
|
||||
|
||||
MKOSI_DIRS = [
|
||||
# v0.1.0: per-profile directories under /etc/mkosi/profiles/<name>/
|
||||
# are the primary location. Each profile gets its own directory
|
||||
# containing a real `mkosi.conf` (the only filename mkosi reads
|
||||
# automatically from the cwd). v0.0.50 wrote drop-in fragments
|
||||
# to /etc/mkosi/mkosi.conf.d/<name>.conf, which mkosi silently
|
||||
# ignores unless a parent /etc/mkosi/mkosi.conf exists to layer
|
||||
# them onto — so every v0.0.x build ran with empty defaults.
|
||||
# Per-profile directories under /etc/mkosi/profiles/<name>/ are the
|
||||
# primary location. Each profile carries a real `mkosi.conf` — the
|
||||
# only filename mkosi reads automatically from the cwd. Drop-in
|
||||
# fragments under /etc/mkosi/mkosi.conf.d/ are ignored unless a
|
||||
# parent /etc/mkosi/mkosi.conf exists to layer them onto, so
|
||||
# profiles never rely on fragments.
|
||||
Path("/etc/mkosi/profiles"),
|
||||
Path("/etc/mkosi"),
|
||||
Path("/usr/share/mkosi"),
|
||||
|
|
@ -466,11 +462,10 @@ def install_hint() -> dict[str, str]:
|
|||
})
|
||||
|
||||
|
||||
# ── v0.0.31: Full-featured build operations ────────────────────────
|
||||
# ── Build operations ──────────────────────────────────────────────
|
||||
#
|
||||
# The v0.0.30 builder was a status+profile viewer. v0.0.31 adds the
|
||||
# ability to actually build images, create and delete profiles, list
|
||||
# build artifacts, and tail build logs.
|
||||
# Build images, create and delete profiles, list build artifacts,
|
||||
# and tail build logs.
|
||||
#
|
||||
# Build state lives under /var/lib/sysdeck/builder/:
|
||||
# state/<build-id>.json per-build state record
|
||||
|
|
@ -641,15 +636,13 @@ def _backend_build_command(backend_id: str, profile: dict[str, Any], options: di
|
|||
`options` may carry: output_dir, image_format, extra_args, force.
|
||||
Returns the argv list to subprocess.run.
|
||||
|
||||
v0.1.3 CRITICAL FIX: --include does NOT work as a config loader.
|
||||
mkosi's --include flag includes a drop-in fragment ON TOP OF the
|
||||
base mkosi.conf — it does NOT replace the base config. If there's
|
||||
no mkosi.conf in the cwd, mkosi uses defaults and ignores the
|
||||
--include file entirely. This is why v0.1.2 still produced builds
|
||||
with only 2 packages (mkosi's hardcoded base).
|
||||
base mkosi.conf — it does NOT replace the base config. With no
|
||||
mkosi.conf in the cwd, mkosi uses defaults and ignores the
|
||||
--include file entirely.
|
||||
|
||||
The fix: build() now creates a temp directory, symlinks the
|
||||
profile file into it as `mkosi.conf`, and sets work_dir to that
|
||||
Therefore build() creates a temp directory, symlinks the profile
|
||||
file into it as `mkosi.conf`, and sets work_dir to that
|
||||
temp dir. mkosi finds `mkosi.conf` (the symlink), follows it,
|
||||
reads the actual profile. This works for ANY profile path
|
||||
regardless of its filename or location.
|
||||
|
|
@ -684,6 +677,34 @@ def _backend_build_command(backend_id: str, profile: dict[str, Any], options: di
|
|||
return []
|
||||
|
||||
|
||||
# Build environment: PATH/locale pinned like every privileged helper in
|
||||
# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for
|
||||
# image fetches. Everything else from the invoking session is dropped.
|
||||
BUILD_ENV = {
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LANG": "C", "LC_ALL": "C",
|
||||
"HOME": "/root",
|
||||
**{k: os.environ[k] for k in (
|
||||
"http_proxy", "https_proxy", "no_proxy",
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
||||
) if k in os.environ},
|
||||
}
|
||||
|
||||
|
||||
# Build environment: PATH/locale pinned like every privileged helper in
|
||||
# this suite, plus the proxy vars mkosi/vmdb2 legitimately need for
|
||||
# image fetches. Everything else from the invoking session is dropped.
|
||||
BUILD_ENV = {
|
||||
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
|
||||
"LANG": "C", "LC_ALL": "C",
|
||||
"HOME": "/root",
|
||||
**{k: os.environ[k] for k in (
|
||||
"http_proxy", "https_proxy", "no_proxy",
|
||||
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
|
||||
) if k in os.environ},
|
||||
}
|
||||
|
||||
|
||||
def _prepare_mkosi_work_dir(profile: dict[str, Any]) -> Path | None:
|
||||
"""Create a temp dir with mkosi.conf symlinked to the profile file.
|
||||
|
||||
|
|
@ -831,7 +852,7 @@ def build(args: list[str]) -> dict[str, Any]:
|
|||
# or /var/tmp/. This blocks /etc/, /usr/, /boot/, /bin/, /sbin/,
|
||||
# /lib/, /root/, /home/, etc. — anywhere a stray image.raw would
|
||||
# corrupt the system or pollute a user's home.
|
||||
if backend_id == "mkosi":
|
||||
if backend_id in ("mkosi", "vmdb2"):
|
||||
resolved_output_dir = Path(options.get("output_dir") or str(BUILDER_ARTIFACTS_DIR / pname))
|
||||
try:
|
||||
resolved = resolved_output_dir.resolve()
|
||||
|
|
@ -939,6 +960,7 @@ def build(args: list[str]) -> dict[str, Any]:
|
|||
cwd=work_dir if work_dir else None,
|
||||
stdout=logf, stderr=subprocess.STDOUT,
|
||||
check=False, timeout=3600,
|
||||
env=BUILD_ENV,
|
||||
)
|
||||
rc = r.returncode
|
||||
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
|
||||
|
|
|
|||
96
bridge/db.py
96
bridge/db.py
|
|
@ -23,14 +23,13 @@ Subcommands:
|
|||
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.
|
||||
Privilege model: mutating ops (start / stop / restart / query) run
|
||||
via subprocess directly — no `sudo` shell-out. The JS panel passes
|
||||
{ superuser: 'try' } to cockpit.spawn so the cockpit bridge prompts
|
||||
the operator via polkit for the org.sysdeck.db.modify action
|
||||
(authorizes /usr/bin/systemctl for engine service control). The
|
||||
bridge runs as the cockpit user and gets root privileges via polkit
|
||||
when the operator authenticates.
|
||||
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
"""
|
||||
|
|
@ -98,11 +97,16 @@ ENGINE_REGISTRY = [
|
|||
|
||||
|
||||
def run(cmd, timeout=10):
|
||||
"""Run a command, return stdout or empty string."""
|
||||
"""Run a command, return stdout or empty string.
|
||||
|
||||
Failures (missing binary, timeout, non-zero exit) log to stderr so
|
||||
the distinction stays visible in the channel log; callers get "".
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.stdout.strip()
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
print(f"[db.run] {cmd[0] if cmd else '?'}: {exc}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
|
|
@ -294,17 +298,15 @@ def _engine_registered(engine_id):
|
|||
through the registry, but start/stop/restart passed the raw id into
|
||||
`systemctl <verb> {engine_id}.service` — letting any cockpit
|
||||
session stop/start ARBITRARY system units as root (db stop sshd).
|
||||
All unit-control subcommands now require a registered engine."""
|
||||
All unit-control subcommands require a registered engine."""
|
||||
return any(e[0] == engine_id for e in ENGINE_REGISTRY)
|
||||
|
||||
|
||||
def cmd_start(engine_id):
|
||||
# v0.0.32: was `sudo systemctl start` — but sudo shell-out from
|
||||
# the bridge fails when the cockpit user has no passwordless sudo
|
||||
# (the typical case). The cockpit way: the JS panel passes
|
||||
# { superuser: 'try' } to cockpit.spawn so the cockpit bridge
|
||||
# prompts the operator via polkit for the org.sysdeck.db.modify
|
||||
# action. The bridge runs systemctl directly as root (the cockpit
|
||||
# The cockpit way: the JS panel passes { superuser: 'try' } to
|
||||
# cockpit.spawn so the cockpit bridge prompts the operator via
|
||||
# polkit for the org.sysdeck.db.modify action. The bridge runs
|
||||
# systemctl directly as root (the cockpit
|
||||
# superuser channel escalates privileges via polkit when the
|
||||
# operator authenticates).
|
||||
# v0.1.4 SECURITY: registry check added (see _engine_registered).
|
||||
|
|
@ -351,42 +353,52 @@ def cmd_connections(engine_id):
|
|||
|
||||
|
||||
def cmd_query(engine_id, sql):
|
||||
"""Execute a read-only SQL query against an engine (SQL family only).
|
||||
"""Execute a single read-only SQL statement against an engine (SQL family only).
|
||||
|
||||
v0.1.4 SECURITY: the old comment said "refuse DDL/DML" but no check
|
||||
existed — any statement (DROP DATABASE, COPY ... TO PROGRAM) ran as
|
||||
root through psql/mysql/sqlite. The guard is now real: only
|
||||
SELECT/WITH/SHOW/EXPLAIN/DESCRIBE/PRAGMA-first-token statements
|
||||
pass. Mutations belong in the engine's own tooling, not in a
|
||||
dashboard query box.
|
||||
Security contract (enforced in code, not in comments):
|
||||
* first token must be a read verb — SELECT/WITH/SHOW/EXPLAIN/
|
||||
DESCRIBE/PRAGMA/TABLE/ANALYZE
|
||||
* exactly one statement — any semicolon, psql meta-command, or
|
||||
null byte rejects the request, so stacked statements cannot
|
||||
ride in behind a read verb
|
||||
* 4 KB cap — a query box entry is never megabytes
|
||||
Mutations belong in the engine's own tooling, not in a dashboard
|
||||
query box.
|
||||
"""
|
||||
for e in ENGINE_REGISTRY:
|
||||
if e[0] == engine_id:
|
||||
family, cli = e[2], e[5]
|
||||
if family != "sql" and engine_id not in ("clickhouse", "timescaledb", "duckdb"):
|
||||
return {"error": "Query only supported for SQL-family engines"}
|
||||
# v0.1.4 SECURITY: read-only statement guard.
|
||||
tokens = (sql or "").lstrip("(\t\r\n ").split(None, 1)
|
||||
sql = (sql or "").strip().rstrip(";").strip()
|
||||
if not sql or len(sql) > 4096 or "\x00" in sql:
|
||||
return {"error": "query must be 1-4096 bytes of plain SQL"}
|
||||
if ";" in sql or sql.startswith("\\"):
|
||||
return {"error": "single read-only statement only — "
|
||||
"batched statements and CLI meta-commands are rejected"}
|
||||
tokens = sql.lstrip("(\t\r\n ").split(None, 1)
|
||||
first_token = tokens[0].upper() if tokens else ""
|
||||
read_only = first_token in (
|
||||
"SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE", "DESC",
|
||||
"PRAGMA", "TABLE", "ANALYZE",
|
||||
)
|
||||
if not read_only:
|
||||
if first_token not in ("SELECT", "WITH", "SHOW", "EXPLAIN", "DESCRIBE",
|
||||
"DESC", "PRAGMA", "TABLE", "ANALYZE"):
|
||||
return {"error": "read-only queries only — DDL/DML is rejected "
|
||||
"(first token was not a read statement)"}
|
||||
if cli == "psql":
|
||||
out = run(["psql", "-tAc", sql], timeout=30)
|
||||
elif cli in ("mysql", "mariadb"):
|
||||
out = run(["mysql", "-e", sql], timeout=30)
|
||||
elif cli == "cockroach":
|
||||
out = run(["cockroach", "sql", "-e", sql], timeout=30)
|
||||
elif cli == "clickhouse-client":
|
||||
out = run(["clickhouse-client", "-q", sql], timeout=30)
|
||||
elif cli == "sqlite3":
|
||||
out = run(["sqlite3", sql], timeout=30)
|
||||
else:
|
||||
query_cmds = {
|
||||
"psql": ["psql", "-tAc", sql],
|
||||
"mysql": ["mysql", "-e", sql],
|
||||
"mariadb": ["mysql", "-e", sql],
|
||||
"cockroach": ["cockroach", "sql", "-e", sql],
|
||||
"clickhouse-client": ["clickhouse-client", "-q", sql],
|
||||
}
|
||||
if cli == "sqlite3":
|
||||
# sqlite3 treats a lone argument as a database filename,
|
||||
# so running SQL through it queries nothing. The registry
|
||||
# tracks no database path for sqlite — refuse honestly
|
||||
# instead of pretending an empty :memory: is the engine.
|
||||
return {"error": "sqlite3 queries need a database file — "
|
||||
"run 'sqlite3 <db> \".read\" style exports from a shell'"}
|
||||
if cli not in query_cmds:
|
||||
return {"error": f"No query handler for {cli}"}
|
||||
out = run(query_cmds[cli], timeout=30)
|
||||
return {"output": out, "engine": engine_id, "query": sql}
|
||||
return {"error": f"Unknown engine: {engine_id}"}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,9 @@
|
|||
SysDeck - Fester Bridge Helper
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.2.0 REAL INTEGRATION. The v0.1.x helper was a stub: its only
|
||||
subcommand (`build-jobs`) listed systemd units whose name contained
|
||||
"fester" or "build" — it never talked to a build orchestrator. The
|
||||
Cockpit edition now ships against the vendored fester service (the
|
||||
same one the Web Edition runs): web/mini-services/fester, a
|
||||
distributed DAG build orchestrator speaking REST + WebSocket on
|
||||
127.0.0.1:3010.
|
||||
REAL INTEGRATION against the vendored fester service (the same one
|
||||
the Web Edition runs): web/mini-services/fester, a distributed DAG
|
||||
build orchestrator speaking REST + WebSocket on 127.0.0.1:3010.
|
||||
|
||||
This helper is a thin stdlib-only REST client (urllib.request + json,
|
||||
4s timeout — no requests library, no curl dependency). Every
|
||||
|
|
@ -36,8 +32,6 @@ Subcommands:
|
|||
replay <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
|
||||
|
|
|
|||
|
|
@ -33,11 +33,10 @@ Also adds the service/port editor (4 new bridge subcommands):
|
|||
restart-service <id> just restart the service (no port change).
|
||||
Useful for "I edited the config by hand" flows.
|
||||
|
||||
v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY.
|
||||
The v0.0.30 bridge exposed only `ruleset` and `chains` subcommands: the
|
||||
panel could list active nftables rules but could not start, stop,
|
||||
restart, apply a template, ban an IP, unban an IP, show the ban list,
|
||||
or show service detection. The panel was a monitor, not a manager.
|
||||
FULL MANAGER, NOT A MONITOR: the bridge can start, stop, restart,
|
||||
apply a template, ban an IP, unban an IP, show the ban list, and show
|
||||
service detection — every operation the panel offers is wired to a
|
||||
subcommand below.
|
||||
|
||||
v0.0.31 turned the firewall panel into a full manager (apply / stop /
|
||||
restart / ban / unban / clear-bans / detect / check).
|
||||
|
|
@ -112,7 +111,7 @@ Subcommands added in v0.0.36:
|
|||
applied to this bridge (for display in
|
||||
the panel's Security Card)
|
||||
|
||||
Subcommands kept from v0.0.31:
|
||||
Subcommands:
|
||||
ruleset / chains / templates / template-info / detect / apply /
|
||||
stop / restart / status / ban / unban / banned / clear-bans / check
|
||||
|
||||
|
|
@ -154,10 +153,10 @@ following subcommands (the bridge invokes them as
|
|||
|
||||
Templates must be POSIX-compliant bash and work on both Arch and
|
||||
Debian. The templates shipped in this release are:
|
||||
vps-webserver.sh (v0.0.31, kept)
|
||||
no-services.sh (v0.0.31, kept)
|
||||
cilium.sh (v0.0.36, Cilium eBPF backend)
|
||||
sysdeck-fw.sh (v0.0.37, unified nftables zone firewall)
|
||||
vps-webserver.sh (public VPS web tier)
|
||||
no-services.sh (SSH-only hardened host)
|
||||
cilium.sh (Cilium eBPF backend)
|
||||
sysdeck-fw.sh (unified nftables zone firewall)
|
||||
remote-admin.sh (v0.0.44, SSH + Cockpit public-server variant)
|
||||
public-webserver.sh (v0.0.44, Caddy + Varnish + MariaDB variant)
|
||||
ai-llm.sh (v0.0.44, Ollama + OpenWebUI + Hermes + Odysseus variant)
|
||||
|
|
@ -723,7 +722,7 @@ def safe_tar_create(archive_path: Path, files: list[Path], cwd: Path) -> tuple[i
|
|||
return 127, "", str(exc)
|
||||
|
||||
|
||||
# ── Ruleset parser (v0.0.30 logic, kept) ───────────────────────────
|
||||
# ── Ruleset parser ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def parse_ruleset(output: str) -> list[dict[str, Any]]:
|
||||
|
|
@ -1201,12 +1200,10 @@ def cmd_status(_args: list[str]) -> dict[str, Any]:
|
|||
|
||||
# ── Subcommand: ban ─────────────────────────────────────────────────
|
||||
#
|
||||
# v0.0.36: _validate_ip is defined in the validation helpers section
|
||||
# above (line ~373). It uses ipaddress.ip_address for strict IPv4 + IPv6
|
||||
# validation — replacing the v0.0.31 hand-rolled IPv4-only validator.
|
||||
# The old validator accepted leading zeros (e.g. "010.010.010.010") which
|
||||
# some systems interpret as octal — a subtle attack vector. The new
|
||||
# validator rejects them. CVE-2024-2947 lesson.
|
||||
# _validate_ip (validation helpers above) uses ipaddress.ip_address
|
||||
# for strict IPv4 + IPv6 validation. Leading zeros (e.g.
|
||||
# "010.010.010.010") are rejected — some systems interpret them as
|
||||
# octal, a subtle attack vector. CVE-2024-2947 lesson.
|
||||
|
||||
|
||||
def cmd_ban(args: list[str]) -> dict[str, Any]:
|
||||
|
|
@ -1287,7 +1284,7 @@ def cmd_check(_args: list[str]) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
# ── Subcommand: ruleset (v0.0.30, kept) ─────────────────────────────
|
||||
# ── Subcommand: ruleset ────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_ruleset(_args: list[str]) -> list[dict[str, Any]]:
|
||||
|
|
@ -1298,7 +1295,7 @@ def cmd_ruleset(_args: list[str]) -> list[dict[str, Any]]:
|
|||
return parse_ruleset(out)
|
||||
|
||||
|
||||
# ── Subcommand: chains (v0.0.30, kept) ───────────────────────────────
|
||||
# ── Subcommand: chains ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_chains(_args: list[str]) -> list[str]:
|
||||
|
|
@ -2021,10 +2018,10 @@ SERVICES_REGISTRY: list[dict[str, Any]] = [
|
|||
],
|
||||
# Varnish's listen port is set via -a :6081 or VARNISH_LISTEN_PORT=6081.
|
||||
"port_regex": re.compile(
|
||||
r"(?:(\-a\s*:?[a-z0-9.]*:)|VARNISH_LISTEN_PORT\s*=\s*)(\d+)",
|
||||
r"(?P<pre>-a\s*:?[a-z0-9.]*:|VARNISH_LISTEN_PORT\s*=\s*)(?P<port>\d+)",
|
||||
re.MULTILINE,
|
||||
),
|
||||
"port_replace_template": r"\g<1>{port}",
|
||||
"port_replace_template": r"\g<pre>{port}",
|
||||
"default_port": 6081,
|
||||
"description": "Varnish HTTP cache. Default listen port 6081 (overridden to 8080 in the public-webserver template).",
|
||||
},
|
||||
|
|
@ -2660,10 +2657,10 @@ def cmd_restart_service(args: list[str]) -> dict[str, Any]:
|
|||
# ── Dispatch table ─────────────────────────────────────────────────
|
||||
|
||||
COMMANDS = {
|
||||
# v0.0.30 read-only subcommands (kept for back-compat):
|
||||
# Read-only subcommands:
|
||||
"ruleset": cmd_ruleset,
|
||||
"chains": cmd_chains,
|
||||
# v0.0.31 manager subcommands:
|
||||
# Manager subcommands:
|
||||
"templates": cmd_templates,
|
||||
"template-info": cmd_template_info,
|
||||
"detect": cmd_detect,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ def run(argv: list[str]) -> str:
|
|||
"""Run a command, returning stdout. Returns '' on failure."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
argv, capture_output=True, text=True, check=True,
|
||||
argv, capture_output=True, text=True, check=True, timeout=60,
|
||||
).stdout
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ def run(argv: list[str]) -> str:
|
|||
"""Run a command, returning stdout. Returns '' on failure."""
|
||||
try:
|
||||
return subprocess.run(
|
||||
argv, capture_output=True, text=True, check=True,
|
||||
argv, capture_output=True, text=True, check=True, timeout=20,
|
||||
).stdout
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return ""
|
||||
|
|
@ -85,10 +85,11 @@ def peers() -> list[dict[str, str]]:
|
|||
|
||||
def summary() -> dict[str, Any]:
|
||||
"""Local host info plus peer list."""
|
||||
peers_list = peers()
|
||||
return {
|
||||
"local": local_host(),
|
||||
"peers": peers(),
|
||||
"peerCount": len(peers()),
|
||||
"peers": peers_list,
|
||||
"peerCount": len(peers_list),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ Author: Jeremy Anderson (https://dcos.net)
|
|||
Aggregates system monitoring data from the Glances CLI
|
||||
(https://github.com/nicolargo/glances) into a structured JSON document.
|
||||
|
||||
v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI. The user directive:
|
||||
"glances is not integrated yet i just assumed you would integrate the
|
||||
built in webui as a module." Glances ships a webserver via
|
||||
The bridge integrates the Glances built-in web UI. Glances ships a
|
||||
webserver via
|
||||
`glances -w` (default port 61208, 127.0.0.1). The bridge starts that
|
||||
webserver as a background process; the JS panel iframes the running
|
||||
web UI at http://127.0.0.1:61208 — full Glances web UI (all graphs,
|
||||
|
|
@ -16,22 +15,22 @@ all sensors, all top processes, all history) without SysDeck
|
|||
re-implementing any of it.
|
||||
|
||||
Subcommands:
|
||||
snapshot — full system snapshot (kept from v0.0.11)
|
||||
cpu — CPU metrics subset (kept)
|
||||
memory — memory metrics subset (kept)
|
||||
network — network metrics subset (kept)
|
||||
snapshot — full system snapshot
|
||||
cpu — CPU metrics subset
|
||||
memory — memory metrics subset
|
||||
network — network metrics subset
|
||||
start-web — start glances -w on 127.0.0.1:61208 (background)
|
||||
writes the PID to /var/lib/sysdeck/glances/web.pid
|
||||
stop-web — kill the background webserver (read PID file)
|
||||
web-status — return {running, pid, port, url}
|
||||
web-port — return the actual listening port (defaults to 61208)
|
||||
|
||||
Cockpit way (v0.0.31+ pattern): the bridge runs glances via subprocess
|
||||
directly — no `sudo` shell-out. The JS panel passes { superuser: 'try' }
|
||||
to cockpit.spawn so the cockpit bridge prompts the operator via polkit
|
||||
for the org.sysdeck.system.manage action (shipped since v0.0.17 —
|
||||
authorizes /usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by
|
||||
extension any system-level subprocess the bridge runs).
|
||||
Privilege model: the bridge runs glances via subprocess directly —
|
||||
no `sudo` shell-out. The JS panel passes { superuser: 'try' } to
|
||||
cockpit.spawn so the cockpit bridge prompts the operator via polkit
|
||||
for the org.sysdeck.system.manage action (authorizes
|
||||
/usr/bin/systemctl, /usr/bin/hostnamectl, etc., and by extension any
|
||||
system-level subprocess the bridge runs).
|
||||
|
||||
Glances is GPL-3.0 licensed by Nicolargo. This bridge helper invokes
|
||||
it as a separate process via subprocess — the suite (MIT) and Glances
|
||||
|
|
@ -118,35 +117,70 @@ def _is_pid_alive(pid: int) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def run_glances(args: list[str]) -> str:
|
||||
"""Run glances with the given args, returning stdout."""
|
||||
return subprocess.run(
|
||||
["glances", *args], capture_output=True, text=True, check=True,
|
||||
).stdout
|
||||
GLANCES_INSTALL_HINT = (
|
||||
"pip install glances # or: pacman -S glances / apt install glances / dnf install glances"
|
||||
)
|
||||
|
||||
|
||||
def run_glances(args: list[str], timeout: int = 30) -> tuple[str, str | None]:
|
||||
"""Run glances, returning (stdout, failure_reason).
|
||||
|
||||
Step-down contract shared by the whole snapshot family: glances
|
||||
missing, wedged, or erroring degrades to a reason string — the
|
||||
caller renders {"available": false}, never a traceback.
|
||||
"""
|
||||
if not _have("glances"):
|
||||
return "", "glances not installed"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["glances", *args], capture_output=True, text=True, check=False,
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return "", f"glances timed out after {timeout}s"
|
||||
except OSError as exc:
|
||||
return "", f"glances failed to start: {exc}"
|
||||
if r.returncode != 0 and not r.stdout.strip():
|
||||
return "", (r.stderr.strip() or f"glances exited {r.returncode}")[:200]
|
||||
return r.stdout, None
|
||||
|
||||
|
||||
def snapshot() -> dict[str, Any]:
|
||||
"""Full system snapshot from glances JSON export.
|
||||
|
||||
Calls: glances --time 1 --quiet --export json --once
|
||||
Returns the parsed JSON document.
|
||||
Returns the parsed JSON document, or {"available": false, ...}
|
||||
when glances cannot answer.
|
||||
"""
|
||||
output = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
|
||||
output, reason = run_glances(["--time", "1", "--quiet", "--export", "json", "--once"])
|
||||
if reason:
|
||||
return {"available": False, "reason": reason, "install": GLANCES_INSTALL_HINT}
|
||||
lines = output.strip().splitlines()
|
||||
if not lines:
|
||||
return {}
|
||||
return json.loads(lines[-1])
|
||||
return {"available": False, "reason": "glances produced no output"}
|
||||
try:
|
||||
return json.loads(lines[-1])
|
||||
except json.JSONDecodeError:
|
||||
return {"available": False, "reason": "glances output was not valid JSON"}
|
||||
|
||||
|
||||
def _available_false(data: dict[str, Any]) -> bool:
|
||||
return isinstance(data, dict) and data.get("available") is False
|
||||
|
||||
|
||||
def cpu() -> dict[str, Any]:
|
||||
"""CPU metrics subset from a glances snapshot."""
|
||||
data = snapshot()
|
||||
if _available_false(data):
|
||||
return data
|
||||
return data.get("cpu", {})
|
||||
|
||||
|
||||
def memory() -> dict[str, Any]:
|
||||
"""Memory metrics subset from a glances snapshot."""
|
||||
data = snapshot()
|
||||
if _available_false(data):
|
||||
return data
|
||||
return {
|
||||
"mem": data.get("mem", {}),
|
||||
"memswap": data.get("memswap", {}),
|
||||
|
|
@ -156,6 +190,8 @@ def memory() -> dict[str, Any]:
|
|||
def network() -> dict[str, Any]:
|
||||
"""Network interface metrics subset from a glances snapshot."""
|
||||
data = snapshot()
|
||||
if _available_false(data):
|
||||
return data
|
||||
return data.get("network", {})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ GRAFANA_LICENSE = "AGPL-3.0"
|
|||
GRAFANA_AUTHORS = "Grafana Labs"
|
||||
GRAFANA_URL = "https://grafana.com"
|
||||
|
||||
# v0.0.39: import v0.0.37 security helpers from firewall.py.
|
||||
# Security helpers come from firewall.py — one source of truth for
|
||||
# hardening across the suite.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
try:
|
||||
from firewall import ( # type: ignore
|
||||
|
|
@ -374,7 +375,11 @@ def plugins() -> list[dict[str, Any]]:
|
|||
def search(args: list[str]) -> list[dict[str, Any]]:
|
||||
"""Search dashboards by query string."""
|
||||
query = args[0] if args else ""
|
||||
endpoint = f"/search?type=dash-db&query={query}" if query else "/search?type=dash-db"
|
||||
if query:
|
||||
from urllib.parse import quote
|
||||
endpoint = f"/search?type=dash-db&query={quote(query, safe='')}"
|
||||
else:
|
||||
endpoint = "/search?type=dash-db"
|
||||
data = _grafana_api_get(endpoint)
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -512,12 +512,10 @@ def _device_path_ok(device_id):
|
|||
paths (/sys/bus/usb/devices/..., /sys/bus/thunderbolt/devices/...,
|
||||
/sys/bus/pci/devices/...). block/unblock write to <id>/authorized as
|
||||
root, so the id must resolve inside one of those scanned bases —
|
||||
otherwise cmd_block was an arbitrary file-overwrite ('0') and
|
||||
cmd_unblock was a root shell injection via `sudo sh -c` with the
|
||||
f-string path (found by the 0.3.0 security audit; both sudo
|
||||
fallbacks are also gone: the cockpit superuser channel already
|
||||
escalates this helper via polkit, so shelling out through sudo
|
||||
only ever added the injection primitive)."""
|
||||
anything else would make cmd_block an arbitrary file-overwrite ('0')
|
||||
and cmd_unblock a shell-injection primitive. The cockpit superuser
|
||||
channel escalates this helper via polkit; no sudo shell-out exists
|
||||
anywhere in this helper."""
|
||||
if not isinstance(device_id, str) or not device_id.startswith("/"):
|
||||
return False
|
||||
bases = (
|
||||
|
|
@ -551,10 +549,9 @@ def cmd_unblock(device_id):
|
|||
return {"action": "unblock", "deviceId": device_id, "result": "invalid-device-path"}
|
||||
auth_path = os.path.join(device_id, "authorized")
|
||||
if os.path.exists(auth_path):
|
||||
# v0.1.4 SECURITY: was `sudo sh -c f"echo 1 > {auth_path}"` — a
|
||||
# device_id containing shell metacharacters was literal root RCE.
|
||||
# Direct write (this helper already runs privileged through the
|
||||
# cockpit superuser channel when the operator approves polkit).
|
||||
# Direct write, never a shell: the helper already runs
|
||||
# privileged through the cockpit superuser channel when the
|
||||
# operator approves polkit.
|
||||
try:
|
||||
with open(auth_path, 'w') as f:
|
||||
f.write('1')
|
||||
|
|
@ -594,14 +591,21 @@ def cmd_whitelist(device_id):
|
|||
|
||||
|
||||
def cmd_unwhitelist(device_id):
|
||||
"""Remove a device from the whitelist."""
|
||||
"""Remove one device from the whitelist by exact identity.
|
||||
|
||||
Match on exact serial, exact device id, or exact vendorId:productId —
|
||||
never a substring, which would remove every entry sharing a letter.
|
||||
"""
|
||||
whitelist = load_whitelist()
|
||||
# Remove by matching serial or vendorId:productId
|
||||
new_wl = []
|
||||
for entry in whitelist:
|
||||
if entry.get("serial") and device_id in entry.get("serial", ""):
|
||||
continue
|
||||
new_wl.append(entry)
|
||||
new_wl = [
|
||||
entry for entry in whitelist
|
||||
if not (
|
||||
device_id == entry.get("serial")
|
||||
or device_id == entry.get("id")
|
||||
or device_id == entry.get("deviceId")
|
||||
or device_id == f"{entry.get('vendorId', '')}:{entry.get('productId', '')}"
|
||||
)
|
||||
]
|
||||
save_whitelist(new_wl)
|
||||
return {"action": "unwhitelist", "deviceId": device_id, "remaining": len(new_wl)}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,10 +32,15 @@ def score() -> int | None:
|
|||
|
||||
def scan() -> dict[str, Any]:
|
||||
"""Run a fresh lynis audit and return the parsed result."""
|
||||
# A lynis audit runs for minutes by design; 15 minutes is the hard
|
||||
# ceiling so a wedged audit cannot hang the bridge forever.
|
||||
try:
|
||||
subprocess.run(
|
||||
["lynis", "audit", "system"], capture_output=True, text=True, check=True,
|
||||
timeout=900,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "lynis audit timed out after 900s", "score": None}
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
|
||||
return {"error": str(exc), "score": None}
|
||||
return {"score": score()}
|
||||
|
|
|
|||
|
|
@ -3,19 +3,10 @@
|
|||
SysDeck - Kata Bridge Helper
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.0.38 PRODUCTION REWRITE. The v0.0.35-v0.0.37 Kata panel shipped a
|
||||
pre-built React bundle from the upstream cockpit-kata sub-project. That
|
||||
bundle displayed HARDCODED MOCK DATA — 5 fake sandboxes (web-frontend-
|
||||
prod, api-gateway-staging, etc.) with synthetic UUIDs and createdAt
|
||||
timestamps, fake metrics (cpuUsagePercent, memoryUsageMB, historyCpu/
|
||||
historyMemory arrays), a fake QCrows bundle catalog, and a fake PXE
|
||||
status (always dnsmasqRunning:true). The only real features were the
|
||||
QCrows kernel-bundle extraction (qcrows-export / qcrows-initrd-regen
|
||||
via cockpit.spawn) and the kata-runtime check call.
|
||||
|
||||
v0.0.38 deletes the React bundle and ships a vanilla-JS panel backed
|
||||
by this Python bridge helper. Every subcommand calls the REAL Kata
|
||||
Containers 3.x APIs:
|
||||
ZERO-DEMO CONTRACT. Every subcommand calls the real Kata Containers
|
||||
3.x APIs and reads the real host state — sandbox lists, metrics, bundle
|
||||
catalogs, and PXE status are live data or honest unavailability, never
|
||||
fabricated records:
|
||||
|
||||
list enumerate kata sandboxes via:
|
||||
1. kata-monitor HTTP /sandboxes (if running)
|
||||
|
|
@ -543,8 +534,7 @@ def cmd_check(_args: list[str]) -> dict[str, Any]:
|
|||
|
||||
# ── Subcommand: pxe-status ─────────────────────────────────────────
|
||||
#
|
||||
# Real PXE/TFTP status — replaces the v0.0.37 mock that always
|
||||
# returned dnsmasqRunning:true.
|
||||
# Real PXE/TFTP status straight from systemctl and /srv/tftp.
|
||||
|
||||
|
||||
def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
|
||||
|
|
@ -584,9 +574,8 @@ def cmd_pxe_status(_args: list[str]) -> dict[str, Any]:
|
|||
|
||||
# ── Subcommand: qcrows-list ────────────────────────────────────────
|
||||
#
|
||||
# QCrows kernel bundles are the real feature for kata kernel/module
|
||||
# compilation. The v0.0.37 React bundle had a mock catalog; this
|
||||
# reads the real filesystem.
|
||||
# QCrows kernel bundles are the kata kernel/module compilation
|
||||
# surface; the listing reads the real filesystem.
|
||||
|
||||
|
||||
def cmd_qcrows_list(_args: list[str]) -> list[dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
SysDeck - Mining Bridge Helper
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
|
||||
"themes and mining they need to be expanded for maximum ui
|
||||
control. think 1999 power tool style here." The Mining Dashboard
|
||||
1999 POWER-TOOL STYLE: maximum UI control over every mining surface.
|
||||
The Mining Dashboard
|
||||
panel surfaces every XMRig REST API knob:
|
||||
|
||||
summary — GET /1/summary (live hashrate, pool, threads)
|
||||
|
|
@ -219,11 +218,13 @@ def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
|
|||
if len(args) < 2:
|
||||
return {"error": "usage: pool-config-set <url> <username> [password]"}
|
||||
url, username = args[0], args[1]
|
||||
password = args[2] if len(args) > 2 else "x"
|
||||
# None = the operator sent no password; "x" is XMRig's conventional
|
||||
# dummy and only ever goes on the wire, never into the report.
|
||||
password = args[2] if len(args) > 2 else None
|
||||
cfg = _http_get("/1/config")
|
||||
if cfg is None:
|
||||
return {"available": False, "reason": "XMRig REST API not reachable"}
|
||||
new_pool = {"url": url, "user": username, "pass": password, "rig-id": "", "nicehash": False, "keep-alive": True, "enabled": True}
|
||||
new_pool = {"url": url, "user": username, "pass": password or "x", "rig-id": "", "nicehash": False, "keep-alive": True, "enabled": True}
|
||||
if not cfg.get("pools"):
|
||||
cfg["pools"] = [new_pool]
|
||||
else:
|
||||
|
|
@ -233,7 +234,7 @@ def cmd_pool_config_set(args: list[str]) -> dict[str, Any]:
|
|||
"set": result is not None,
|
||||
"url": url,
|
||||
"username": username,
|
||||
"password_set": password != "x",
|
||||
"password_set": password is not None,
|
||||
"raw": result,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ row shows the license, developer, source URL, and homepage link
|
|||
right next to a 1-click Install button. Clicking Install IS the
|
||||
operator's acceptance of the inline-displayed license.
|
||||
|
||||
Design rules (per v0.0.46 directive):
|
||||
Design rules:
|
||||
1. The catalog is the single source of truth — no per-module code
|
||||
branches. Adding a module = appending a dict to CATALOG.
|
||||
2. No pulls are executed without an explicit install call from
|
||||
|
|
@ -640,16 +640,27 @@ def status() -> list[dict[str, Any]]:
|
|||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _arg(a: list[str], i: int = 0, default: str = "") -> str:
|
||||
"""Positional argv read with an honest usage error, never IndexError."""
|
||||
if len(a) <= i or not a[i]:
|
||||
raise _UsageError(f"missing required argument {i + 1}")
|
||||
return a[i]
|
||||
|
||||
|
||||
class _UsageError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
COMMANDS: dict[str, Callable[[list[str]], Any]] = {
|
||||
"catalog": lambda _a: [e.to_public_dict() for e in catalog_entries()],
|
||||
"status": lambda _a: status(),
|
||||
"preflight": lambda a: preflight(a[0]),
|
||||
"preflight": lambda a: preflight(_arg(a)),
|
||||
"install": lambda a: install(
|
||||
a[0],
|
||||
_arg(a),
|
||||
accept_license=("--accept-license" in a) or ("--accept-license=1" in a),
|
||||
),
|
||||
"uninstall": lambda a: uninstall(a[0]),
|
||||
"audit": lambda a: audit(int(a[0]) if a else 200),
|
||||
"uninstall": lambda a: uninstall(_arg(a)),
|
||||
"audit": lambda a: audit(int(a[0]) if a and str(a[0]).isdigit() else 200),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -661,7 +672,11 @@ def main(argv: list[str]) -> int:
|
|||
if not cmd:
|
||||
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
|
||||
return 2
|
||||
result = cmd(argv[1:])
|
||||
try:
|
||||
result = cmd(argv[1:])
|
||||
except _UsageError as exc:
|
||||
print(json.dumps({"ok": False, "error": str(exc)}))
|
||||
return 2
|
||||
print(json.dumps(result, indent=2, default=str))
|
||||
return 0 if (not isinstance(result, dict) or result.get("ok", True)) else 1
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,10 @@
|
|||
SysDeck - Netsec Bridge Helper
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.0.43 REWRITE — IPTRAF-NG STYLE NETWORK MONITOR.
|
||||
IPTRAF-NG STYLE NETWORK MONITOR.
|
||||
|
||||
The v0.0.10-v0.0.42 panel used `ss -tulpn` for a static socket list.
|
||||
v0.0.43 recreates the iptraf-ng UI by reading the same kernel sources
|
||||
iptraf-ng reads from directly — no fragile ncurses parsing.
|
||||
The monitor reads the same kernel sources iptraf-ng reads from
|
||||
directly — no fragile ncurses parsing, no static socket list.
|
||||
|
||||
Data sources:
|
||||
/proc/net/dev per-interface RX/TX byte + packet counters
|
||||
|
|
@ -26,8 +25,8 @@ Subcommands:
|
|||
connections active TCP/UDP flows with PID mapping (like iptraf-ng IP monitor)
|
||||
interfaces per-interface detailed stats (cumulative counters)
|
||||
protocols /proc/net/snmp parsed: IP/TCP/UDP/ICMP counters
|
||||
sockets listening TCP+UDP sockets (kept from v0.0.10 for back-compat)
|
||||
established established TCP connections (kept from v0.0.10 for back-compat)
|
||||
sockets listening TCP+UDP sockets
|
||||
established established TCP connections
|
||||
|
||||
Usage:
|
||||
python3 /usr/lib/sysdeck/bridge/netsec.py traffic
|
||||
|
|
@ -376,7 +375,7 @@ def cmd_summary(_args: list[str]) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
# ── Legacy subcommands (kept for back-compat) ─────────────────────
|
||||
# ── Socket-listing subcommands ───────────────────────────────────
|
||||
|
||||
|
||||
def parse_ss(output: str) -> list[dict[str, Any]]:
|
||||
|
|
@ -396,12 +395,12 @@ def parse_ss(output: str) -> list[dict[str, Any]]:
|
|||
|
||||
|
||||
def sockets() -> list[dict[str, Any]]:
|
||||
"""Listening TCP and UDP sockets (legacy, kept for back-compat)."""
|
||||
"""Listening TCP and UDP sockets from `ss -tulpn`."""
|
||||
return parse_ss(_run(["ss", "-tulpn"]))
|
||||
|
||||
|
||||
def established() -> list[dict[str, Any]]:
|
||||
"""Established TCP connections (legacy, kept for back-compat)."""
|
||||
"""Established TCP connections from `ss -tnp state established`."""
|
||||
return parse_ss(_run(["ss", "-tnp", "state", "established"]))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1065,13 +1065,7 @@ def install(args: list[str]) -> dict[str, str]:
|
|||
# Actually run it. The cockpit bridge runs as the cockpit user; the
|
||||
# JS panel's cockpit.spawn(..., { superuser: 'try' }) makes cockpit
|
||||
# prompt the operator for auth and run us as root via polkit.
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
return {"action": "install", "package": pkg, "manager": PKG_MANAGER,
|
||||
"command": " ".join(cmd), "success": r.returncode == 0,
|
||||
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
|
||||
return _run_mutation(cmd, "install", pkg)
|
||||
|
||||
|
||||
def remove(args: list[str]) -> dict[str, str]:
|
||||
|
|
@ -1087,13 +1081,7 @@ def remove(args: list[str]) -> dict[str, str]:
|
|||
if not cmd:
|
||||
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": f"no remove command for {PKG_MANAGER}"}
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
return {"action": "remove", "package": pkg, "manager": PKG_MANAGER,
|
||||
"command": " ".join(cmd), "success": r.returncode == 0,
|
||||
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
|
||||
return _run_mutation(cmd, "remove", pkg)
|
||||
|
||||
|
||||
def update(args: list[str]) -> dict[str, str]:
|
||||
|
|
@ -1116,11 +1104,53 @@ def update(args: list[str]) -> dict[str, str]:
|
|||
if not cmd:
|
||||
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": f"no update command for {PKG_MANAGER}"}
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
return {"action": "update", "package": pkg, "manager": PKG_MANAGER,
|
||||
return _run_mutation(cmd, "update", pkg)
|
||||
|
||||
|
||||
def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]:
|
||||
"""Run a package-manager mutation; never raises.
|
||||
|
||||
Ten-minute package operations are normal, and a missing binary is
|
||||
a state, not a crash: both come back as a structured failure the
|
||||
panel can render, exactly like the read path.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": "package manager timed out after 600s — "
|
||||
"it may still be running; check with the manager directly"}
|
||||
except FileNotFoundError:
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": f"{cmd[0]} is not installed"}
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"command": " ".join(cmd), "success": r.returncode == 0,
|
||||
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
|
||||
|
||||
|
||||
def _run_mutation(cmd: list[str], action: str, pkg: str = "") -> dict[str, str]:
|
||||
"""Run a package-manager mutation; never raises.
|
||||
|
||||
Ten-minute package operations are normal, and a missing binary is
|
||||
a state, not a crash: both come back as a structured failure the
|
||||
panel can render, exactly like the read path.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": "package manager timed out after 600s — "
|
||||
"it may still be running; check with the manager directly"}
|
||||
except FileNotFoundError:
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": f"{cmd[0]} is not installed"}
|
||||
return {"action": action, "package": pkg, "manager": PKG_MANAGER,
|
||||
"command": " ".join(cmd), "success": r.returncode == 0,
|
||||
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
|
||||
|
||||
|
|
@ -1137,13 +1167,7 @@ def update_all() -> dict[str, str]:
|
|||
if not cmd:
|
||||
return {"action": "update-all", "manager": PKG_MANAGER,
|
||||
"success": False, "stderr": f"no update-all command for {PKG_MANAGER}"}
|
||||
r = subprocess.run(
|
||||
cmd, capture_output=True, text=True, check=False,
|
||||
timeout=600, env=SCRUBBED_ENV,
|
||||
)
|
||||
return {"action": "update-all", "manager": PKG_MANAGER,
|
||||
"command": " ".join(cmd), "success": r.returncode == 0,
|
||||
"rc": r.returncode, "output": r.stdout, "stderr": r.stderr}
|
||||
return _run_mutation(cmd, "update-all", "")
|
||||
|
||||
|
||||
def dry_run(args: list[str]) -> dict[str, str]:
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ Manages self-hosted photo management backends as systemd services
|
|||
and exposes the built-in admin web UI for iframe embedding in the
|
||||
SysDeck panel.
|
||||
|
||||
v0.0.35 directive: "as well as a photo manager of equal quality.
|
||||
with its own module." Following the Jellyfin pattern: start/stop/
|
||||
restart the service via systemctl; the panel iframes the running
|
||||
A photo manager of equal quality to the Jellyfin module, as its own
|
||||
module. Following the Jellyfin pattern: start/stop/restart the
|
||||
service via systemctl; the panel iframes the running
|
||||
admin web UI. Equal quality means the photo manager module ships
|
||||
with the same service-control + iframe-load shape as Jellyfin.
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
SysDeck - Policy & Permissions Bridge
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.0.32 NEW MODULE — modern policy management and permissions
|
||||
manager for groups. The user directive:
|
||||
Modern policy management and permissions manager for groups.
|
||||
Design contract:
|
||||
|
||||
"modern policy management and permissions manager for groups.
|
||||
such as acl, cgroups, vlans, ebpf namespace separation and
|
||||
|
|
@ -665,14 +665,20 @@ def cmd_ns_show(args: list[str]) -> dict[str, Any]:
|
|||
lsns = shutil.which("lsns")
|
||||
if not lsns:
|
||||
return {"available": False, "reason": "lsns (util-linux) not installed"}
|
||||
rc, out, _ = _run([lsns, "-J", "-t", nsid])
|
||||
# lsns has no lookup-by-namespace-id flag; -t expects a type. Take
|
||||
# the full JSON listing once and select the requested id from it.
|
||||
rc, out, _ = _run([lsns, "-J"])
|
||||
if rc == 0 and out.strip():
|
||||
try:
|
||||
data = json.loads(out)
|
||||
return {"available": True, "info": data, "format": "json"}
|
||||
listing = json.loads(out)
|
||||
for ns in listing.get("namespaces", []):
|
||||
if str(ns.get("ns", "")) == nsid:
|
||||
return {"available": True, "info": ns, "format": "json"}
|
||||
return {"available": False,
|
||||
"reason": f"namespace {nsid} not found in the live listing"}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
rc2, out2, _ = _run([lsns, "-t", nsid])
|
||||
rc2, out2, _ = _run([lsns])
|
||||
return {"available": True, "raw": out2, "format": "text"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,8 +39,8 @@ PROM_LICENSE = "Apache-2.0"
|
|||
PROM_AUTHORS = "Prometheus Authors"
|
||||
PROM_URL = "https://prometheus.io"
|
||||
|
||||
# v0.0.39: import v0.0.37 security helpers from firewall.py (single
|
||||
# source of truth for hardening).
|
||||
# Security helpers come from firewall.py — one source of truth for
|
||||
# hardening across the suite.
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
try:
|
||||
from firewall import ( # type: ignore
|
||||
|
|
@ -60,14 +60,11 @@ except ImportError:
|
|||
def _validate_filename(name: str) -> bool:
|
||||
return bool(name and len(name) <= 64 and _FILENAME_RE_FALLBACK.match(name))
|
||||
|
||||
# Prometheus API endpoint from environment or default
|
||||
# v0.0.40: Prometheus defaults to port 9090, which is the SAME port
|
||||
# Cockpit-ws uses. Since Cockpit is already running on 9090 on every
|
||||
# SysDeck host, Prometheus MUST be moved to a different port. We
|
||||
# default to 9095 — it's in the familiar 909x range, doesn't conflict
|
||||
# with Pushgateway (9091), Alertmanager (9093), or Cockpit (9090).
|
||||
# Operators who already run Prometheus on a custom port can override
|
||||
# via the PROMETHEUS_API_URL environment variable.
|
||||
# Prometheus API endpoint from environment or default. Port 9090
|
||||
# belongs to cockpit-ws on every SysDeck host, so Prometheus defaults
|
||||
# to 9095 — same 909x range, clear of Pushgateway (9091), Alertmanager
|
||||
# (9093), and Cockpit (9090). Operators running Prometheus on a custom
|
||||
# port override it via PROMETHEUS_API_URL.
|
||||
PROM_API_URL = os.environ.get("PROMETHEUS_API_URL", "http://localhost:9095")
|
||||
# Pushgateway URL for log pipeline
|
||||
PUSHGATEWAY_URL = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL", "http://localhost:9091")
|
||||
|
|
@ -373,23 +370,35 @@ def push_log(args: list[str]) -> dict[str, Any]:
|
|||
# Metadata JSON malformed: reject push request
|
||||
return {"error": "metadata_json must be valid JSON"}
|
||||
|
||||
# Filesystem contract: the module name becomes a filename under
|
||||
# LOG_DIR. Validate it like every other filename this suite writes —
|
||||
# no absolute paths, no traversal, no symlink tricks downstream.
|
||||
if not _validate_filename(module):
|
||||
return {"error": "invalid module name (max 64 chars of [A-Za-z0-9._-])"}
|
||||
|
||||
ts = time.time()
|
||||
iso_ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(ts))
|
||||
|
||||
# Build Prometheus exposition format
|
||||
# Build Prometheus exposition format. Label values are escaped
|
||||
# (backslash, quote, newline) per the exposition spec — raw values
|
||||
# must never inject metric lines into the pushgateway payload.
|
||||
def _esc(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
|
||||
|
||||
mod_l, lvl_l, msg_l = _esc(module), _esc(level), _esc(message[:128])
|
||||
metrics = (
|
||||
f'# TYPE sysdeck_log_total counter\n'
|
||||
f'sysdeck_log_total{{module="{module}",level="{level}"}} 1\n'
|
||||
f'# TYPE sysdeck_log_timestamp_seconds gauge\n'
|
||||
f'sysdeck_log_timestamp_seconds{{module="{module}",level="{level}"}} {ts:.3f}\n'
|
||||
f'# TYPE sysdeck_log_message_info gauge\n'
|
||||
f'sysdeck_log_message_info{{module="{module}",level="{level}",msg="{message[:128]}"}} 1\n'
|
||||
'# TYPE sysdeck_log_total counter\n'
|
||||
f'sysdeck_log_total{{module="{mod_l}",level="{lvl_l}"}} 1\n'
|
||||
'# TYPE sysdeck_log_timestamp_seconds gauge\n'
|
||||
f'sysdeck_log_timestamp_seconds{{module="{mod_l}",level="{lvl_l}"}} {ts:.3f}\n'
|
||||
'# TYPE sysdeck_log_message_info gauge\n'
|
||||
f'sysdeck_log_message_info{{module="{mod_l}",level="{lvl_l}",msg="{msg_l}"}} 1\n'
|
||||
)
|
||||
|
||||
pushed = _pushgateway_post("sysdeck_logs", metrics)
|
||||
|
||||
# Persist to local audit log
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Persist to local audit log. Path stays inside LOG_DIR by
|
||||
# construction (validated module name, fixed directory).
|
||||
log_entry = {
|
||||
"module": module,
|
||||
"level": level,
|
||||
|
|
@ -399,10 +408,15 @@ def push_log(args: list[str]) -> dict[str, Any]:
|
|||
"pushedToPrometheus": pushed,
|
||||
}
|
||||
log_file = LOG_DIR / f"{module}.jsonl"
|
||||
with open(log_file, "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
try:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
except OSError as exc:
|
||||
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry,
|
||||
"persisted": False, "persistError": str(exc)}
|
||||
|
||||
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry}
|
||||
return {"pushed": pushed, "timestamp": iso_ts, "entry": log_entry, "persisted": True}
|
||||
|
||||
|
||||
def log_summary() -> dict[str, Any]:
|
||||
|
|
|
|||
|
|
@ -313,11 +313,30 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
|
|||
return {"error": f"Unknown backend: {bid}"}
|
||||
(bid2, name, family, port, unit, cli, configs, lic, homepage, install_hint) = e
|
||||
|
||||
# One probe table, not five copy-pasted branches: each backend
|
||||
# maps to its argv; a CLI that is not installed is a state the panel
|
||||
# can render, not a traceback.
|
||||
probes: dict[str, list[str]] = {
|
||||
"ceph": ["ceph", "status", "--format=json"],
|
||||
"glusterfs": ["gluster", "pool", "list"],
|
||||
"moosefs": ["moosefs-cli", "info"],
|
||||
"beegfs": ["beegfs-ctl", "--listnodes"],
|
||||
"orangefs": ["pvfs2-server", "-m"],
|
||||
}
|
||||
argv = probes.get(bid2)
|
||||
if argv is None:
|
||||
return {"error": f"No cluster-info handler for {bid2}"}
|
||||
try:
|
||||
out = subprocess.run(argv, capture_output=True, text=True, timeout=15)
|
||||
except FileNotFoundError:
|
||||
return {"available": False,
|
||||
"reason": f"{argv[0]} is not installed",
|
||||
"install": install_hint or ""}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"available": False,
|
||||
"reason": f"{argv[0]} timed out after 15s"}
|
||||
|
||||
if bid2 == "ceph":
|
||||
out = subprocess.run(
|
||||
["ceph", "status", "--format=json"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return {"error": out.stderr.strip() or f"ceph status returned {out.returncode}"}
|
||||
try:
|
||||
|
|
@ -337,55 +356,12 @@ def cmd_cluster_info(bid: str) -> dict[str, Any]:
|
|||
except json.JSONDecodeError:
|
||||
return {"backend": bid2, "rawText": out.stdout[:4000]}
|
||||
|
||||
if bid2 == "glusterfs":
|
||||
out = subprocess.run(
|
||||
["gluster", "pool", "list"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return {
|
||||
"backend": bid2,
|
||||
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
|
||||
"stderr": out.stderr.strip() if out.returncode != 0 else "",
|
||||
"rc": out.returncode,
|
||||
}
|
||||
|
||||
if bid2 == "moosefs":
|
||||
out = subprocess.run(
|
||||
["moosefs-cli", "info"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return {
|
||||
"backend": bid2,
|
||||
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
|
||||
"stderr": out.stderr.strip() if out.returncode != 0 else "",
|
||||
"rc": out.returncode,
|
||||
}
|
||||
|
||||
if bid2 == "beegfs":
|
||||
out = subprocess.run(
|
||||
["beegfs-ctl", "--listnodes"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return {
|
||||
"backend": bid2,
|
||||
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
|
||||
"stderr": out.stderr.strip() if out.returncode != 0 else "",
|
||||
"rc": out.returncode,
|
||||
}
|
||||
|
||||
if bid2 == "orangefs":
|
||||
out = subprocess.run(
|
||||
["pvfs2-server", "-m"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return {
|
||||
"backend": bid2,
|
||||
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
|
||||
"stderr": out.stderr.strip() if out.returncode != 0 else "",
|
||||
"rc": out.returncode,
|
||||
}
|
||||
|
||||
return {"error": f"No cluster-info handler for {bid2}"}
|
||||
return {
|
||||
"backend": bid2,
|
||||
"rawText": out.stdout[:4000] if out.returncode == 0 else "",
|
||||
"stderr": out.stderr.strip() if out.returncode != 0 else "",
|
||||
"rc": out.returncode,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@
|
|||
SysDeck - Theme Engine Bridge Helper
|
||||
Author: Jeremy Anderson (https://dcos.net)
|
||||
|
||||
v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE. Per user directive:
|
||||
"themes and mining they need to be expanded for maximum ui control.
|
||||
think 1999 power tool style here." The Theme Engine panel surfaces
|
||||
1999 POWER-TOOL STYLE: maximum UI control over every theme surface.
|
||||
The Theme Engine panel surfaces
|
||||
the full set of cockpit.conf theming knobs plus a preset gallery
|
||||
and live-preview CSS-variable overrides.
|
||||
|
||||
|
|
@ -440,6 +439,22 @@ def cmd_variable_set(args: list[str]) -> dict[str, Any]:
|
|||
spec = next((v for v in CSS_VARIABLES if v["name"] == name), None)
|
||||
if spec is None:
|
||||
return {"error": f"variable {name} not in the surface"}
|
||||
# Values land in overrides.css, which every SysDeck page loads.
|
||||
# Accept color literals and numeric expressions only: no braces,
|
||||
# semicolons, quotes, or url()/import tokens — a value that could
|
||||
# break out of the declaration or fetch a remote asset is rejected
|
||||
# at the door, not sanitized after the fact.
|
||||
if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
|
||||
return {"error": "value must be 1-128 chars of color/number syntax "
|
||||
"(letters, digits, space, # % ( ) , . / _ -)"}
|
||||
# Values land in overrides.css, which every SysDeck page loads.
|
||||
# Accept color literals and numeric expressions only: no braces,
|
||||
# semicolons, quotes, or url()/import tokens — a value that could
|
||||
# break out of the declaration or fetch a remote asset is rejected
|
||||
# at the door, not sanitized after the fact.
|
||||
if not re.fullmatch(r"[A-Za-z0-9 #%(),./_-]{1,128}", value):
|
||||
return {"error": "value must be 1-128 chars of color/number syntax "
|
||||
"(letters, digits, space, # % ( ) , . / _ -)"}
|
||||
try:
|
||||
SYSDECK_THEME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Read existing overrides, replace or append this variable.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ def list_luks() -> list:
|
|||
try:
|
||||
r = subprocess.run(
|
||||
["lsblk", "-o", "NAME,FSTYPE,MOUNTPOINT,SIZE,TYPE", "-J"],
|
||||
capture_output=True, text=True, check=True,
|
||||
capture_output=True, text=True, check=True, timeout=20,
|
||||
)
|
||||
data = json.loads(r.stdout) if r.stdout.strip() else {}
|
||||
devices = []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"_comment": "Compatibility Manifest — sysdeck v0.1.3",
|
||||
"version": "0.4.4",
|
||||
"version": "0.4.5",
|
||||
"suite_requires": { "cockpit": ">=239", "python": ">=3.9" },
|
||||
"modules": {
|
||||
"containers": {
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"distro_support": { "arch": "full", "debian": "full", "fedora": "full" }
|
||||
},
|
||||
"kata": {
|
||||
"_comment_v0.0.35": "SysDeck Kata — restored to its own sidebar entry per user directive. Hosts the pre-built cockpit-kata React app (index.js + index.css). Requires cockpit ≥ 286 because the React bundle uses newer cockpit-podman base1 APIs. The standalone cockpit-kata sub-project is consolidated into SysDeck.",
|
||||
"_comment": "SysDeck Kata — its own sidebar entry. Hosts the pre-built cockpit-kata React app (index.js + index.css). Requires cockpit ≥ 286 because the React bundle uses newer cockpit-podman base1 APIs. The standalone cockpit-kata sub-project is consolidated into SysDeck.",
|
||||
"requires": { "cockpit": ">=286" },
|
||||
"conditions": [{ "path-or-exists": "/usr/bin/kata-runtime" }, { "path-or-exists": "/usr/bin/kata-containerd-shim-v2" }],
|
||||
"config": {
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ The JS bridge client calls each helper by absolute path — `python3 /usr/lib/sy
|
|||
|
||||
### Content Security Policy violations
|
||||
|
||||
The manifests declare `content-security-policy: default-src 'self' 'unsafe-inline'`. `unsafe-eval` was dropped from every plugin in the 0.3.0 security audit. If your cockpit deployment enforces a stricter policy, tighten the manifest to match — the panels do not require it.
|
||||
The manifests declare `content-security-policy: default-src 'self' 'unsafe-inline'` — no plugin evaluates code, so `unsafe-eval` stays out of every manifest. If your cockpit deployment enforces a stricter policy, tighten the manifest to match; the panels do not require it.
|
||||
|
||||
### Web console: login loop or 401 on every route
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Cilium default network policy — shipped with sysdeck-0.0.36.
|
||||
# Cilium default network policy — shipped with the SysDeck firewall module.
|
||||
# Author: Jeremy Anderson <info@dcos.net>
|
||||
#
|
||||
# This policy is applied by firewall/templates/cilium.sh `start` action
|
||||
|
|
@ -6,9 +6,11 @@
|
|||
# panel. It implements a sensible default:
|
||||
#
|
||||
# - default-deny ingress + egress at the cluster level
|
||||
# - allow DNS (UDP/TCP 53) to kube-dns / systemd-resolved
|
||||
# - allow SSH (TCP 22) from anywhere
|
||||
# - allow HTTP/HTTPS (TCP 80/443) from anywhere
|
||||
# - allow DNS (UDP/TCP 53) to kube-dns (K8s) or any local resolver
|
||||
# (standalone installs run systemd-resolved without kube labels)
|
||||
# - allow SSH (TCP 22) from anywhere — no L7 parser on port 22
|
||||
# - allow HTTP/HTTPS (TCP 80/443) from anywhere, with the HTTP
|
||||
# method allow-list scoped to 80/443 only
|
||||
#
|
||||
# Operators can drop a custom policy at
|
||||
# /etc/sysdeck/firewall/cilium-policy.yaml to override.
|
||||
|
|
@ -22,27 +24,37 @@ metadata:
|
|||
namespace: default
|
||||
annotations:
|
||||
sysdeck.io/managed-by: "sysdeck-firewall-cilium"
|
||||
sysdeck.io/version: "0.0.36"
|
||||
sysdeck.io/version: "0.4.5"
|
||||
spec:
|
||||
description: "SysDeck default policy — deny all ingress + egress, then re-allow DNS/SSH/HTTP/HTTPS"
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
# Allow all endpoints to receive traffic from anywhere on SSH/HTTP/HTTPS.
|
||||
# SSH passes at L3/L4 only: an HTTP L7 filter on port 22 would deny
|
||||
# every non-HTTP byte of an SSH session.
|
||||
- toPorts:
|
||||
- ports:
|
||||
- port: "22"
|
||||
protocol: TCP
|
||||
# HTTP/HTTPS carry the method allow-list — scoped to these ports
|
||||
# alone, never to the whole toPorts block.
|
||||
- toPorts:
|
||||
- ports:
|
||||
- port: "80"
|
||||
protocol: TCP
|
||||
rules:
|
||||
http:
|
||||
- method: "GET"
|
||||
- method: "POST"
|
||||
- method: "HEAD"
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
rules:
|
||||
http:
|
||||
- method: "GET"
|
||||
- method: "POST"
|
||||
- method: "HEAD"
|
||||
rules:
|
||||
http:
|
||||
- method: "GET"
|
||||
- method: "POST"
|
||||
- method: "HEAD"
|
||||
egress:
|
||||
# Allow DNS to kube-dns (K8s) or systemd-resolved (standalone).
|
||||
# DNS to kube-dns on Kubernetes deployments.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: kube-system
|
||||
|
|
@ -56,7 +68,18 @@ spec:
|
|||
rules:
|
||||
dns:
|
||||
- matchPattern: "*"
|
||||
# Allow egress to anywhere on SSH/HTTP/HTTPS.
|
||||
# DNS to any local resolver on the host network — standalone Cilium
|
||||
# (the documented sysdeck mode) has no kube-dns labels to match.
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
reserved:world
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "53"
|
||||
protocol: UDP
|
||||
- port: "53"
|
||||
protocol: TCP
|
||||
# Egress to anywhere on SSH/HTTP/HTTPS.
|
||||
- toPorts:
|
||||
- ports:
|
||||
- port: "22"
|
||||
|
|
@ -65,7 +88,8 @@ spec:
|
|||
protocol: TCP
|
||||
- port: "443"
|
||||
protocol: TCP
|
||||
# Allow egress to anywhere on HTTPS (for system updates).
|
||||
# Egress to public HTTPS (for system updates) — private ranges stay
|
||||
# blocked so endpoints cannot reach internal services uninvited.
|
||||
- toCIDRSet:
|
||||
- cidr: 0.0.0.0/0
|
||||
except:
|
||||
|
|
|
|||
|
|
@ -207,7 +207,10 @@ build_ruleset() {
|
|||
cat <<RULESET
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
# Table-scoped reset: foreign tables (docker, libvirt,
|
||||
# systemd-networkd) are not ours to destroy.
|
||||
add table inet ${TABLE_NAME}
|
||||
flush table inet ${TABLE_NAME}
|
||||
|
||||
table inet ${TABLE_NAME} {
|
||||
|
||||
|
|
@ -322,6 +325,16 @@ fw_start() {
|
|||
log_info " hermes=${HERMES_PORT} (public)"
|
||||
log_info " odysseus=${ODYSSEUS_PORT} (public)"
|
||||
build_ruleset > "$RULES_FILE"
|
||||
# Root check + validate-then-load: a ruleset that cannot parse must
|
||||
# never reach the kernel, and only the polkit bridge runs us.
|
||||
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
|
||||
echo "ERROR: ruleset failed validation — nothing was loaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$NFT_CMD" -f "$RULES_FILE"
|
||||
log_info "Firewall loaded."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -251,9 +251,12 @@ fw_stop() {
|
|||
return 0
|
||||
fi
|
||||
|
||||
# Delete all Cilium policies (reverts to default allow-all).
|
||||
# Delete all Cilium policies. Cilium's default posture without a
|
||||
# policy is allow-all — stopping the agent opens the host; the
|
||||
# operator hears that decision, not just "done".
|
||||
# This does NOT unload the BPF programs — cilium-agent keeps running
|
||||
# so the operator can re-apply a policy without reinstalling.
|
||||
log_warn "stopping the cilium backend returns the host to allow-all — re-apply a policy or load an nftables template before exposing the host"
|
||||
"$CILIUM_BIN" policy delete --all 2>/dev/null || log_warn "policy delete --all failed (no policies loaded?)."
|
||||
|
||||
if [[ -x "$CILIUM_AGENT_BIN" ]] && systemctl is-active --quiet "$CILIUM_AGENT_SVC" 2>/dev/null; then
|
||||
|
|
|
|||
|
|
@ -84,9 +84,9 @@ SSH_BAN_TIME="300"
|
|||
|
||||
# --- Allowed TCP Services ---
|
||||
# Format: "port" or "port:interface" for interface-specific rules
|
||||
# Contract: no public services except SSH. Operators who need a
|
||||
# web tier use the public-webserver or vps-webserver template.
|
||||
TCP_SERVICES=(
|
||||
"80"
|
||||
"443"
|
||||
"${SSH_PORT}"
|
||||
)
|
||||
|
||||
|
|
@ -297,7 +297,7 @@ generate_rules() {
|
|||
all_ifs=$(get_all_interfaces)
|
||||
|
||||
# Start with table definition
|
||||
cat > "$RULES_FILE" << 'TABLE_HEADER'
|
||||
cat > "$RULES_FILE" << TABLE_HEADER
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
# ============================================================================
|
||||
|
|
@ -305,7 +305,10 @@ generate_rules() {
|
|||
# Generated by: nftables-firewall.sh
|
||||
# ============================================================================
|
||||
|
||||
flush ruleset
|
||||
# Table-scoped reset: foreign tables (docker, libvirt, systemd-networkd)
|
||||
# are not ours to destroy.
|
||||
add table inet ${TABLE_NAME}
|
||||
flush table inet ${TABLE_NAME}
|
||||
|
||||
TABLE_HEADER
|
||||
|
||||
|
|
@ -325,11 +328,15 @@ table inet ${TABLE_NAME} {
|
|||
}
|
||||
|
||||
# Trusted networks
|
||||
set trusted_nets {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
elements = { $(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//') }
|
||||
}
|
||||
$(if [[ ${#TRUSTED_NETS[@]} -gt 0 ]]; then
|
||||
printf 'set trusted_nets {\n'
|
||||
printf ' type ipv4_addr\n'
|
||||
printf ' flags interval\n'
|
||||
printf ' elements = { %s }\n' "$(printf '%s, ' "${TRUSTED_NETS[@]}" | sed 's/, $//')"
|
||||
printf '}\n'
|
||||
else
|
||||
printf 'set trusted_nets { type ipv4_addr; }\n'
|
||||
fi)
|
||||
|
||||
# TCP ports to allow
|
||||
set tcp_allowed {
|
||||
|
|
@ -422,11 +429,16 @@ COUNTER_MAP
|
|||
chain input {
|
||||
type filter hook input priority 0; policy ${POLICY_INPUT};
|
||||
|
||||
# Loopback first, unconditionally: local IPC (DNS, databases,
|
||||
# graphical sessions) must never ride the interface verdict map.
|
||||
iifname "$LO_IF" accept
|
||||
|
||||
# Update interface counters
|
||||
meta iifname @iface_input_policy counter name @iface_counters
|
||||
|
||||
# Jump to interface-specific handling
|
||||
meta iifname @iface_input_policy
|
||||
}
|
||||
INPUT_CHAIN
|
||||
|
||||
# --- Loopback Chain ---
|
||||
|
|
@ -457,21 +469,21 @@ LO_CHAIN
|
|||
$(if [[ "$PROTECT_FRAGMENTS" == "yes" ]]; then echo "ip frag-off != 0 counter drop comment \"Fragmented packet\""; fi)
|
||||
|
||||
# Drop XMAS tree scans (all flags set)
|
||||
$(if [[ "$PROTECT_XMAS" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) counter jump to port_scan_detect comment \"XMAS scan\""; fi)
|
||||
$(if [[ "$PROTECT_XMAS" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == (fin|syn|rst|psh|ack|urg) counter jump port_scan_detect comment \"XMAS scan\""; fi)
|
||||
|
||||
# Drop NULL scans (no flags set)
|
||||
$(if [[ "$PROTECT_NULL_SCAN" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter jump to port_scan_detect comment \"NULL scan\""; fi)
|
||||
$(if [[ "$PROTECT_NULL_SCAN" == "yes" ]]; then echo "tcp flags & (fin|syn|rst|psh|ack|urg) == 0x0 counter jump port_scan_detect comment \"NULL scan\""; fi)
|
||||
|
||||
# Drop bogus TCP flag combinations
|
||||
$(if [[ "$PROTECT_BOGUS_TCP" == "yes" ]]; then generate_bogus_tcp_rules; fi)
|
||||
|
||||
# ICMP handling
|
||||
jump to icmp_handling
|
||||
jump icmp_handling
|
||||
|
||||
# Connection rate limiting
|
||||
$(generate_connlimit_rules)
|
||||
|
||||
jump to service_handling
|
||||
jump service_handling
|
||||
}
|
||||
PREFILTER
|
||||
|
||||
|
|
@ -513,9 +525,7 @@ EXTRA_CHAIN
|
|||
# --- Port Scan Detection Chain ---
|
||||
cat >> "$RULES_FILE" << SCAN_CHAIN
|
||||
chain port_scan_detect {
|
||||
# Add to scanner set and drop
|
||||
ip saddr @port_scanners drop
|
||||
ip saddr set add @port_scanners counter drop comment \"Port scan detected\"
|
||||
add @port_scanners { ip saddr } counter drop comment \"Port scan detected\"
|
||||
}
|
||||
SCAN_CHAIN
|
||||
|
||||
|
|
@ -661,8 +671,8 @@ generate_ssh_rules() {
|
|||
cat << SSH_RULES
|
||||
# SSH brute force protection
|
||||
tcp dport $SSH_PORT ip saddr @ssh_abuse counter drop comment \"SSH brute force ban\"
|
||||
tcp dport $SSH_IP ct state new limit rate ${SSH_MAX_CONN} burst 5 accept
|
||||
tcp dport $SSH_IP ct state new add @ssh_abuse ip saddr counter drop comment \"SSH rate limit exceeded\"
|
||||
tcp dport $SSH_PORT ct state new limit rate ${SSH_MAX_CONN} burst 5 accept
|
||||
tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } counter drop comment \"SSH rate limit exceeded\"
|
||||
SSH_RULES
|
||||
}
|
||||
|
||||
|
|
@ -678,9 +688,9 @@ DNS_RULES
|
|||
# Generate connection rate limiting
|
||||
generate_connlimit_rules() {
|
||||
cat << CONNLIMIT_RULES
|
||||
# Global connection rate limit
|
||||
ct state new limit rate ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} accept
|
||||
ct state new add @connlimit_abuse ip saddr counter drop comment \"Connection rate limit exceeded\"
|
||||
# Global connection rate limit: drop only the excess — the
|
||||
# accept decisions stay with the service rules.
|
||||
ct state new limit rate over ${CONN_RATE_LIMIT} burst ${CONN_RATE_BURST} add @connlimit_abuse { ip saddr } counter drop comment \"Connection rate limit exceeded\"
|
||||
CONNLIMIT_RULES
|
||||
}
|
||||
|
||||
|
|
@ -688,13 +698,13 @@ CONNLIMIT_RULES
|
|||
generate_bogus_tcp_rules() {
|
||||
cat << BOGUS_TCP
|
||||
# SYN+FIN (scan)
|
||||
tcp flags & (syn|fin) == (syn|fin) counter jump to port_scan_detect
|
||||
tcp flags & (syn|fin) == (syn|fin) counter jump port_scan_detect
|
||||
# SYN+RST (scan)
|
||||
tcp flags & (syn|rst) == (syn|rst) counter jump to port_scan_detect
|
||||
tcp flags & (syn|rst) == (syn|rst) counter jump port_scan_detect
|
||||
# FIN+RST (scan)
|
||||
tcp flags & (fin|rst) == (fin|rst) counter jump to port_scan_detect
|
||||
tcp flags & (fin|rst) == (fin|rst) counter jump port_scan_detect
|
||||
# PSH+FIN without ACK (scan)
|
||||
tcp flags & (psh|fin|ack) == (psh|fin) counter jump to port_scan_detect
|
||||
tcp flags & (psh|fin|ack) == (psh|fin) counter jump port_scan_detect
|
||||
BOGUS_TCP
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,10 @@ build_ruleset() {
|
|||
cat <<RULESET
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
# Table-scoped reset: foreign tables (docker, libvirt,
|
||||
# systemd-networkd) are not ours to destroy.
|
||||
add table inet ${TABLE_NAME}
|
||||
flush table inet ${TABLE_NAME}
|
||||
|
||||
table inet ${TABLE_NAME} {
|
||||
|
||||
|
|
@ -329,6 +332,16 @@ fw_start() {
|
|||
log_info " caddy-admin=${CADDY_ADMIN_PORT} (loopback only)"
|
||||
log_info " mariadb=${MARIADB_PORT} (loopback only, defense-in-depth drop)"
|
||||
build_ruleset > "$RULES_FILE"
|
||||
# Root check + validate-then-load: a ruleset that cannot parse must
|
||||
# never reach the kernel, and only the polkit bridge runs us.
|
||||
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
|
||||
echo "ERROR: ruleset failed validation — nothing was loaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$NFT_CMD" -f "$RULES_FILE"
|
||||
log_info "Firewall loaded."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -164,7 +164,10 @@ build_ruleset() {
|
|||
cat <<RULESET
|
||||
#!/usr/sbin/nft -f
|
||||
|
||||
flush ruleset
|
||||
# Table-scoped reset: foreign tables (docker, libvirt,
|
||||
# systemd-networkd) are not ours to destroy.
|
||||
add table inet ${TABLE_NAME}
|
||||
flush table inet ${TABLE_NAME}
|
||||
|
||||
table inet ${TABLE_NAME} {
|
||||
|
||||
|
|
@ -294,6 +297,16 @@ fw_start() {
|
|||
detect_cockpit_port
|
||||
log_info "Starting remote-admin firewall: ssh=${SSH_PORT}, cockpit=${COCKPIT_PORT}"
|
||||
build_ruleset > "$RULES_FILE"
|
||||
# Root check + validate-then-load: a ruleset that cannot parse must
|
||||
# never reach the kernel, and only the polkit bridge runs us.
|
||||
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||
echo "ERROR: $0 must run as root (via the sysdeck firewall bridge)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! "$NFT_CMD" -c -f "$RULES_FILE"; then
|
||||
echo "ERROR: ruleset failed validation — nothing was loaded" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$NFT_CMD" -f "$RULES_FILE"
|
||||
log_info "Firewall loaded."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -208,8 +208,6 @@ build_ruleset() {
|
|||
# AirWall: ${AIRWALL}
|
||||
# Flow offload: ${FLOW_OFFLOAD}
|
||||
|
||||
flush table inet ${TABLE_NAME} 2>/dev/null
|
||||
|
||||
table inet ${TABLE_NAME} {
|
||||
# ── Sets ─────────────────────────────────────────────────────────
|
||||
set ssh_abuse { type ipv4_addr; flags interval; timeout 1h; }
|
||||
|
|
@ -244,14 +242,19 @@ table inet ${TABLE_NAME} {
|
|||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate 50/second burst 100 packets accept
|
||||
ct state new tcp flags & (fin|syn|rst|ack) == syn drop
|
||||
# Flood control only — the accept decisions stay with the zone
|
||||
# rules below, so no rate-limited blanket SYN accept exists here.
|
||||
ct state new tcp flags & (fin|syn|rst|ack) == syn limit rate over 50/second burst 100 packets counter drop comment "SYN flood rate limit"
|
||||
|
||||
ip protocol icmp icmp type echo-request limit rate 5/second accept
|
||||
ip6 nexthdr icmpv6 icmpv6 type { echo-request, nd-neighbor-solicit, nd-router-advert } accept
|
||||
|
||||
iifname "$RED_IF" tcp dport { $red_tcp_in } accept comment "RED inbound TCP"
|
||||
iifname "$RED_IF" udp dport { $red_udp_in } accept comment "RED inbound UDP"
|
||||
# Full IPv6 control-plane set: NDP (solicit + advert, both
|
||||
# router and neighbor) and PMTUD errors — without these, on-link
|
||||
# IPv6 and path MTU discovery silently break.
|
||||
ip6 nexthdr icmpv6 icmpv6 type { destination-unreachable, packet-too-big, time-exceeded, parameter-problem, echo-request, nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert } accept
|
||||
|
||||
$( [[ -n "$red_tcp_in" ]] && echo " iifname \"$RED_IF\" tcp dport { $red_tcp_in } accept comment \"RED inbound TCP\"" )
|
||||
$( [[ -n "$red_udp_in" ]] && echo " iifname \"$RED_IF\" udp dport { $red_udp_in } accept comment \"RED inbound UDP\"" )
|
||||
$( [[ -n "$GREEN_IF" ]] && echo "iifname \"$GREEN_IF\" ip saddr @green_net tcp dport { $green_tcp_in } accept comment \"GREEN inbound (source-verified)\"" )
|
||||
$( [[ -n "$GREEN_IF" ]] && echo "iifname \"$GREEN_IF\" ip saddr @green_net udp dport { $green_udp_in } accept comment \"GREEN inbound (source-verified)\"" )
|
||||
$( [[ -n "$ORANGE_IF" ]] && echo "iifname \"$ORANGE_IF\" ip saddr @orange_net tcp dport { $orange_tcp_in } accept comment \"ORANGE inbound (source-verified)\"" )
|
||||
|
|
@ -269,8 +272,10 @@ table inet ${TABLE_NAME} {
|
|||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
|
||||
# SYN flood protection on RED ingress (synproxy).
|
||||
iifname "$RED_IF" tcp flags syn notrack accept comment "synproxy: pass new SYN to synproxy chain"
|
||||
# Flood control on RED ingress: drop the excess, never blanket
|
||||
# accept — the DMZ port-forwards below are the only RED -> ORANGE
|
||||
# paths and each one carries an explicit dport + daddr.
|
||||
iifname "$RED_IF" ct state new limit rate over 100/second burst 200 packets counter drop comment "RED new-connection flood limit"
|
||||
|
||||
# ── Zone-to-zone matrix (source-verified) ──────────────────────
|
||||
# GREEN -> RED : allow
|
||||
|
|
@ -311,12 +316,6 @@ EOF
|
|||
|
||||
cat <<EOF
|
||||
}
|
||||
|
||||
# ── synproxy chain (SYN flood mitigation) ────────────────────────
|
||||
chain synproxy {
|
||||
tcp flags syn notrack accept
|
||||
}
|
||||
|
||||
# ── Output ───────────────────────────────────────────────────────
|
||||
chain output {
|
||||
type filter hook output priority filter; policy accept;
|
||||
|
|
@ -393,9 +392,15 @@ fw_start() {
|
|||
|
||||
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"
|
||||
# mktemp, never a fixed /tmp path a local user could pre-place
|
||||
local failed_nft
|
||||
failed_nft="$(mktemp /tmp/sysdeck-fw-failed.XXXXXX.nft)" || die "mktemp failed"
|
||||
cp "$RULES_FILE" "$failed_nft"
|
||||
die "Validation failed. Ruleset saved to ${failed_nft}"
|
||||
fi
|
||||
# Fresh table on every apply: the shell owns the redirect, and a
|
||||
# missing table is not an error here (first apply).
|
||||
"$NFT_CMD" delete table inet "${TABLE_NAME}" 2>/dev/null || true
|
||||
|
||||
log_info "Loading..."
|
||||
if "$NFT_CMD" -f "$RULES_FILE"; then
|
||||
|
|
|
|||
|
|
@ -605,7 +605,10 @@ generate_rules() {
|
|||
# SSH: ${SSH_DETECTED:+:${SSH_PORT}} ${FORGEJO_DETECTED:+Forgejo SSH: :${FORGEJO_SSH_PORT}}
|
||||
# Web: ${VARNISH_DETECTED:+Varnish:${VARNISH_PORT} -> }Caddy:${CADDY_HTTP_PORT}/${CADDY_HTTPS_PORT}
|
||||
|
||||
flush ruleset
|
||||
# Table-scoped reset: foreign tables (docker, libvirt, systemd-networkd)
|
||||
# are not ours to destroy.
|
||||
add table inet ${TABLE_NAME}
|
||||
flush table inet ${TABLE_NAME}
|
||||
|
||||
table inet ${TABLE_NAME} {
|
||||
|
||||
|
|
@ -699,16 +702,17 @@ table inet ${TABLE_NAME} {
|
|||
ip6 nexthdr icmpv6 drop comment "ICMPv6 rejected"
|
||||
|
||||
# ---- Connection rate limiting ----
|
||||
ct state new limit rate 50/second burst 100 accept
|
||||
ct state new add @connlimit_abuse ip saddr drop comment "Connlimit exceeded"
|
||||
ct state new limit rate over 50/second burst 100 add @connlimit_abuse { ip saddr } counter drop comment "Connlimit exceeded"
|
||||
|
||||
# ---- SSH with brute-force protection ----
|
||||
$(if [[ "$SSH_DETECTED" == "yes" ]]; then echo "
|
||||
# SSH - aggressive protection (pubkey-only assumed)
|
||||
tcp dport $SSH_PORT ip saddr @ssh_abuse drop comment \"SSH banned\"
|
||||
tcp dport $SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 \
|
||||
$(if [[ "$SYNPROXY_ENABLED" == "yes" ]]; then echo "synproxy mss 1460 wscale 7 timestamp sack-perm"; else echo "accept"; fi)
|
||||
tcp dport $SSH_PORT ct state new add @ssh_abuse ip saddr drop comment \"SSH brute force\"
|
||||
# A verdict-less synproxy statement here let every in-rate SSH
|
||||
# login fall through to the ban rule below; the rate limit +
|
||||
# ban set is the protection this chain ships.
|
||||
tcp dport $SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept
|
||||
tcp dport $SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"SSH brute force\"
|
||||
"; fi)
|
||||
|
||||
# ---- Forgejo SSH (separate from system SSH) ----
|
||||
|
|
@ -716,7 +720,7 @@ table inet ${TABLE_NAME} {
|
|||
# Forgejo SSH
|
||||
tcp dport $FORGEJO_SSH_PORT ip saddr @ssh_abuse drop comment \"Forgejo SSH banned\"
|
||||
tcp dport $FORGEJO_SSH_PORT ct state new limit rate ${ssh_rate_limit} burst 5 accept
|
||||
tcp dport $FORGEJO_SSH_PORT ct state new add @ssh_abuse ip saddr drop comment \"Forgejo SSH brute force\"
|
||||
tcp dport $FORGEJO_SSH_PORT ct state new add @ssh_abuse { ip saddr } drop comment \"Forgejo SSH brute force\"
|
||||
"; fi)
|
||||
|
||||
# ---- Public TCP services ----
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# Upstream: https://dcos.net
|
||||
|
||||
pkgname=sysdeck
|
||||
pkgver=0.4.4
|
||||
pkgver=0.4.5
|
||||
pkgrel=1
|
||||
pkgdesc="Unified operations surface for Linux infrastructure — the Cockpit plugin edition: 27 domain modules plus the Python bridge (the standalone web console ships in the master tarball)"
|
||||
arch=('any')
|
||||
|
|
@ -43,35 +43,15 @@ optdepends=(
|
|||
)
|
||||
makedepends=('make')
|
||||
backup=()
|
||||
install=sysdeck.install
|
||||
source=("${pkgname}-${pkgver}.tar.bz2")
|
||||
sha256sums=('SKIP') # Replace with actual hash for release
|
||||
sha256sums=('e579dc82aca5f3def1d6a3df6bcc7671b9dd3b30e78dd710f6ea14a93f3c176b')
|
||||
|
||||
package() {
|
||||
cd "${srcdir}/${pkgname}-${pkgver}"
|
||||
make install DESTDIR="${pkgdir}"
|
||||
|
||||
# Arch-specific: ensure cockpit can find the bridge Python package.
|
||||
# The bridge installs to /usr/lib/sysdeck/bridge/; add a symlink
|
||||
# from the Arch Python site-packages so `python3 -m sysdeck.bridge.*`
|
||||
# resolves correctly from the cockpit spawn context.
|
||||
local site_packages
|
||||
site_packages=$(python3 -c "import site; print(site.getsitepackages()[0])")
|
||||
install -d "${pkgdir}${site_packages}"
|
||||
ln -sf /usr/lib/sysdeck/bridge "${pkgdir}${site_packages}/sysdeck"
|
||||
}
|
||||
|
||||
post_install() {
|
||||
# Restart cockpit.socket so the new plugin appears in the menu.
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
echo ">>> SysDeck installed. Restart cockpit.socket if not done automatically."
|
||||
echo ">>> sudo systemctl restart cockpit.socket"
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
echo ">>> SysDeck removed. Restart cockpit.socket to flush the menu."
|
||||
# No python site-packages symlink: the bridge's invocation contract
|
||||
# is `python3 /usr/lib/sysdeck/bridge/<mod>.py <sub>` by absolute
|
||||
# path (check-no-broken-python-module enforces it), so no import
|
||||
# path needs to resolve.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,49 @@
|
|||
sysdeck (0.4.5-1) unstable; urgency=medium
|
||||
|
||||
* PRODUCTION-HARDENING RELEASE — full MoE QA pass (web designers,
|
||||
backend, JS/React/Next, CSS, UI/UX, algorithms, Linux systems,
|
||||
DevOps).
|
||||
* Build: the web-dev recipe is attached to its own target (a spliced
|
||||
recipe ran fester + the dev console from uninstall-branding); master
|
||||
tarball excludes dev/server logs and .env; dist is reproducible
|
||||
(sorted, pinned mtime/owner) and gated on a clean git tree when one
|
||||
exists; distcheck uses mktemp; recipes quote every rm path.
|
||||
* Bridge: prometheus push-log validates the module filename and escapes
|
||||
exposition labels (root-level path traversal + metric-line
|
||||
injection closed); db query admits one read-only statement (no
|
||||
batches, no CLI meta-commands; sqlite refuses honestly instead of
|
||||
querying nothing); glances snapshot family degrades to
|
||||
{available:false} with a timeout; package mutations survive the
|
||||
600s timeout; vmdb2 builds carry the mkosi output-dir guard; theme
|
||||
variable values are allowlisted against CSS injection; auth/vault/
|
||||
firmware/fleet/integrity helpers run with hard timeouts; kerberos
|
||||
status derives from the real TGT end time; unwhitelist matches
|
||||
exactly; modules3p renders usage errors as JSON.
|
||||
* Web console: theme switching goes through applySdTheme (light
|
||||
themes keep their palette, the mirror stays in sync); cockpit
|
||||
modules table renders valid rows; overview reports the registry
|
||||
version and a live tarball link; fester health probes are cached
|
||||
and single-flight; usePoll rejects stale responses; the session
|
||||
clock is hydration-safe; tsc + eslint run as build gates (both
|
||||
green).
|
||||
* Firewall templates: all six nftables rulesets validate
|
||||
structurally; table-scoped flush replaces `flush ruleset` on every
|
||||
template (docker/libvirt tables survive an apply); no-services
|
||||
gains loopback accept, valid jump syntax, braced set-adds, and the
|
||||
SSH-port fix that previously locked operators out; vps-webserver SSH
|
||||
rule carries an explicit verdict; the Cilium default policy scopes
|
||||
its HTTP method filter to 80/443 and resolves DNS in standalone
|
||||
mode.
|
||||
* Packaging: RPM %files matches the 27-plugin install and the spec
|
||||
builds noarch; debian gains the nodejs build dependency and stops
|
||||
recommending media/virtualization stacks; pacman hooks live in
|
||||
sysdeck.install; AppStream addon extends the cockpit component;
|
||||
Caddyfile's port gateway is a 3010 allowlist.
|
||||
* Comments state standing decisions in the present tense throughout
|
||||
the first-party tree.
|
||||
|
||||
-- Jeremy Anderson <info@dcos.net> Thu, 17 Sep 2026 10:00:00 -0400
|
||||
|
||||
sysdeck (0.4.4-1) unstable; urgency=medium
|
||||
|
||||
* v0.4.4: ten package-manager backends on both editions + the blog
|
||||
|
|
@ -125,9 +171,9 @@ sysdeck (0.2.0-1) unstable; urgency=medium
|
|||
real REST client (11 subcommands, FESTER_URL override, graceful
|
||||
offline JSON); plugins/sysdeck-fester is a full panel; the shared
|
||||
bridge.js fester surface grew from 1 method to 11.
|
||||
* Makefile: restored tab-indented recipes (the shipped 0.1.3 Makefile
|
||||
used 8-space indents and GNU make rejected it with "missing
|
||||
separator"); new targets fester-start, web-install, web-dev, master.
|
||||
* Makefile: recipes are tab-indented (GNU make rejects 8-space indents
|
||||
with "missing separator"; a build-time guard enforces it); new targets
|
||||
fester-start, web-install, web-dev, master.
|
||||
|
||||
-- Jeremy Anderson <info@dcos.net> Thu, 20 Aug 2026 12:00:00 -0400
|
||||
|
||||
|
|
@ -1860,7 +1906,7 @@ sysdeck (0.0.35-1) unstable; urgency=medium
|
|||
to plugins/sysdeck-kata/, converted from a tools-section
|
||||
manifest entry to a menu-section entry (label "SysDeck Kata",
|
||||
order 27), removed the "hidden helper" wording, dropped the
|
||||
priority -1, and restored a dedicated keywords list. The
|
||||
priority -1, and carries a dedicated keywords list. The
|
||||
Containers panel now manages Podman only — the Kata tab and
|
||||
its iframe were removed. The pre-built cockpit-kata React
|
||||
bundle (index.js + index.css) is shipped unchanged.
|
||||
|
|
@ -2073,11 +2119,10 @@ sysdeck (0.0.33-1) unstable; urgency=medium
|
|||
mkdir, mount, ip, bpftool, lsns, aa-enforce, aa-complain,
|
||||
aa-status).
|
||||
* DOCUMENTATION REWRITE. README.md, QUICKSTART.md, BLOG.md, and
|
||||
LICENSE rewritten with decisive language. Every design choice
|
||||
is documented as a decision, not as history. Removed "restored"
|
||||
/ "brought back" / "was dropped" / "surviving artifact" wording
|
||||
from active code comments and current-version docs (historical
|
||||
release narratives in BLOG.md are preserved as record).
|
||||
LICENSE state every design choice as a standing decision in the
|
||||
present tense; active code comments and current-version docs
|
||||
carry no development-churn narration (release history stays in
|
||||
the changelog, where it belongs).
|
||||
* STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33
|
||||
entry — three options (implement anyway / skip entirely / detect
|
||||
only) considered; option 2 won because it composes best with the
|
||||
|
|
@ -2124,10 +2169,9 @@ sysdeck (0.0.32-1) unstable; urgency=medium
|
|||
probably around version 18 if i had to guess." Confirmed via
|
||||
BLOG.md — the DB Control module was added in v0.0.15 alongside
|
||||
Prometheus and Grafana. The v0.0.20 architectural overhaul
|
||||
split SysDeck into 18 standalone plugins and the DB plugin
|
||||
panel got dropped in the cut — only 18 made the list, even
|
||||
though the bridge helper survived. v0.0.32 restores the
|
||||
plugin panel.
|
||||
split SysDeck into 18 standalone plugins and the DB panel
|
||||
did not make that list of 18, even though its bridge helper
|
||||
remained in the tree. v0.0.32 ships the plugin panel.
|
||||
v0.0.32 also fixes the v0.0.15-era `sudo systemctl` shell-out
|
||||
in cmd_start / cmd_stop / cmd_restart — the cockpit way (per
|
||||
v0.0.31 pattern) is to run systemctl directly via subprocess
|
||||
|
|
@ -2406,9 +2450,9 @@ sysdeck (0.0.28-1) unstable; urgency=high
|
|||
Cross-checks every bridgeCmd("<module>", ["<sub>", ...]) call in
|
||||
shared/bridge.js against the COMMANDS dict declared in each
|
||||
bridge/<module>.py. Would have caught both bugs above.
|
||||
Regression-tested: reverting firmware.py to its v0.0.27 state
|
||||
causes the guard to fail with a clear message naming the file,
|
||||
line, and missing subcommand.
|
||||
Negative-tested: a summary-only firmware.py (the v0.0.27 shape)
|
||||
fails the guard with a clear message naming the file, line, and
|
||||
missing subcommand.
|
||||
* FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
|
||||
.suite-progress, .suite-progress-bar, .suite-progress-fill,
|
||||
.suite-stat-value, .suite-stat-label, .suite-row, .suite-row-between,
|
||||
|
|
@ -2549,8 +2593,8 @@ sysdeck (0.0.23-1) unstable; urgency=high
|
|||
`import cockpit from "cockpit"` to `module.exports = cockpit`.
|
||||
* New guard: check-no-broken-cockpit-import in `make check` scans every
|
||||
JS file in plugins/ and shared/ for the broken `import cockpit from`
|
||||
pattern. Regression-tested: deliberately reintroducing the v0.0.22
|
||||
import causes `make check` to fail with a clear message.
|
||||
pattern. Negative-tested: the v0.0.22 import form makes `make
|
||||
check` fail with a clear message.
|
||||
|
||||
-- Jeremy Anderson <info@dcos.net> Sun, 17 Aug 2026 09:00:00 -0500
|
||||
|
||||
|
|
@ -2707,10 +2751,12 @@ sysdeck (0.0.16-1) unstable; urgency=high
|
|||
|
||||
sysdeck (0.0.15-1) unstable; urgency=low
|
||||
|
||||
* RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py
|
||||
silently dropped from v0.0.13 release; restored here.
|
||||
* RESTORE: nextjs-dashboard/ and prometheus/ directories restored
|
||||
to source tree and included in tarball + install target.
|
||||
* SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and
|
||||
bridge/prometheus.py ship in the tarball (the v0.0.13 tarball
|
||||
omitted them).
|
||||
* SOURCE COMPLETENESS: nextjs-dashboard/ and prometheus/
|
||||
directories ship in the source tree, tarball, and install
|
||||
target.
|
||||
* Makefile install target now installs prometheus configs to
|
||||
/etc/sysdeck/prometheus/ and Next.js dashboard to
|
||||
/usr/share/sysdeck/nextjs-dashboard/.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ Source: sysdeck
|
|||
Section: admin
|
||||
Priority: optional
|
||||
Maintainer: Jeremy Anderson <info@dcos.net>
|
||||
Build-Depends: debhelper-compat (= 13), make, python3 (>= 3.9)
|
||||
# nodejs: `make check` syntax-checks every shipped JS file via
|
||||
# `node --check` — a clean sbuild chroot needs it at build time.
|
||||
Build-Depends: debhelper-compat (= 13), make, python3 (>= 3.9), nodejs
|
||||
Standards-Version: 4.7.0
|
||||
Homepage: https://dcos.net
|
||||
Vcs-Browser: https://dcos.net
|
||||
|
|
@ -11,8 +13,8 @@ Vcs-Git: https://dcos.net/sysdeck.git
|
|||
Package: sysdeck
|
||||
Architecture: all
|
||||
Depends: cockpit-bridge (>= 239), python3 (>= 3.9), ${misc:Depends}
|
||||
Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream, kata-containers, jellyfin
|
||||
Suggests: kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server
|
||||
Recommends: podman, nftables, lynis, iproute2, fwupd, tpm2-tools, opensc, pcscd, glances, lm-sensors, sysbench, polkitd, appstream
|
||||
Suggests: kata-containers, jellyfin, kubectl, cryptsetup, mkosi, vmdb2, archiso, live-build, photoprism, piwigo, lychee, librephotos, nextcloud-server, ceph, glusterfs-server, moosefs-master, beegfs-meta, orangefs-server
|
||||
Description: Unified operations surface for Linux infrastructure
|
||||
SysDeck is a standalone Linux operations console that also ships as a
|
||||
Cockpit plugin suite. This package installs the Cockpit plugin edition:
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import shutil
|
|||
import subprocess
|
||||
from setuptools import setup
|
||||
|
||||
VERSION = "0.4.4"
|
||||
VERSION = "0.4.5"
|
||||
PACKAGE = "sysdeck"
|
||||
|
||||
# The repository root (setup.py lives in packaging/).
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
# SysDeck pacman hooks
|
||||
# Restart cockpit.socket so plugin changes appear in the menu.
|
||||
|
||||
post_install() {
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
echo ">>> SysDeck installed. Restart cockpit.socket if not done automatically:"
|
||||
echo ">>> sudo systemctl restart cockpit.socket"
|
||||
}
|
||||
|
||||
post_upgrade() {
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
}
|
||||
|
||||
post_remove() {
|
||||
systemctl try-restart cockpit.socket 2>/dev/null || true
|
||||
echo ">>> SysDeck removed. Restart cockpit.socket to flush the menu."
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
-->
|
||||
<component type="addon">
|
||||
<id>net.dcos.sysdeck</id>
|
||||
<extends>org.cockpit_project.cockpit</extends>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>SysDeck</name>
|
||||
|
|
@ -80,7 +81,7 @@
|
|||
plugin's manifest.json. The cockpit apps page (pkg/apps/utils.tsx:152)
|
||||
reads launchable.type == "cockpit-manifest" and uses the text to
|
||||
link the AppStream component to the corresponding Cockpit plugin.
|
||||
v0.0.35: SysDeck Kata restored as its own sidebar entry; added
|
||||
v0.0.35: SysDeck Kata as its own sidebar entry; added
|
||||
sysdeck-jellyfin, sysdeck-photos, sysdeck-remotefs.
|
||||
v0.0.46: added sysdeck-modules (in-suite 3rd-party module installer).
|
||||
v0.0.47: added sysdeck-services (service/port editor promoted to
|
||||
|
|
@ -131,6 +132,18 @@
|
|||
</keywords>
|
||||
<content_rating type="oars-1.1" />
|
||||
<releases>
|
||||
<release version="0.4.5" date="2026-09-17">
|
||||
<description>
|
||||
<p>v0.4.5: production-hardening release from a full
|
||||
Mixture-of-Experts QA pass — security fixes in the bridge
|
||||
(filename validation, single-statement SQL guard, CSS value
|
||||
allowlist, subprocess timeouts), six structurally validated
|
||||
nftables firewall templates with table-scoped flush, the web
|
||||
console enforcing its type and lint gates, and packaging that
|
||||
builds cleanly on all three distro paths. Comments state
|
||||
standing decisions in the present tense.</p>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.4.4" date="2026-09-12">
|
||||
<description>
|
||||
<p>v0.4.4: ten package managers on both editions — pacman,
|
||||
|
|
@ -660,7 +673,7 @@
|
|||
plugins/sysdeck-containers-kata/ to plugins/sysdeck-kata/,
|
||||
converted from a "tools" manifest entry to a "menu" entry
|
||||
(label "SysDeck Kata", order 27), removed the "hidden helper"
|
||||
wording, dropped the priority -1, and restored a dedicated
|
||||
wording, dropped the priority -1, and added a dedicated
|
||||
keywords list. The Containers panel now manages Podman only —
|
||||
the Kata tab and its iframe were removed. The pre-built
|
||||
cockpit-kata React bundle (index.js + index.css) is shipped
|
||||
|
|
@ -814,7 +827,7 @@
|
|||
<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/<module>.py. Would have
|
||||
caught both bugs above. Regression-tested: reverting firmware.py to
|
||||
caught both bugs above. Negative-tested: a summary-only firmware.py
|
||||
its v0.0.27 state causes the guard to fail with a clear message.</p>
|
||||
<p>FIXED MISSING CSS CLASSES: shared/sysdeck.css was missing
|
||||
.suite-progress, .suite-progress-bar, .suite-progress-fill,
|
||||
|
|
|
|||
|
|
@ -7,16 +7,18 @@
|
|||
# Debian/Ubuntu users: see packaging/debian/
|
||||
|
||||
Name: sysdeck
|
||||
Version: 0.4.4
|
||||
Version: 0.4.5
|
||||
Release: 1%{?dist}
|
||||
Summary: Unified operations surface for Linux infrastructure
|
||||
|
||||
License: MIT
|
||||
URL: https://dcos.net
|
||||
Source0: %{name}-%{version}.tar.bz2
|
||||
BuildArch: noarch
|
||||
|
||||
BuildRequires: make
|
||||
BuildRequires: python3-devel
|
||||
BuildRequires: nodejs
|
||||
Requires: cockpit-bridge >= 239
|
||||
Recommends: podman
|
||||
Recommends: nftables
|
||||
|
|
@ -27,10 +29,10 @@ Recommends: tpm2-tools
|
|||
Recommends: glances
|
||||
Recommends: lm_sensors
|
||||
Recommends: sysbench
|
||||
Recommends: kata-containers
|
||||
Recommends: jellyfin
|
||||
Recommends: ceph
|
||||
Recommends: glusterfs
|
||||
Suggests: kata-containers
|
||||
Suggests: jellyfin
|
||||
Suggests: ceph
|
||||
Suggests: glusterfs
|
||||
|
||||
%description
|
||||
SysDeck is a standalone Linux operations console that also ships as a
|
||||
|
|
@ -62,16 +64,19 @@ make install DESTDIR=%{buildroot}
|
|||
%files
|
||||
%license LICENSE
|
||||
%doc README.md QUICKSTART.md BLOG.md QA.md
|
||||
/usr/share/cockpit/%{name}/manifest.json
|
||||
/usr/share/cockpit/%{name}/index.html
|
||||
/usr/share/cockpit/%{name}/suite.js
|
||||
/usr/share/cockpit/%{name}/suite.css
|
||||
/usr/share/cockpit/%{name}/logo.svg
|
||||
/usr/share/cockpit/%{name}/src/
|
||||
/usr/lib/%{name}/bridge/
|
||||
/usr/lib/%{name}/tests/
|
||||
# The install target ships: 27 plugins under
|
||||
# /usr/share/cockpit/sysdeck-*/ (manifest.json, index.html, module js),
|
||||
# the sysdeck-common bridge library, the Python bridge at
|
||||
# /usr/lib/sysdeck/bridge/, tests, docs, share assets (diagnose +
|
||||
# smoke + uninstall scripts, firewall templates + policies, prometheus
|
||||
# configs), AppStream metainfo, and both polkit actions.
|
||||
/usr/share/cockpit/sysdeck-*/
|
||||
/usr/lib/%{name}/
|
||||
/usr/share/%{name}/
|
||||
/usr/share/doc/%{name}/
|
||||
/usr/share/metainfo/sysdeck.metainfo.xml
|
||||
/usr/share/polkit-1/actions/org.sysdeck.policy
|
||||
/usr/share/polkit-1/actions/org.sysdeck.modules3p.policy
|
||||
|
||||
%post
|
||||
# Restart cockpit.socket so the new plugin appears in the menu.
|
||||
|
|
@ -87,6 +92,23 @@ if [ $1 -eq 0 ]; then
|
|||
fi
|
||||
|
||||
%changelog
|
||||
* Thu Sep 17 2026 Jeremy Anderson <info@dcos.net> - 0.4.5-1
|
||||
- v0.4.5 production-hardening release: full MoE QA pass. Makefile
|
||||
web-dev splice fixed; reproducible dist + clean-tree release gate;
|
||||
master tarball hygiene (no dev logs, no .env). Bridge security:
|
||||
prometheus push-log filename validation + label escaping, single-
|
||||
statement read-only SQL guard, glances availability step-down,
|
||||
mutation timeout safety, vmdb2 output-dir guard, theme CSS value
|
||||
allowlist, subprocess timeouts across helpers. Web: applySdTheme on
|
||||
every theme path, valid table rows, registry-sourced version
|
||||
surface, cached fester probes, tsc+eslint as build gates. Firewall:
|
||||
six structurally validated nftables rulesets, table-scoped flush
|
||||
everywhere, no-services loopback+SSH fixes, vps SSH verdict fix,
|
||||
Cilium policy scoping. Packaging: noarch spec with %files matching
|
||||
the 27-plugin install, nodejs build dependency, sysdeck.install
|
||||
hooks, AppStream extends, Caddyfile port allowlist. Decisive
|
||||
comment language across the first-party tree.
|
||||
|
||||
* Sat Sep 12 2026 Jeremy Anderson <info@dcos.net> - 0.4.4-1
|
||||
- v0.4.4: package parity + blog essay. Ten package-manager backends on
|
||||
both editions (bridge/packages.py gains emerge/lunar/sorcery/xbps/
|
||||
|
|
@ -134,7 +156,6 @@ fi
|
|||
HMAC session cookie, gated API routes, fester WS session check,
|
||||
audited logins) — the 0.3.0 audit follow-through.
|
||||
|
||||
%changelog
|
||||
* Fri Sep 11 2026 Jeremy Anderson <info@dcos.net> - 0.3.0-1
|
||||
- v0.3.0 AI GATEWAY EDITION: klanker-gate (Frosty Deno LLM gateway,
|
||||
independent version 0.9.0) vendored at /klanker-gate with full Arch
|
||||
|
|
@ -153,9 +174,9 @@ fi
|
|||
- bridge/fester.py: real REST client (11 subcommands) replacing the
|
||||
v0.0.31 systemd-listing stub; fester panel fully built; bridge.js
|
||||
fester surface 1 -> 11 methods.
|
||||
- Makefile: tab-indented recipes restored (0.1.3 shipped 8-space indents
|
||||
that GNU make rejected); new targets fester-start, web-install,
|
||||
web-dev, master.
|
||||
- Makefile: recipes are tab-indented (GNU make rejects the 8-space
|
||||
indent form; a build-time guard enforces tabs); new targets
|
||||
fester-start, web-install, web-dev, master.
|
||||
* Tue Aug 19 2026 Jeremy Anderson <info@dcos.net> - 0.1.3-1
|
||||
- v0.1.3 CRITICAL FIX: host package import was silently failing + mkosi
|
||||
still wasn't reading the profile config. Two root causes fixed, plus
|
||||
|
|
@ -1030,11 +1051,10 @@ fi
|
|||
setcap, getcap (in addition to v0.0.32 set: setfacl, getfacl, mkdir,
|
||||
mount, ip, bpftool, lsns, aa-enforce, aa-complain, aa-status).
|
||||
- DOCUMENTATION REWRITE: README.md, QUICKSTART.md, BLOG.md, LICENSE
|
||||
rewritten with decisive language. Every design choice recorded as a
|
||||
decision, not as history. Removed "restored/brought back/was dropped/
|
||||
surviving artifact" wording from active code comments and current-
|
||||
version docs (historical release narratives in BLOG.md preserved as
|
||||
record).
|
||||
state every design choice as a standing decision in the present
|
||||
tense; active code comments and current-version docs carry no
|
||||
development-churn narration (release history stays in the changelog,
|
||||
where it belongs).
|
||||
- STEP-DOWN LOGIC: SELinux decision documented in BLOG.md v0.0.33
|
||||
entry — three options considered (implement / skip / detect-only);
|
||||
option 2 won because it composes best with the rest of the system.
|
||||
|
|
@ -1287,8 +1307,8 @@ fi
|
|||
real data. With v0.0.26 the other 15 modules should now load real data.
|
||||
- New guard: check-no-broken-python-module in `make check` scans every JS
|
||||
file for spawn calls using `python3 -m sysdeck.bridge` and fails with a
|
||||
clear message. Regression-tested: deliberately reintroducing the broken
|
||||
pattern causes `make check` to fail.
|
||||
clear message. Negative-tested: the broken pattern makes `make check`
|
||||
fail.
|
||||
|
||||
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.25-1
|
||||
- ROOT CAUSE FOUND AND FIXED: every plugin page showed "Module load
|
||||
|
|
@ -1354,9 +1374,8 @@ fi
|
|||
rewrites `import cockpit from "cockpit"` to `module.exports = cockpit`.
|
||||
- New guard: check-no-broken-cockpit-import in `make check` scans every
|
||||
JS file in plugins/ and shared/ for the broken `import cockpit from`
|
||||
pattern. Regression-tested: deliberately reintroducing the v0.0.22
|
||||
import causes `make check` to fail with a clear message citing the
|
||||
cockpit source code.
|
||||
pattern. Negative-tested: the v0.0.22 import form makes `make check`
|
||||
fail with a clear message citing the cockpit source code.
|
||||
|
||||
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.22-1
|
||||
- ROOT CAUSE FOUND AND FIXED: the `requires.cockpit` field was set to
|
||||
|
|
@ -1516,17 +1535,17 @@ fi
|
|||
at build time.
|
||||
|
||||
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.15-1
|
||||
- RESTORE: bridge/grafana.py, bridge/hwalert.py, bridge/prometheus.py
|
||||
were silently dropped from the v0.0.13 release tarball and shipped
|
||||
missing through v0.0.14. All three are restored (1489 lines of code).
|
||||
- RESTORE: nextjs-dashboard/ directory (standalone Next.js variant
|
||||
dashboard) and prometheus/ config directory (Prometheus + Grafana
|
||||
YAML configs) are restored to the source tree.
|
||||
- SOURCE COMPLETENESS: bridge/grafana.py, bridge/hwalert.py, and
|
||||
bridge/prometheus.py ship in the tarball (the v0.0.13-v0.0.14
|
||||
tarballs omitted them; 1489 lines of code).
|
||||
- SOURCE COMPLETENESS: nextjs-dashboard/ directory (standalone Next.js
|
||||
variant dashboard) and prometheus/ config directory (Prometheus +
|
||||
Grafana YAML configs) ship in the source tree.
|
||||
- Makefile dist target now includes prometheus/ and nextjs-dashboard/.
|
||||
- Makefile install target now installs prometheus configs to
|
||||
/etc/sysdeck/prometheus/ and the Next.js dashboard to
|
||||
/usr/share/sysdeck/nextjs-dashboard/.
|
||||
- Tarball size restored to ~280 KB (was 80 KB in v0.0.14 due to
|
||||
- Tarball size back at ~280 KB (v0.0.14 shipped an 80 KB tarball due to
|
||||
the dropped code).
|
||||
|
||||
* Sun Aug 17 2026 Jeremy Anderson <info@dcos.net> - 0.0.14-1
|
||||
|
|
|
|||
|
|
@ -120,8 +120,8 @@ export async function mount(panel, { bridge, EventBus }) {
|
|||
<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>`}
|
||||
? `${escapeHtml(primary.id)} ${escapeHtml(primary.version || '')} — <span class="suite-badge success">${escapeHtml(summary.state)}</span>`
|
||||
: `no backend installed — <span class="suite-badge danger">${escapeHtml(summary.state)}</span>`}
|
||||
· ${builds.length} build${builds.length === 1 ? '' : 's'} tracked
|
||||
· ${artifacts.artifacts || 0} artifact${(artifacts.artifacts || 0) === 1 ? '' : 's'}
|
||||
</p>
|
||||
|
|
@ -172,10 +172,10 @@ function renderBackends(backends, primary) {
|
|||
<ul class="suite-list">
|
||||
${backends.map(b => `
|
||||
<li class="suite-mono">
|
||||
<strong>${b.id}</strong>
|
||||
<strong>${escapeHtml(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>
|
||||
<span class="suite-muted">(${escapeHtml(b.kind)})</span>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
|
|
@ -309,7 +309,7 @@ function renderCreateProfile(backends, primary) {
|
|||
`;
|
||||
}
|
||||
const backendOptions = scaffoldable
|
||||
.map(b => `<option value="${b.id}">${b.id}</option>`).join('');
|
||||
.map(b => `<option value="${escapeHtml(b.id)}">${escapeHtml(b.id)}</option>`).join('');
|
||||
return `
|
||||
<div class="suite-card">
|
||||
<div class="suite-card-header">
|
||||
|
|
@ -820,22 +820,14 @@ function wireEvents(panel, { bridge, EventBus }) {
|
|||
btn.disabled = true;
|
||||
btn.textContent = '⏳ ...';
|
||||
try {
|
||||
// Read the file via cockpit.spawn cat. We use binary mode
|
||||
// by reading as a binary stream. cockpit.spawn returns a
|
||||
// channel that we collect into a byte array.
|
||||
const channel = cockpit.spawn(["cat", path], {
|
||||
// cockpit.spawn returns a promise; in binary mode it
|
||||
// resolves to the full byte payload — the documented API,
|
||||
// not the low-level channel surface.
|
||||
const data = await cockpit.spawn(["cat", path], {
|
||||
superuser: "try",
|
||||
binary: true,
|
||||
});
|
||||
const chunks = [];
|
||||
channel.ondata = (data) => { chunks.push(data); };
|
||||
await new Promise((resolve, reject) => {
|
||||
channel.onclose = (resp) => {
|
||||
if (resp.exit_status === 0 || resp.exit_status === null) resolve();
|
||||
else reject(new Error(`cat exited ${resp.exit_status}`));
|
||||
};
|
||||
});
|
||||
const blob = new Blob(chunks, { type: 'application/octet-stream' });
|
||||
const blob = new Blob([data], { type: 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
|
|
|||
|
|
@ -8,10 +8,8 @@
|
|||
* user directive: "kata containers should be called SysDeck Kata and
|
||||
* moved out of the tools area."
|
||||
*
|
||||
* v0.0.34 was a merged two-tab panel (Podman + Kata iframe). The
|
||||
* v0.0.35 split restores the one-module-one-concern shape: this
|
||||
* panel manages Podman containers only; the SysDeck Kata plugin
|
||||
* hosts the pre-built cockpit-kata React app for Kata sandboxes & VMs.
|
||||
* One module, one concern: this panel manages Podman containers
|
||||
* only; the SysDeck Kata plugin owns Kata sandboxes & VMs.
|
||||
*
|
||||
* Bridge surface (see shared/bridge.js → bridge.containers):
|
||||
* list() → podman ps --format json (normalized)
|
||||
|
|
@ -21,7 +19,6 @@
|
|||
*/
|
||||
|
||||
export async function mount(panel, { bridge, EventBus }) {
|
||||
panel.innerHTML = renderSkeleton();
|
||||
panel.innerHTML = renderShell();
|
||||
await renderPodmanTable(panel, { bridge, EventBus });
|
||||
EventBus.emit('containers.loaded', {});
|
||||
|
|
@ -40,14 +37,15 @@ function renderShell() {
|
|||
<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-flash" class="suite-badge danger" style="display:none; margin-bottom:0.5rem; padding:0.4rem 0.6rem;"></div>
|
||||
<div id="containers-table-host"></div>
|
||||
</div>
|
||||
<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.
|
||||
Kata Containers (hardware-virtualized OCI sandboxes) lives in its
|
||||
own sidebar entry — <strong>SysDeck Kata</strong> — real sandbox
|
||||
and VM state from the host, not a bundle mock.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -90,6 +88,15 @@ async function renderPodmanTable(panel, { bridge, EventBus }) {
|
|||
EventBus.emit('container.action', { id, action });
|
||||
} catch (err) {
|
||||
EventBus.emit('container.error', { id, error: err.message });
|
||||
// the operator sees the failure where they clicked it —
|
||||
// the table re-render alone would silently swallow it
|
||||
const flash = panel.querySelector('#containers-flash');
|
||||
if (flash) {
|
||||
flash.textContent = `action failed: ${err.message || err}`;
|
||||
flash.style.display = 'inline-block';
|
||||
clearTimeout(panel._containersFlashTimer);
|
||||
panel._containersFlashTimer = setTimeout(() => { flash.style.display = 'none'; }, 4000);
|
||||
}
|
||||
}
|
||||
renderPodmanTable(panel, { bridge, EventBus });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -319,6 +319,6 @@ function renderSkeleton() {
|
|||
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>
|
||||
<p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/db.py.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,13 +65,17 @@ export async function mount(panel, { bridge, EventBus }) {
|
|||
|
||||
// Clean up the interval when the panel leaves the DOM
|
||||
// (house pattern from netsec.js).
|
||||
const observer = new MutationObserver(() => {
|
||||
// one observer per panel: a re-mount releases the previous one
|
||||
// instead of stacking body-wide observers on every refresh
|
||||
if (panel.festerObserver) panel.festerObserver.disconnect();
|
||||
if (panel.festerObserver) panel.festerObserver.disconnect();
|
||||
panel.festerObserver = new MutationObserver(() => {
|
||||
if (!document.body.contains(panel)) {
|
||||
clearInterval(panel._festerInterval);
|
||||
observer.disconnect();
|
||||
panel.festerObserver.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
panel.festerObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// ── refresh loop ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ function renderBackendSelector(backends, excluded, activeBackend, templates, act
|
|||
? '<span class="suite-badge success" style="margin-left:0.25rem">installed</span>'
|
||||
: '<span class="suite-badge 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);' : ''}">
|
||||
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(63,201,176,0.08);' : ''}">
|
||||
<input type="radio" name="fw-backend" value="${escapeHtml(b.id)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
|
||||
<strong>${escapeHtml(b.name)}</strong>
|
||||
${techBadge}
|
||||
|
|
@ -431,7 +431,7 @@ function renderTemplateSelector(templates, activeTemplate, state, activeBackend,
|
|||
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);' : ''}">
|
||||
<label class="suite-template-card ${isActive ? 'active' : ''}" style="display:block;padding:0.75rem;border:1px solid var(--sysdeck-border);border-radius:6px;margin-bottom:0.5rem;cursor:pointer;${isActive ? 'border-color:var(--sysdeck-accent);background:rgba(63,201,176,0.08);' : ''}">
|
||||
<input type="radio" name="fw-template" value="${escapeHtml(t.name)}" ${isActive ? 'checked' : ''} style="margin-right:0.5rem" />
|
||||
<strong>${escapeHtml(t.name)}</strong>
|
||||
${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''}
|
||||
|
|
@ -542,7 +542,7 @@ function renderBans(bans, total) {
|
|||
</tbody>
|
||||
</table>
|
||||
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
|
||||
Ban sets: <code>${sets.join(', ')}</code>.
|
||||
Ban sets: <code>${escapeHtml(sets.join(', '))}</code>.
|
||||
ssh_abuse = SSH brute-force ban (1h timeout),
|
||||
port_scanners = port scan detection (1h timeout),
|
||||
connlimit_abuse = connection rate limit exceeded (10m timeout).
|
||||
|
|
|
|||
|
|
@ -79,10 +79,10 @@ function renderServiceCard(summary, available, running, webUrl) {
|
|||
<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.')}
|
||||
${escapeHtml(summary?.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>`
|
||||
${summary?.install
|
||||
? `<p class="suite-muted" style="margin-top:0.5rem"><code>${escapeHtml(summary.install)}</code></p>`
|
||||
: ''}
|
||||
<p class="suite-muted" style="margin-top:0.5rem">
|
||||
Jellyfin is GPL-2.0 licensed by the Jellyfin contributors —
|
||||
|
|
|
|||
|
|
@ -74,13 +74,17 @@ export async function mount(panel, { bridge, EventBus }) {
|
|||
|
||||
// Clean up the interval when the panel leaves the DOM
|
||||
// (house pattern from netsec.js / fester.js).
|
||||
const observer = new MutationObserver(() => {
|
||||
// one observer per panel: a re-mount releases the previous one
|
||||
// instead of stacking body-wide observers on every refresh
|
||||
if (panel.klankerObserver) panel.klankerObserver.disconnect();
|
||||
if (panel.klankerObserver) panel.klankerObserver.disconnect();
|
||||
panel.klankerObserver = new MutationObserver(() => {
|
||||
if (!document.body.contains(panel)) {
|
||||
clearInterval(panel._klankerInterval);
|
||||
observer.disconnect();
|
||||
panel.klankerObserver.disconnect();
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
panel.klankerObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
// ── refresh loop ────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
border: 1px solid var(--sysdeck-border); border-radius: 4px;
|
||||
padding: 0.35rem 0.5rem; font-size: 0.85rem;
|
||||
}
|
||||
.sysdeck-badge.info { background: rgba(0, 102, 204, 0.18); color: #6cb6ff; }
|
||||
.sysdeck-badge.info { background: rgba(63, 201, 176, 0.14); color: var(--sysdeck-accent, #3fc9b0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -326,7 +326,14 @@ async function refresh(panel, bridge) {
|
|||
try {
|
||||
catalog = await bridge.modules3p.status();
|
||||
} catch (e) {
|
||||
showRowFlash(panel, `Refresh failed: ${e.message || e}`, "danger");
|
||||
// the catalog stays rendered; the failure is a banner, not a
|
||||
// replacement of the whole panel
|
||||
const flash = panel.querySelector('#modules-flash');
|
||||
if (flash) {
|
||||
flash.textContent = `Refresh failed: ${e.message || e}`;
|
||||
flash.style.display = 'block';
|
||||
setTimeout(() => { flash.style.display = 'none'; }, 4000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
panel.innerHTML = renderShell(catalog);
|
||||
|
|
|
|||
|
|
@ -51,11 +51,11 @@
|
|||
transition: all 0.15s;
|
||||
}
|
||||
.monitoring-tab:hover {
|
||||
background: rgba(6,102,204,0.08);
|
||||
background: rgba(63,201,176,0.08);
|
||||
color: var(--sysdeck-fg, #e0e0e0);
|
||||
}
|
||||
.monitoring-tab.active {
|
||||
background: rgba(6,102,204,0.12);
|
||||
background: rgba(63,201,176,0.12);
|
||||
color: var(--sysdeck-accent, #0666cc);
|
||||
border-color: var(--sysdeck-border, #333);
|
||||
border-bottom: 2px solid var(--sysdeck-accent, #0666cc);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
*
|
||||
* The panel has three sections (matching iptraf-ng's views):
|
||||
* 1. Interface Overview — live RX/TX rate cards (bytes/s, packets/s)
|
||||
* per interface. Auto-refreshes every 3s. The traffic subcommand
|
||||
* per interface. Auto-refreshes every 5s. The traffic subcommand
|
||||
* samples /proc/net/dev twice (1s apart) to compute live rates.
|
||||
* 2. IP Traffic Monitor — active TCP/UDP connections table (proto,
|
||||
* state, local addr:port, remote addr:port, TX/RX queue). Reads
|
||||
|
|
|
|||
|
|
@ -247,6 +247,6 @@ function renderSkeleton() {
|
|||
function renderError(err) {
|
||||
return `<div class="suite-card">
|
||||
<h3 class="suite-card-title">Photos bridge unavailable</h3>
|
||||
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.</p>
|
||||
<p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/photos.py.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -291,6 +291,6 @@ function renderSkeleton() {
|
|||
function renderError(err) {
|
||||
return `<div class="suite-card">
|
||||
<h3 class="suite-card-title">Remote FS bridge unavailable</h3>
|
||||
<p class="suite-card-body suite-muted">${err.message || err}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/remotefs.py.</p>
|
||||
<p class="suite-card-body suite-muted">${escapeHtml(String(err.message || err))}. Ensure the bridge helper is installed at /usr/lib/sysdeck/bridge/remotefs.py.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue