SysDeck/shared/bridge.js

885 lines
48 KiB
JavaScript

/*
* SysDeck — shared bridge.js
*
* Provides the `bridge` and `EventBus` objects that each SysDeck Cockpit
* plugin imports. Wraps cockpit.spawn() calls to the Python bridge helpers
* under /usr/lib/sysdeck/bridge/ (invoked as `python3 -m sysdeck.bridge.<m>`).
*
* COCKPIT.JS LOADING CONTRACT (verified from cockpit source code):
* - pkg/base1/cockpit.js is a UMD/IIFE script that sets `window.cockpit`
* as a global. It is NOT an ES module — it has no `export` statements.
* - Plugins load it via `<script src="../base1/cockpit.js"></script>` in
* their index.html (see pkg/systemd/index.html for the reference pattern).
* - At build time, cockpit's esbuild plugin (build.js:71-83) rewrites
* `import cockpit from "cockpit"` in plugin source to
* `module.exports = cockpit` — i.e., a reference to the global.
* - SysDeck plugins don't go through esbuild, so we access the global
* `window.cockpit` directly. The index.html already loads cockpit.js
* via <script src> before this module runs.
*
* v0.0.22 BUG: this file did `import cockpit from "../base1/cockpit.js"`
* — that's an ES module import, but cockpit.js has no ES exports. The
* import returned undefined, so cockpit.spawn() threw when mount() ran,
* and the plugin page stayed on "Loading…". Fixed in v0.0.23 by using
* the global `window.cockpit` instead.
*
* Live-update subscriptions (subscribeCount, subscribeLoadAvg,
* subscribeSocketRate, dbusProxies.systemd) are no-ops in v0.0.23.
* Each module's mount() already wraps them in try/catch, so they degrade
* gracefully — the page loads once and provides a manual refresh button.
*/
// cockpit.js is loaded by index.html's <script src="../base1/cockpit.js">.
// It sets window.cockpit as a global. We access it directly here.
//
// If window.cockpit is undefined at this point, it means cockpit.js failed
// to load (404, network error, or wrong path). Throw a clear error so the
// plugin page shows the actual problem instead of silently failing.
if (typeof window.cockpit === 'undefined') {
throw new Error(
'shared/bridge.js: window.cockpit is undefined. ' +
'This means ../base1/cockpit.js (loaded via <script src> in ' +
'index.html) failed to load. Check that ' +
'/usr/share/cockpit/base1/cockpit.js exists on disk (installed ' +
'by the cockpit-bridge package). Run: ' +
'ls -l /usr/share/cockpit/base1/cockpit.js'
);
}
const cockpit = window.cockpit;
// ── spawn helpers ──────────────────────────────────────────────────
//
// HOW BRIDGE HELPERS ARE INVOKED (verified working in v0.0.26):
//
// Each Python helper at /usr/lib/sysdeck/bridge/<module>.py has a
// `if __name__ == "__main__": sys.exit(main(sys.argv[1:]))` guard, so it
// can be run as a standalone script: `python3 /usr/lib/sysdeck/bridge/glances.py snapshot`.
//
// v0.0.25 BUG: bridge.js called `python3 -m sysdeck.bridge.glances`. That
// requires `sysdeck` to be importable as a Python package AND `sysdeck.bridge`
// to be a subpackage containing `glances.py`. The install layout was:
// /usr/lib/sysdeck/bridge/__init__.py (the sysdeck package)
// /usr/lib/sysdeck/bridge/glances.py (the helper, in the sysdeck package)
// So `python3 -m sysdeck.glances` would work, but `python3 -m sysdeck.bridge.glances`
// looks for `sysdeck/bridge/glances.py` — a path that doesn't exist. Every
// plugin that called a bridge helper got ModuleNotFoundError.
//
// v0.0.26 FIX: call helpers by absolute path. No package layout, no
// symlink, no PYTHONPATH magic. The Makefile installs each helper to
// /usr/lib/sysdeck/bridge/<module>.py and the JS calls it directly.
const BRIDGE_DIR = "/usr/lib/sysdeck/bridge";
function spawn(argv, options = {}) {
return cockpit.spawn(argv, { err: "message", ...options });
}
async function spawnJson(argv, options = {}) {
const out = await spawn(argv, options);
try { return JSON.parse(out); } catch { return out; }
}
async function bridgeCmd(module, args = [], options = {}) {
return spawnJson(
["python3", `${BRIDGE_DIR}/${module}.py`, ...args],
{ superuser: "try", ...options }
);
}
// ── bridge module surfaces (per-module helpers) ─────────────────────
export const bridge = {
containers: {
// Subcommands aligned with bridge/containers.py COMMANDS:
// list → list podman containers
// inspect → inspect one container
// (containers.py has no 'count' or 'action' subcommand)
list: () => bridgeCmd("containers", ["list"]),
inspect: (id) => bridgeCmd("containers", ["inspect", id]),
action: async (id, action) => {
// containers.py has no 'action' subcommand; use podman directly
const map = { stop: "stop", restart: "restart", rm: "rm" };
// 'try': rootless podman manages the operator's own store;
// escalation only when the host demands it
await spawn(["podman", map[action] || action, id], { superuser: "try" });
},
count: async () => { // no 'count' subcommand; compute from list
try {
const list = await bridgeCmd("containers", ["list"]);
return Array.isArray(list) ? list.length : 0;
} catch { return 0; }
},
subscribeCount: (_cb) => () => {}, // no-op: manual refresh only
},
firewall: {
// v0.0.31 REWRITE — PREVIOUS SURFACE WAS READ-ONLY.
// Subcommands aligned with bridge/firewall.py COMMANDS:
// ruleset → list active nftables rules (v0.0.30, kept)
// chains → list nftables chain names (v0.0.30, kept)
// templates → list installed *.sh templates
// template-info → show one template's metadata
// detect → run active template's detect action
// apply → apply a template (start action) under polkit
// stop → stop the firewall
// restart → stop + re-apply active template
// status → running state + ban lists + counters
// ban → add IP to ssh_abuse set
// unban → delete IP from all ban sets
// banned → list banned IPs across all sets
// clear-bans → flush all ban sets
// check → nft -c list ruleset (validate)
//
// v0.0.36 additions — backend dropdown + Cilium + security-hardening:
// backends → list backends + availability
// backend-info → one backend's details + install hint
// active-backend → currently selected backend id + metadata
// switch-backend → switch backend (stops old, applies new)
// install-backend → install backend deps via packages module
// cilium-status → cilium status --brief
// cilium-endpoints → cilium endpoint list -o json
// cilium-policy → cilium policy get -o json
// cilium-policy-apply → cilium policy apply <file>
// cilium-policy-validate → cilium policy validate <file>
// security-hardening → CVE-derived hardening checklist
//
// Mutating ops (apply, stop, restart, ban, unban, clear-bans,
// switch-backend, install-backend, cilium-policy-apply) run with
// { superuser: 'try' } — the cockpit bridge prompts the operator
// via polkit for the org.sysdeck.firewall.modify action (shipped
// since v0.0.17). v0.0.36 extends the action to authorize
// /usr/bin/cilium, /usr/sbin/cilium, /usr/bin/cilium-agent,
// /usr/sbin/cilium-agent, and /usr/bin/helm.
// No `sudo` shell-out from JS; the bridge runs as the cockpit
// user and gets root privileges via polkit when the operator
// authenticates. This is the "cockpit way" per user directive
// v0.0.31.
listChains: () => bridgeCmd("firewall", ["chains"]),
listRules: () => bridgeCmd("firewall", ["ruleset"]),
ruleCount: async () => { // no 'count' subcommand; compute from ruleset
try {
const rs = await bridgeCmd("firewall", ["ruleset"]);
const text = typeof rs === 'string' ? rs : JSON.stringify(rs);
return (text.match(/\bchain\b/g) || []).length;
} catch { return 0; }
},
// v0.0.31 manager methods — all use the bridge subcommands
// added in this release.
templates: () => bridgeCmd("firewall", ["templates"]),
templateInfo: (name) => bridgeCmd("firewall", ["template-info", name]),
detect: () => bridgeCmd("firewall", ["detect"]),
apply: (template, policy) => bridgeCmd(
"firewall",
policy ? ["apply", template, policy] : ["apply", template],
{ superuser: "try" },
),
stop: () => bridgeCmd("firewall", ["stop"], { superuser: "try" }),
restart: () => bridgeCmd("firewall", ["restart"], { superuser: "try" }),
status: () => bridgeCmd("firewall", ["status"]),
ban: (ip) => bridgeCmd("firewall", ["ban", ip], { superuser: "try" }),
unban: (ip) => bridgeCmd("firewall", ["unban", ip], { superuser: "try" }),
banned: () => bridgeCmd("firewall", ["banned"]),
clearBans: () => bridgeCmd("firewall", ["clear-bans"], { superuser: "try" }),
check: () => bridgeCmd("firewall", ["check"]),
// v0.0.36 backend dropdown — read-only queries do NOT need superuser.
backends: () => bridgeCmd("firewall", ["backends"]),
backendInfo: (name) => bridgeCmd("firewall", ["backend-info", name]),
activeBackend: () => bridgeCmd("firewall", ["active-backend"]),
switchBackend: (name) => bridgeCmd("firewall", ["switch-backend", name], { superuser: "try" }),
installBackend: (name) => bridgeCmd("firewall", ["install-backend", name], { superuser: "try" }),
// v0.0.36 Cilium eBPF backend — read-only queries do NOT need superuser.
ciliumStatus: () => bridgeCmd("firewall", ["cilium-status"]),
ciliumEndpoints: () => bridgeCmd("firewall", ["cilium-endpoints"]),
ciliumPolicy: () => bridgeCmd("firewall", ["cilium-policy"]),
ciliumPolicyApply: (file) => bridgeCmd("firewall", ["cilium-policy-apply", file], { superuser: "try" }),
ciliumPolicyValidate: (file) => bridgeCmd("firewall", ["cilium-policy-validate", file]),
// v0.0.36 security-hardening — read-only, no auth needed.
securityHardening: () => bridgeCmd("firewall", ["security-hardening"]),
// v0.0.44 service/port editor — detect running services + edit
// their ports + restart. The 3 new public-server templates
// (remote-admin.sh, public-webserver.sh, ai-llm.sh) and this
// editor together implement the user directive: "a few public
// server variants ... and lastly a full service/port editor
// that detects based on running ports and services detected
// on them. make it as simple as editing the port to change it
// in a config on the system. auto restart the associated
// service if it is changed."
// services + serviceInfo are read-only — no auth.
// setServicePort + restartService mutate state — they need
// superuser (polkit prompts via the cockpit bridge). The
// org.sysdeck.firewall.modify action (shipped since v0.0.17)
// already authorizes systemctl.
services: () => bridgeCmd("firewall", ["services"]),
serviceInfo: (id) => bridgeCmd("firewall", ["service-info", id]),
setServicePort: (id, port) => bridgeCmd(
"firewall",
["set-service-port", id, String(port)],
{ superuser: "try" },
),
restartService: (id) => bridgeCmd(
"firewall",
["restart-service", id],
{ superuser: "try" },
),
},
// v0.0.47: Service / Port Editor is now its own sidebar entry —
// sysdeck-services at order 45. Per user directive: "we should
// move the service/ports editor to its own module entry for ease
// of access." The bridge surface below is a thin proxy over the
// existing firewall.py subcommands (services / service-info /
// set-service-port / restart-service) — no new bridge helper
// needed, no Python changes. The firewall.py SERVICES_REGISTRY
// and the four subcommands remain the source of truth.
//
// Why proxy instead of move: the bridge/firewall.py helper already
// owns the SERVICES_REGISTRY + the atomic-write + systemctl
// restart logic + the CONFIG_BASE_DIRS allowlist. Duplicating
// that into a new bridge/services.py would split the security-
// critical path across two files. Keeping it in firewall.py and
// proxying from bridge.services keeps one auditable code path.
services: {
// list() — detect running listening services + cross-reference
// against SERVICES_REGISTRY. Read-only — no auth.
// info(id) — show one service's full registry entry + detected
// port from its config file. Read-only — no auth.
// setPort(id, port) — edit the port in the service's config
// file (atomic write) + systemctl restart.
// Mutates state — needs superuser.
// restart(id) — systemctl restart only (no port change).
// Mutates state — needs superuser.
list: () => bridgeCmd("firewall", ["services"]),
info: (id) => bridgeCmd("firewall", ["service-info", id]),
setPort: (id, port) => bridgeCmd(
"firewall",
["set-service-port", id, String(port)],
{ superuser: "try" },
),
restart: (id) => bridgeCmd(
"firewall",
["restart-service", id],
{ superuser: "try" },
),
},
integrity: {
// Subcommands aligned with bridge/integrity.py COMMANDS:
// score → trust score from lynis
// scan → run lynis scan
trustScore: () => bridgeCmd("integrity", ["score"]),
runLynis: () => bridgeCmd("integrity", ["scan"], { superuser: true }),
},
netsec: {
// v0.0.43: iptraf-ng-style network monitor. Reads /proc/net/dev,
// /proc/net/snmp, /proc/net/tcp+udp directly — no iptraf-ng
// binary dependency. The traffic subcommand samples /proc/net/dev
// twice (1s apart) to compute live RX/TX rates.
// Legacy sockets/established kept for back-compat.
summary: () => bridgeCmd("netsec", ["summary"]),
traffic: () => bridgeCmd("netsec", ["traffic"]),
connections: () => bridgeCmd("netsec", ["connections"]),
interfaces: () => bridgeCmd("netsec", ["interfaces"]),
protocols: () => bridgeCmd("netsec", ["protocols"]),
listeningPorts: () => bridgeCmd("netsec", ["sockets"]),
subscribeSocketRate: (_cb) => () => {}, // no-op (legacy)
},
mesh: {
services: () => bridgeCmd("mesh", ["services"]),
},
vault: {
listLuks: () => bridgeCmd("vault", ["list-luks"]),
},
fleet: {
// Subcommands aligned with bridge/fleet.py COMMANDS:
// summary → local host + peers + uptime
// local → local host only
// peers → peer hosts only
// (fleet.py has no 'uptime' or 'node-count' subcommand — use 'summary')
uptime: async () => {
const s = await bridgeCmd("fleet", ["summary"]);
return s?.local?.uptime ?? 'unavailable';
},
nodeCount: async () => {
const s = await bridgeCmd("fleet", ["summary"]);
return 1 + (s?.peers?.length ?? 0);
},
summary: () => bridgeCmd("fleet", ["summary"]),
subscribeLoadAvg: (_cb) => () => {}, // no-op
},
firmware: {
// bridge/firmware.py COMMANDS: devices
// (firmware.py has no 'tpm-info' subcommand; read PCR0 directly)
devices: () => bridgeCmd("firmware", ["devices"]),
tpmInfo: async () => {
try {
return await spawn(["tpm2_pcrread", "sha256:0"]);
} catch {
return 'tpm2-tools not installed';
}
},
},
builder: {
// Subcommands aligned with bridge/builder.py COMMANDS.
// v0.0.30 viewer subcommands (kept):
// status → {state, primary, backends, distro}
// profiles → flat list of build profiles/specs across backends
// summary → combined status + profiles in one call
// backends → just the installed backend list
// install-hint → recommended pacman/apt install command for the
// host distro (used when no backend is installed)
// v0.0.31 full-featured subcommands (new):
// build → run a build for a profile
// profile-create → scaffold a new mkosi.conf / vmdb2 yaml
// (v0.0.49: accepts --packages=<json> + --mode)
// profile-copy → (v0.0.48) copy a shipped archiso/live-build
// profile tree into /etc/
// (v0.0.49: accepts --packages=<json> + --mode)
// profile-delete → delete a profile (refuses /usr/share)
// profile-import-packages (v0.1.0) → write the host's
// explicitly-installed packages into a
// profile's package list
// build-status → list active + recent builds
// build-log → tail a build's log file
// artifacts → list image/ISO files per profile
//
// Mutating ops (build, profile-create, profile-copy, profile-delete,
// profile-import-packages) run with { superuser: 'try' } — the cockpit bridge prompts the
// operator via polkit for the org.sysdeck.builder.modify action
// (shipped since v0.0.17, v0.0.30 expanded for mkosi/vmdb2/archiso/
// live-build). No `sudo` shell-out from JS.
status: () => bridgeCmd("builder", ["status"]),
profiles: () => bridgeCmd("builder", ["profiles"]),
summary: () => bridgeCmd("builder", ["summary"]),
backends: () => bridgeCmd("builder", ["backends"]),
installHint: () => bridgeCmd("builder", ["install-hint"]),
// v0.0.31 new methods.
build: (profile, backend, options) => bridgeCmd(
"builder",
["build", profile, ...(backend ? [backend] : []),
...(options ? [`--options=${JSON.stringify(options)}`] : [])],
{ superuser: "try" },
),
// v0.0.49: profileCreate and profileCopy accept an optional
// inline package list (packagesText — multiline string, one
// package per line, # comments allowed) and a merge mode
// ("append" or "replace"). packagesText is JSON-encoded so
// newlines and quotes survive the argv boundary cleanly. When
// omitted, the bridge writes no package file (back-compat
// with v0.0.48 callers).
profileCreate: (name, backend, base, packagesText, mode) => bridgeCmd(
"builder",
["profile-create", name, backend,
...(base ? [base] : []),
...(packagesText ? [`--packages=${JSON.stringify(packagesText)}`] : []),
...(mode ? [`--mode=${mode}`] : [])],
{ superuser: "try" },
),
// v0.0.48: copy a shipped archiso/live-build profile tree into
// /etc/. Used by the panel's "Copy shipped profile" form, which
// is the directory-based-backend counterpart to profileCreate.
// v0.0.49: accepts the same packagesText + mode args as
// profileCreate. Default mode for copy is "append" (preserves
// the baseline's packages); pass "replace" to overwrite.
profileCopy: (srcName, newName, backend, packagesText, mode) => bridgeCmd(
"builder",
["profile-copy", srcName, newName,
...(backend ? [backend] : []),
...(packagesText ? [`--packages=${JSON.stringify(packagesText)}`] : []),
...(mode ? [`--mode=${mode}`] : [])],
{ superuser: "try" },
),
profileDelete: (name, force) => bridgeCmd(
"builder",
["profile-delete", name, ...(force ? ["--force"] : [])],
{ superuser: "try" },
),
// v0.1.0: import the host's explicitly-installed packages into
// an existing profile's package list. Queries pacman -Qqe /
// apt-mark showmanual / dnf repoquery --userinstalled on the
// host and writes the result via the same _write_packages
// dispatch used by profileCreate/profileCopy. mode defaults to
// "append" (layer host packages on top of the profile's
// existing baseline); pass "replace" to wipe first. dryRun
// returns what would be written without touching the file.
profileImportPackages: (name, mode, dryRun) => bridgeCmd(
"builder",
["profile-import-packages", name,
...(mode ? [`--mode=${mode}`] : []),
...(dryRun ? ["--dry-run"] : [])],
{ superuser: "try" },
),
buildStatus: () => bridgeCmd("builder", ["build-status"]),
buildLog: (id) => bridgeCmd("builder", ["build-log", id]),
// v0.1.3: build management — delete state + log (+ optionally artifacts).
buildDelete: (id, deleteArtifacts) => bridgeCmd(
"builder",
["build-delete", id, ...(deleteArtifacts ? ["--artifacts"] : [])],
{ superuser: "try" },
),
artifacts: (profile) => bridgeCmd(
"builder",
["artifacts", ...(profile ? [profile] : [])],
),
// v0.1.3: artifact management — delete single file or clear all.
artifactDelete: (profile, name) => bridgeCmd(
"builder",
["artifact-delete", profile, name],
{ superuser: "try" },
),
artifactsClear: (profile) => bridgeCmd(
"builder",
["artifacts-clear", profile],
{ superuser: "try" },
),
},
mining: {
// v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE.
// Read-only:
workers: () => bridgeCmd("mining", ["summary"]),
summary: () => bridgeCmd("mining", ["summary"]),
threads: () => bridgeCmd("mining", ["threads"]),
poolConfigGet: () => bridgeCmd("mining", ["pool-config-get"]),
threadsConfigGet: () => bridgeCmd("mining", ["threads-config-get"]),
algorithmGet: () => bridgeCmd("mining", ["algorithm-get"]),
serviceStatus: () => bridgeCmd("mining", ["service-status"]),
// Mutating (XMRig REST API):
poolConfigSet: (url, user, pass) => bridgeCmd("mining", ["pool-config-set", url, user, pass]),
threadsConfigSet: (count) => bridgeCmd("mining", ["threads-config-set", String(count)]),
algorithmSet: (presetId) => bridgeCmd("mining", ["algorithm-set", presetId]),
pause: () => bridgeCmd("mining", ["pause"]),
resume: () => bridgeCmd("mining", ["resume"]),
pauseWorker: (id) => bridgeCmd("mining", ["pause-worker", String(id)]),
resumeWorker: (id) => bridgeCmd("mining", ["resume-worker", String(id)]),
// Mutating (systemd service control — polkit org.sysdeck.system.modify):
start: () => bridgeCmd("mining", ["start"], { superuser: "try" }),
stop: () => bridgeCmd("mining", ["stop"], { superuser: "try" }),
restart: () => bridgeCmd("mining", ["restart"], { superuser: "try" }),
},
themes: {
// v0.0.34 EXPANDED TO 1999 POWER-TOOL STYLE.
// Read-only:
readConfig: () => bridgeCmd("themes", ["read-config"]),
presetList: () => bridgeCmd("themes", ["preset-list"]),
variableList: () => bridgeCmd("themes", ["variable-list"]),
variableGet: (name) => bridgeCmd("themes", ["variable-get", name]),
// Mutating (writes /etc/cockpit/cockpit.conf — polkit org.sysdeck.system.modify):
writeConfig: (text) => bridgeCmd("themes", ["write-config", text], { superuser: "try" }),
set: (section, key, value) => bridgeCmd("themes", ["set", section, key, value], { superuser: "try" }),
unset: (section, key) => bridgeCmd("themes", ["unset", section, key], { superuser: "try" }),
reset: () => bridgeCmd("themes", ["reset"], { superuser: "try" }),
presetApply: (id) => bridgeCmd("themes", ["preset-apply", id], { superuser: "try" }),
variableSet: (name, value) => bridgeCmd("themes", ["variable-set", name, value], { superuser: "try" }),
variableReset: () => bridgeCmd("themes", ["variable-reset"], { superuser: "try" }),
},
auth: {
// Subcommands aligned with bridge/auth.py COMMANDS:
// slots → PKCS#11 smartcard reader slots
// identities → SSH keys + Kerberos principals
// ssh-keys → SSH keys only
// kerberos → Kerberos principals only
// readers → smartcard-class USB devices (lsusb)
// certs → PKCS#11 cert objects (v0.1.4 — replaces the
// panel's bridge.spawn() calls that never worked)
// (auth.py has no 'smartcards' subcommand — use 'slots')
smartcards: () => bridgeCmd("auth", ["slots"]),
identities: () => bridgeCmd("auth", ["identities"]),
sshKeys: () => bridgeCmd("auth", ["ssh-keys"]),
kerberos: () => bridgeCmd("auth", ["kerberos"]),
readers: () => bridgeCmd("auth", ["readers"]),
certs: () => bridgeCmd("auth", ["certs"]),
},
// v0.0.38: Kata panel rewritten as vanilla JS backed by bridge/kata.py.
// The v0.0.35-v0.0.37 React bundle is deleted (it showed hardcoded
// mock data — 5 fake sandboxes, fake metrics, fake PXE status).
// Every method below calls the REAL Kata Containers 3.x APIs:
// - list/inspect/metrics → kata-monitor HTTP /sandboxes, /agent-url,
// /metrics?sandbox=<id> + filesystem /run/vc/sbs/, /run/kata/
// - summary/version/check → kata-runtime version/env --json/check
// - pxeStatus → systemctl is-active dnsmasq + real /srv/tftp probes
// - qcrowsList → filesystem /usr/share/sysdeck/kata/qcrows/ — since
// the cockpit-kata master spec v0.2 this is FORMAT-AWARE: each
// archive is parsed in memory (metadata.toml + menu.toml), so
// the panel shows image name/version/arch/kernel/hypervisors.
// - qcrowsInspect/qcrowsVerify → single-image detail + in-memory
// verification mirroring cockpit-kata's qcrows-verify.
// Read-only queries do NOT pass { superuser: 'try' }.
kata: {
list: () => bridgeCmd("kata", ["list"]),
inspect: (id) => bridgeCmd("kata", ["inspect", id]),
metrics: (id) => bridgeCmd("kata", ["metrics", id]),
summary: () => bridgeCmd("kata", ["summary"]),
version: () => bridgeCmd("kata", ["version"]),
check: () => bridgeCmd("kata", ["check"]),
pxeStatus: () => bridgeCmd("kata", ["pxe-status"]),
qcrowsList: () => bridgeCmd("kata", ["qcrows-list"]),
qcrowsInspect: (filename) => bridgeCmd("kata", ["qcrows-inspect", filename]),
qcrowsVerify: (filename) => bridgeCmd("kata", ["qcrows-verify", filename]),
},
// v0.0.39: Monitoring module — shared tabbed Prometheus + Grafana panel.
// Both bridge helpers (bridge/prometheus.py, bridge/grafana.py) call
// the real HTTP APIs. No mock data. Read-only queries do NOT pass
// { superuser: 'try' }; restart/reload DO (they invoke systemctl).
prometheus: {
summary: () => bridgeCmd("prometheus", ["summary"]),
targets: () => bridgeCmd("prometheus", ["targets"]),
alerts: () => bridgeCmd("prometheus", ["alerts"]),
rules: () => bridgeCmd("prometheus", ["rules"]),
config: () => bridgeCmd("prometheus", ["config"]),
logSummary: () => bridgeCmd("prometheus", ["log-summary"]),
restart: () => bridgeCmd("prometheus", ["restart"], { superuser: "try" }),
reload: () => bridgeCmd("prometheus", ["reload"], { superuser: "try" }),
},
grafana: {
summary: () => bridgeCmd("grafana", ["summary"]),
dashboards: () => bridgeCmd("grafana", ["dashboards"]),
datasources: () => bridgeCmd("grafana", ["datasources"]),
alerts: () => bridgeCmd("grafana", ["alerts"]),
health: () => bridgeCmd("grafana", ["health"]),
org: () => bridgeCmd("grafana", ["org"]),
users: () => bridgeCmd("grafana", ["users"]),
plugins: () => bridgeCmd("grafana", ["plugins"]),
search: (q) => bridgeCmd("grafana", ["search", q]),
restart: () => bridgeCmd("grafana", ["restart"], { superuser: "try" }),
reload: () => bridgeCmd("grafana", ["reload"], { superuser: "try" }),
},
fester: {
// v0.2.0 — real integration with the vendored fester service
// (web/mini-services/fester, REST on :3010). The old build-jobs
// stub that listed systemd units is gone.
status: () => bridgeCmd("fester", ["status"]),
metrics: () => bridgeCmd("fester", ["metrics"]),
builds: () => bridgeCmd("fester", ["builds"]),
build: (id) => bridgeCmd("fester", ["build", String(id)]),
nodes: () => bridgeCmd("fester", ["nodes"]),
targets: () => bridgeCmd("fester", ["targets"]),
timeline: (id) => bridgeCmd("fester", ["timeline", String(id)]),
sessions: () => bridgeCmd("fester", ["sessions"]),
startBuild: (project, targets, opts) => bridgeCmd("fester", ["start-build", "--project", String(project), "--targets", (targets || []).join(","), ...((opts && opts.noCache) ? ["--no-cache"] : []), ...((opts && opts.retries != null) ? ["--retries", String(opts.retries)] : [])]),
cancel: (id) => bridgeCmd("fester", ["cancel", String(id)]),
replay: (buildId, label) => bridgeCmd("fester", ["replay", String(buildId), ...((label != null) ? ["--label", String(label)] : [])]),
},
klanker: {
// v0.3.0 NEW MODULE — the vendored klanker-gate service
// (master-build/klanker-gate): the Frosty Deno LLM gateway,
// REST on :8080. UPSTREAM: klanker-gate by TykoDev
// (https://github.com/TykoDev/klanker-gate, Apache-2.0) —
// not SysDeck code; see klanker-gate/ATTRIBUTION.md.
// Read-only polls hit the operator API
// (KLANKER_URL / KLANKER_ADMIN_TOKEN env, see bridge/klanker.py
// — the token travels as a bearer header only, never echoed);
// service/journal wrap systemctl + journalctl for the
// klanker-gate.service unit (superuser via the bridgeCmd
// default, so polkit prompts the operator).
status: () => bridgeCmd("klanker", ["status"]),
providers: () => bridgeCmd("klanker", ["providers"]),
models: () => bridgeCmd("klanker", ["models"]),
vkeys: () => bridgeCmd("klanker", ["vkeys"]),
logs: (limit) => bridgeCmd("klanker", ["logs", "--limit", String(limit ?? 25)]),
analytics: () => bridgeCmd("klanker", ["analytics"]),
runtime: () => bridgeCmd("klanker", ["runtime"]),
service: (action) => bridgeCmd("klanker", ["service", String(action)]),
journal: (n) => bridgeCmd("klanker", ["journal", String(n ?? 40)]),
localstack: () => bridgeCmd("klanker", ["localstack"]),
},
glances: {
// v0.0.34 INTEGRATES THE GLANCES BUILT-IN WEB UI.
// Read-only:
snapshot: () => bridgeCmd("glances", ["snapshot"]),
cpu: () => bridgeCmd("glances", ["cpu"]),
memory: () => bridgeCmd("glances", ["memory"]),
network: () => bridgeCmd("glances", ["network"]),
webStatus: () => bridgeCmd("glances", ["web-status"]),
// Mutating — start/stop the built-in webserver via subprocess
// under the cockpit superuser channel (polkit org.sysdeck.system.modify):
startWeb: (port) => bridgeCmd("glances", ["start-web", ...(port ? [String(port)] : [])], { superuser: "try" }),
stopWeb: () => bridgeCmd("glances", ["stop-web"], { superuser: "try" }),
},
sensors: {
summary: () => bridgeCmd("sensors", ["summary"]),
},
benchmark: {
listTests: () => bridgeCmd("benchmark", ["list-tests"]),
// sysbench needs no root; lynis (runLynis) does and keeps it
runCpu: () => bridgeCmd("benchmark", ["run-cpu"], { superuser: "try" }),
runMemory: () => bridgeCmd("benchmark", ["run-memory"], { superuser: "try" }),
runIo: () => bridgeCmd("benchmark", ["run-io"], { superuser: "try" }),
runTest: (name) => bridgeCmd("benchmark", ["run-test", name], { superuser: "try" }),
},
packages: {
// v0.0.31: install/remove/update/update-all now ACTUALLY RUN
// the package manager via subprocess. The bridge helper
// executes pacman/apt/dnf and returns the actual stdout/stderr
// in the response. The JS panel passes { superuser: 'try' } so
// the cockpit bridge prompts the operator via polkit for the
// org.sysdeck.packages.modify action (shipped since v0.0.17).
// No `sudo` shell-out from JS — this is the cockpit way.
summary: () => bridgeCmd("packages", ["summary"]),
listInstalled: () => bridgeCmd("packages", ["list-installed"]),
search: (q) => bridgeCmd("packages", ["search", q]),
install: (pkg) => bridgeCmd("packages", ["install", pkg], { superuser: "try" }),
remove: (pkg) => bridgeCmd("packages", ["remove", pkg], { superuser: "try" }),
update: (pkg) => bridgeCmd("packages", ["update", pkg], { superuser: "try" }),
updateAll: () => bridgeCmd("packages", ["update-all"], { superuser: "try" }),
// v0.0.31: dry-run returns the command string without running
// it, for the panel's preview-before-confirm flow.
dryRun: (action, pkg) => bridgeCmd("packages", ["dry-run", action, ...(pkg ? [pkg] : [])]),
},
policy: {
// v0.0.32 NEW MODULE — modern policy management and permissions
// manager for groups.
// v0.0.33 EXPANDED — adds the rest of the modern Linux LSM
// stack: Smack, TOMOYO, Yama, LoadPin, Lockdown, BPF-LSM,
// Landlock, plus file capabilities (setcap/getcap). SELinux
// remains skipped (native). Each new LSM is optional — the
// bridge auto-detects whether it is compiled into the kernel
// and returns { available: false, reason: ... } when absent;
// the panel renders an install/enable hint instead of an
// empty table.
//
// Mutating ops (acl-set, acl-remove, acl-default, cgroup-create,
// cgroup-move, cgroup-set, vlan-create, vlan-delete, ebpf-pin,
// apparmor-enforce, apparmor-complain, smack-load, yama-set-scope,
// tomoyo-save-policy, filecaps-set, filecaps-remove) use
// { superuser: 'try' } — the cockpit bridge prompts the
// operator via polkit for the org.sysdeck.policy.modify action.
summary: () => bridgeCmd("policy", ["summary"]),
// ACLs
aclList: (path) => bridgeCmd("policy", ["acl-list", path]),
aclSet: (path, entry) => bridgeCmd("policy", ["acl-set", path, entry], { superuser: "try" }),
aclRemove: (path, entry) => bridgeCmd("policy", ["acl-remove", path, entry], { superuser: "try" }),
aclDefault: (path, entry) => bridgeCmd("policy", ["acl-default", path, entry], { superuser: "try" }),
// cgroups v2
cgroupList: () => bridgeCmd("policy", ["cgroup-list"]),
cgroupShow: (path) => bridgeCmd("policy", ["cgroup-show", path]),
cgroupProcs: (path) => bridgeCmd("policy", ["cgroup-procs", path]),
cgroupCreate: (path) => bridgeCmd("policy", ["cgroup-create", path], { superuser: "try" }),
cgroupMove: (pid, path) => bridgeCmd("policy", ["cgroup-move", pid, path], { superuser: "try" }),
cgroupSet: (path, ctrl, val) => bridgeCmd("policy", ["cgroup-set", path, ctrl, val], { superuser: "try" }),
// VLANs
vlanList: () => bridgeCmd("policy", ["vlan-list"]),
vlanShow: (iface) => bridgeCmd("policy", ["vlan-show", iface]),
vlanCreate: (iface, vid) => bridgeCmd("policy", ["vlan-create", iface, vid], { superuser: "try" }),
vlanDelete: (iface, vid) => bridgeCmd("policy", ["vlan-delete", iface, vid], { superuser: "try" }),
// eBPF
ebpfList: () => bridgeCmd("policy", ["ebpf-list"]),
ebpfShow: (id) => bridgeCmd("policy", ["ebpf-show", id]),
ebpfMaps: () => bridgeCmd("policy", ["ebpf-maps"]),
ebpfPin: (id, path) => bridgeCmd("policy", ["ebpf-pin", id, path], { superuser: "try" }),
// namespaces
nsList: () => bridgeCmd("policy", ["ns-list"]),
nsShow: (nsid) => bridgeCmd("policy", ["ns-show", nsid]),
// AppArmor (optional)
apparmorStatus: () => bridgeCmd("policy", ["apparmor-status"]),
apparmorProfiles: () => bridgeCmd("policy", ["apparmor-profiles"]),
apparmorEnforce: (profile) => bridgeCmd("policy", ["apparmor-enforce", profile], { superuser: "try" }),
apparmorComplain: (profile) => bridgeCmd("policy", ["apparmor-complain", profile], { superuser: "try" }),
// v0.0.33: additional LSMs — Smack, TOMOYO, Yama, LoadPin,
// Lockdown, BPF-LSM, Landlock + file capabilities. Each is
// optional; the bridge auto-detects and the panel renders an
// install/enable hint when absent.
lsmStatus: () => bridgeCmd("policy", ["lsm-status"]),
// Smack
smackStatus: () => bridgeCmd("policy", ["smack-status"]),
smackLabels: () => bridgeCmd("policy", ["smack-labels"]),
smackLoad: (rulesFile) => bridgeCmd("policy", ["smack-load", rulesFile], { superuser: "try" }),
// TOMOYO
tomoyoStatus: () => bridgeCmd("policy", ["tomoyo-status"]),
tomoyoProfiles: () => bridgeCmd("policy", ["tomoyo-profiles"]),
tomoyoSavePolicy: (outPath) => bridgeCmd("policy", ["tomoyo-save-policy", outPath], { superuser: "try" }),
// Yama
yamaStatus: () => bridgeCmd("policy", ["yama-status"]),
yamaSetScope: (scope) => bridgeCmd("policy", ["yama-set-scope", String(scope)], { superuser: "try" }),
// LoadPin
loadpinStatus: () => bridgeCmd("policy", ["loadpin-status"]),
// Lockdown
lockdownStatus: () => bridgeCmd("policy", ["lockdown-status"]),
// BPF-LSM
bpflsmStatus: () => bridgeCmd("policy", ["bpflsm-status"]),
// Landlock
landlockStatus: () => bridgeCmd("policy", ["landlock-status"]),
// File capabilities (setcap/getcap)
filecapsList: () => bridgeCmd("policy", ["filecaps-list"]),
filecapsShow: (path) => bridgeCmd("policy", ["filecaps-show", path]),
filecapsSet: (caps, path) => bridgeCmd("policy", ["filecaps-set", caps, path], { superuser: "try" }),
filecapsRemove: (path) => bridgeCmd("policy", ["filecaps-remove", path], { superuser: "try" }),
},
db: {
// DB Control module — unified control for SQL/NoSQL/vector/AI
// database engines. The bridge helper bridge/db.py surfaces
// 32+ engines across SQL/NoSQL/Vector/TimeSeries/Graph/
// Embedded/Cloud/AI families with summary/status/start/stop/
// restart/connections/query subcommands.
//
// The cockpit-way pattern: the bridge runs systemctl directly
// via subprocess; the JS panel passes { superuser: 'try' } to
// cockpit.spawn so the cockpit bridge prompts the operator via
// polkit for the org.sysdeck.db.modify action.
//
// Subcommands aligned with bridge/db.py:
// summary — list all detected engines with status
// status <id> — detailed status for one engine
// start <id> — start engine via systemctl
// stop <id> — stop engine via systemctl
// restart <id> — restart engine via systemctl
// connections <id> — list active TCP connections
// query <id> <sql> — execute a SQL query (SQL engines only)
summary: () => bridgeCmd("db", ["summary"]),
status: (id) => bridgeCmd("db", ["status", id]),
start: (id) => bridgeCmd("db", ["start", id], { superuser: "try" }),
stop: (id) => bridgeCmd("db", ["stop", id], { superuser: "try" }),
restart: (id) => bridgeCmd("db", ["restart", id], { superuser: "try" }),
connections: (id) => bridgeCmd("db", ["connections", id]),
query: (id, sql) => bridgeCmd("db", ["query", id, sql], { superuser: "try" }),
},
jellyfin: {
// v0.0.35 NEW MODULE — Jellyfin media server management.
// Per user directive: "next we will integrate a jellyfin
// management module where it starts, stops, and loads the
// admin panel in the module." Jellyfin ships a single systemd
// unit (jellyfin.service) and serves a full admin web UI on
// http://127.0.0.1:8096. The bridge starts/stops/restarts the
// service via systemctl; the panel iframes the running admin
// UI — same pattern as the v0.0.34 Glances integration.
//
// Mutating ops (start, stop, restart) run with
// { superuser: 'try' } — the cockpit bridge prompts the
// operator via polkit for the org.sysdeck.jellyfin.modify
// action (added in v0.0.35).
//
// Subcommands aligned with bridge/jellyfin.py:
// summary — service status + version + port + url
// status — service status only
// start — systemctl start jellyfin.service
// stop — systemctl stop jellyfin.service
// restart — systemctl restart jellyfin.service
// web-status — {running, url, port, ...} for iframe
// libraries — best-effort GET /Library/VirtualFolders
summary: () => bridgeCmd("jellyfin", ["summary"]),
status: () => bridgeCmd("jellyfin", ["status"]),
start: () => bridgeCmd("jellyfin", ["start"], { superuser: "try" }),
stop: () => bridgeCmd("jellyfin", ["stop"], { superuser: "try" }),
restart: () => bridgeCmd("jellyfin", ["restart"], { superuser: "try" }),
webStatus: () => bridgeCmd("jellyfin", ["web-status"]),
libraries: () => bridgeCmd("jellyfin", ["libraries"]),
},
photos: {
// v0.0.35 NEW MODULE — Photo Manager. Per user directive:
// "as well as a photo manager of equal quality. with its own
// module." Following the Jellyfin pattern: each supported
// backend ships as a systemd service with a built-in admin
// web UI; the bridge starts/stops/restarts the service via
// systemctl and the panel iframes the running admin UI.
//
// Multi-backend design — same shape as bridge/db.py. The
// bridge auto-detects which backends are installed.
//
// Subcommands aligned with bridge/photos.py:
// summary — list all backends with status
// status <id> — single-backend detail
// start <id> — systemctl start <service>
// stop <id> — systemctl stop <service>
// restart <id> — systemctl restart <service>
// web-status <id> — {running, url, port, ...} for iframe
summary: () => bridgeCmd("photos", ["summary"]),
status: (id) => bridgeCmd("photos", ["status", id]),
start: (id) => bridgeCmd("photos", ["start", id], { superuser: "try" }),
stop: (id) => bridgeCmd("photos", ["stop", id], { superuser: "try" }),
restart: (id) => bridgeCmd("photos", ["restart", id], { superuser: "try" }),
webStatus: (id) => bridgeCmd("photos", ["web-status", id]),
},
remotefs: {
// v0.0.35 NEW MODULE — Remote Filesystems. Per user
// directive: "then a remote fs manager such as ceph, and
// others but not nfs or amanada fs." Manages remote /
// distributed filesystem backends as systemd services and
// surfaces cluster status from each backend's CLI tool.
//
// Multi-backend design — same shape as bridge/db.py.
// Backends shipped: Ceph, GlusterFS, MooseFS, BeeGFS,
// OrangeFS. NFS and Amanda explicitly excluded per directive.
//
// Subcommands aligned with bridge/remotefs.py:
// summary — list all backends + excluded dict
// status <id> — single-backend detail
// start <id> — systemctl start <service>
// stop <id> — systemctl stop <service>
// restart <id> — systemctl restart <service>
// cluster-info <id> — backend-specific cluster status
summary: () => bridgeCmd("remotefs", ["summary"]),
status: (id) => bridgeCmd("remotefs", ["status", id]),
start: (id) => bridgeCmd("remotefs", ["start", id], { superuser: "try" }),
stop: (id) => bridgeCmd("remotefs", ["stop", id], { superuser: "try" }),
restart: (id) => bridgeCmd("remotefs", ["restart", id], { superuser: "try" }),
clusterInfo: (id) => bridgeCmd("remotefs", ["cluster-info", id]),
},
// v0.0.46 — 3rd-party Cockpit module installer.
// catalog → full module catalog (static list from bridge)
// status → per-module installed + missing-deps status
// preflight → license / author / source / install plan disclosure
// (the front-end renders this BEFORE calling install)
// install → run the pull. The 1-click UI always passes
// acceptLicense=true because the license is shown
// inline next to the Install button — the click IS
// the operator's acceptance gesture. The bridge
// still refuses silent installs (no acceptLicense)
// as a guard against malicious callers.
// uninstall → remove the module
// audit → tail of /etc/cockpit/MODULE_LICENSES.log
//
// install / uninstall run with { superuser: 'try' } — the cockpit
// bridge prompts the operator via polkit for the
// org.sysdeck.modules3p.modify action (shipped in
// packaging/polkit/org.sysdeck.modules3p.policy).
modules3p: {
catalog: () => bridgeCmd("modules3p", ["catalog"]),
status: () => bridgeCmd("modules3p", ["status"]),
preflight: (id) => bridgeCmd("modules3p", ["preflight", id]),
install: (id, acceptLicense) => bridgeCmd(
"modules3p",
acceptLicense ? ["install", id, "--accept-license=1"] : ["install", id],
{ superuser: "try" },
),
uninstall: (id) => bridgeCmd("modules3p", ["uninstall", id], { superuser: "try" }),
audit: (limit) => bridgeCmd("modules3p", limit != null ? ["audit", String(limit)] : ["audit"]),
},
};
// ── dbusProxies (no-op in v0.0.20) ──────────────────────────────────
export const dbusProxies = {
systemd: null,
};
// ── EventBus (no-op in v0.0.20) ─────────────────────────────────────
export const EventBus = {
emit: () => {},
on: () => () => {},
off: () => {},
};