/*
* SysDeck - Service / Port Editor Panel (v0.0.47)
* Author: Jeremy Anderson (https://dcos.net)
*
* v0.0.47 — PROMOTED TO ITS OWN SIDEBAR ENTRY. Per user directive:
* "we should move the service/ports editor to its own module entry
* for ease of access." The editor previously lived as a card at the
* bottom of the Firewall panel (v0.0.44); v0.0.47 lifts it into a
* first-class sidebar entry at order 45 so the operator can manage
* service ports without scrolling past the firewall ruleset table.
*
* The bridge surface is `bridge.services.*` — a thin proxy (added in
* v0.0.47) over the existing firewall.py subcommands: services,
* service-info, set-service-port, restart-service. No new bridge
* helper file was needed; the SERVICES_REGISTRY, atomic-write logic,
* and CONFIG_BASE_DIRS allowlist remain in bridge/firewall.py as the
* single source of truth.
*
* Panel layout:
* - Header (counts: N services, M editable, K listeners)
* - Filter row (search box + show-only-editable toggle + refresh)
* - Services table — one row per SERVICES_REGISTRY entry:
* · Service (name + id + editable/restartable badges)
* · Port (editable input + Save & Restart button + ↻ Restart button)
* · Config port (the value parsed from the config file)
* · Default (upstream default port)
* · Listening (ports actually bound on the host)
* · Process / PID (from ss -tlnp)
* · Config file (the resolved path under /etc/ or /usr/share/sysdeck/)
* - Unmapped listeners card — ports that didn't match any registry
* entry. The operator can spot services the editor doesn't yet
* know about and request a SERVICES_REGISTRY entry.
* - Operation output card — shows the result of the last Save /
* Restart action (success message or stderr).
*
* Security (unchanged from v0.0.44 — the bridge helper enforces all
* of this; the JS just renders the response):
* - service_id validated against SERVICES_REGISTRY (CVE-2024-2947
* — attacker cannot trick the bridge into editing /etc/shadow).
* - Port validated with strict integer regex 1..65535,
* `re.fullmatch` to reject trailing newlines (CVE-2019-15107).
* - Config path resolved with `os.path.realpath` + base-dir
* allowlist (/etc/ or /usr/share/sysdeck/ — CVE-2022-30708
* symlink-escape defense).
* - Port substitution uses a strict per-service regex (NOT
* freeform sed) so only the port digits are replaced.
* - systemctl invoked with `shell=False`, list argv, env scrubbed
* (CVE-2024-6126).
* - Atomic write via tmpfile + fsync + rename defeats partial-write
* corruption.
* - The `org.sysdeck.firewall.modify` polkit action (shipped since
* v0.0.17) already authorizes /usr/bin/systemctl — no polkit
* changes required.
*/
export async function mount(panel, { bridge, EventBus }) {
panel.innerHTML = renderSkeleton();
const servicesResp = await safe(
bridge.services.list(),
{ services: [], unmapped_listeners: [], listener_count: 0 },
);
panel.innerHTML = renderPanel(servicesResp);
wireEvents(panel, { bridge, EventBus });
EventBus.emit('services.loaded', {
serviceCount: (servicesResp?.services || []).length,
listenerCount: servicesResp?.listener_count || 0,
});
}
// ── Panel render ────────────────────────────────────────────────────
function renderPanel(servicesResp) {
const services = servicesResp?.services || [];
const unmapped = servicesResp?.unmapped_listeners || [];
const listenerCount = servicesResp?.listener_count || 0;
const editableCount = services.filter((s) => s.editable).length;
return `
${services.length} services
· ${editableCount} editable
· ${listenerCount} listeners
· bridge.firewall.services()
Service / Port Editor
The bridge enumerates every listening TCP socket on the host
(ss -tlnp, falling back to /proc/net/tcp
if unavailable) and cross-references it against the
SERVICES_REGISTRY in bridge/firewall.py
— currently ${services.length} registered services covering
SSH, Cockpit, Caddy, Varnish, MariaDB, Ollama, OpenWebUI,
Hermes, and Odysseus. Each row shows the port parsed from
the service's config file alongside any listening sockets
that match it. Edit the port in the input and click
Save & Restart — the bridge writes
the new port to the config file atomically (tmpfile +
fsync + rename) and runs systemctl restart
on the service. ${listenerCount} listening sockets
detected on this host.
| Service | Port (editable) | Config port | Default | Listening | Process | PID | Config file |
|---|
${escapeHtml(String(portValue))} (default, not detected in config)`;
const saveBtn = s.editable
? ``
: '';
const restartBtn = s.restart_supported
? ``
: '';
const configCell = s.config_file
? `${escapeHtml(s.config_file)}`
: '—';
const description = s.description ? `
No services detected. This usually means the
bridge/firewall.py services subcommand
failed — check the cockpit bridge log. Listening-
socket enumeration requires ss
(iproute2) or readable /proc/net/tcp.
These listening sockets did not match any service in the
SERVICES_REGISTRY. To add support for a new
service, add an entry to SERVICES_REGISTRY
in bridge/firewall.py with its config file
paths and port-extraction regex.
| Port | Proto | Process | PID |
|---|
Save & Restart prompts for the cockpit
superuser password via polkit. The
org.sysdeck.firewall.modify action authorizes
/usr/bin/systemctl. Config-file writes are
atomic — the bridge writes to a sibling .tmp
file, fsyncs, then renames over the original. Edits are
restricted to files under /etc/ or
/usr/share/sysdeck/ (symlink-escape attacks
rejected via os.path.realpath + base-dir
allowlist). Port substitution uses a strict per-service
regex (NOT freeform sed) so only the port digits are
replaced — comments and other content on the line are
preserved.
This panel proxies to bridge.services.*
(added in v0.0.47), which in turn calls the existing
bridge.firewall.services /
service-info /
set-service-port /
restart-service subcommands. The
SERVICES_REGISTRY and atomic-write logic
remain in bridge/firewall.py as the single
source of truth.