/* * 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 `

Service / Port Editor

${services.length} services · ${editableCount} editable · ${listenerCount} listeners · bridge.firewall.services()

${renderIntroCard(services, listenerCount)} ${services.length ? renderFilterRow() : ''} ${services.length ? renderServicesTable(services) : renderServicesEmpty()} ${unmapped.length ? renderUnmappedListeners(unmapped) : ''} ${renderNotesCard()} `; } function renderIntroCard(services, listenerCount) { return `

What this panel does

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.

`; } function renderFilterRow() { return `
`; } function renderServicesTable(services) { const rows = services.map((s) => renderServiceRow(s)).join(''); return `

Registered Services (${services.length})

${rows}
Service Port (editable) Config port Default Listening Process PID Config file
`; } function renderServiceRow(s) { const listening = (s.listening_ports || []).join(', ') || '—'; const processes = (s.processes || []).join(', ') || '—'; const pids = (s.pids || []).join(', ') || '—'; const editableBadge = s.editable ? 'editable' : 'no config'; const restartBadge = s.restart_supported ? 'restartable' : 'no unit'; const portValue = s.current_port_in_config ?? s.default_port; const portInput = s.editable ? `` : `${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 ? `
${escapeHtml(s.description)}
` : ''; return ` ${escapeHtml(s.name)}
${escapeHtml(s.id)}
${editableBadge}${restartBadge} ${description} ${portInput}${saveBtn}${restartBtn} ${escapeHtml(String(s.current_port_in_config ?? '—'))} ${escapeHtml(String(s.default_port))} ${escapeHtml(listening)} ${escapeHtml(processes)} ${escapeHtml(pids)} ${configCell} `; } function renderServicesEmpty() { return `

Registered Services

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.

`; } function renderUnmappedListeners(unmapped) { const rows = unmapped.map((l) => ` ${escapeHtml(String(l.port))} ${escapeHtml(l.proto || '—')} ${escapeHtml(l.process || '—')} ${escapeHtml(String(l.pid || '—'))} `).join(''); return `

Unmapped Listeners (${unmapped.length})

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.

${rows}
PortProtoProcessPID
`; } function renderNotesCard() { return `

Notes & Security

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.

`; } // ── Event wiring ──────────────────────────────────────────────────── function wireEvents(panel, { bridge, EventBus }) { const output = (msg, isError = false) => { const card = panel.querySelector('#svc-output'); const pre = panel.querySelector('#svc-output-pre'); if (!card || !pre) return; card.style.display = 'block'; pre.textContent = msg; pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; }; panel.querySelector('#btn-svc-output-close')?.addEventListener('click', () => { const card = panel.querySelector('#svc-output'); if (card) card.style.display = 'none'; }); // Filter box: live-filter the services table by name/id/port/process. panel.querySelector('#svc-filter')?.addEventListener('input', (ev) => { const q = String(ev.target.value || '').toLowerCase().trim(); const onlyEditable = panel.querySelector('#svc-only-editable')?.checked || false; panel.querySelectorAll('.svc-row').forEach((row) => { const text = row.dataset.searchText || ''; const editable = row.dataset.editable === '1'; const matchesText = !q || text.includes(q); const matchesEditable = !onlyEditable || editable; row.style.display = (matchesText && matchesEditable) ? '' : 'none'; }); }); // Show-only-editable toggle: re-apply the filter. panel.querySelector('#svc-only-editable')?.addEventListener('change', () => { panel.querySelector('#svc-filter')?.dispatchEvent(new Event('input')); }); // Refresh button: re-mount the panel. panel.querySelector('#btn-svc-refresh')?.addEventListener('click', () => { mount(panel, { bridge, EventBus }); }); // Save & Restart: read the port from the sibling input, validate // client-side, then call bridge.services.setPort(id, port). panel.querySelectorAll('.btn-svc-save').forEach((btn) => { btn.addEventListener('click', async () => { const sid = btn.dataset.serviceId; if (!sid) return; const input = panel.querySelector(`.svc-port-input[data-service-id="${CSS.escape(sid)}"]`); if (!input) { output(`Could not find port input for ${sid}`, true); return; } const portStr = String(input.value || '').trim(); const port = Number.parseInt(portStr, 10); if (!Number.isInteger(port) || port < 1 || port > 65535) { output(`Invalid port '${portStr}' for ${sid}. Must be 1..65535.`, true); return; } output(`Setting ${sid} port to ${port} ... (cockpit will prompt for auth)`); try { const r = await bridge.services.setPort(sid, port); if (r.error) { output(`Save FAILED for ${sid}: ${r.error}`, true); return; } const msg = r.restarted ? `${r.name}: port ${r.old_port} → ${r.new_port} (config: ${r.config_file})\nService restarted via ${r.restart_method}.` : `${r.name}: port ${r.old_port} → ${r.new_port} (config: ${r.config_file})\n⚠ Service restart FAILED (rc=${r.restart_rc}): ${r.restart_stderr || '(no stderr)'}`; output(msg, !r.restarted); if (r.new_port) setTimeout(() => mount(panel, { bridge, EventBus }), 1200); } catch (err) { output(`Save error: ${err.message || err}`, true); } }); }); // Restart-only: useful when the operator edited the config by hand. panel.querySelectorAll('.btn-svc-restart').forEach((btn) => { btn.addEventListener('click', async () => { const sid = btn.dataset.serviceId; if (!sid) return; output(`Restarting ${sid} ... (cockpit will prompt for auth)`); try { const r = await bridge.services.restart(sid); if (r.error) { output(`Restart FAILED for ${sid}: ${r.error}`, true); return; } output(r.restarted ? `${r.name} restarted via ${r.restart_method}.` : `${r.name} restart FAILED (rc=${r.restart_rc}): ${r.restart_stderr || '(no stderr)'}`, !r.restarted); } catch (err) { output(`Restart error: ${err.message || err}`, true); } }); }); } // ── Utilities ─────────────────────────────────────────────────────── async function safe(p, fallback) { try { const v = await p; return v ?? fallback; } catch { return fallback; } } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderSkeleton() { return `
`; }