/* * SysDeck - Firewall Panel (v0.0.45) * Author: Jeremy Anderson (https://dcos.net) * * v0.0.45 TRADEMARK SCRUB. Wording-only release — no functional changes. * Per user directive: "you cannot say smoothwall and ipfire where merged * into our fw script either. you can say logic derived from or influenced * by these projects." Every reference to Smoothwall Express or IPFire now * uses "takes influence from" / "logic derived from" instead of "merged" * or "shipped". The sysdeck-fw backend, the 7 firewall templates, and the * v0.0.44 service/port editor are unchanged. All 141 unit tests pass. * * v0.0.47 — SERVICE/PORT EDITOR MOVED TO ITS OWN SIDEBAR ENTRY. * Per user directive: "we should move the service/ports editor to its * own module entry for ease of access." The editor card that lived at * the bottom of this panel since v0.0.44 has been lifted out into a * first-class plugin — sysdeck-services at order 45. The bridge surface * (bridge.firewall.services / service-info / set-service-port / * restart-service) is unchanged; a new bridge.services proxy was added * to bridge.js so the new panel has a clean API. The services() Promise * in the parallel load below was removed because this panel no longer * needs the inventory — it lives in the new Services panel. The * renderServicePortEditor() function and the .btn-svc-save / .btn-svc- * restart wireEvents handlers were removed from this file. * * v0.0.44 PUBLIC-SERVER VARIANTS + SERVICE/PORT EDITOR. * * - Three new public-server templates ship in this release: * remote-admin.sh (SSH + Cockpit), public-webserver.sh * (Caddy + Varnish + MariaDB), ai-llm.sh (Ollama + OpenWebUI * + Hermes + Odysseus). They appear in the existing Templates * card when the 'custom' backend is active — no new UI surface * needed for selection. * - Service/Port Editor card — runs bridge.firewall.services() to * enumerate listening TCP ports on the host and cross-reference * against the SERVICES_REGISTRY (ssh, cockpit, caddy, varnish, * mariadb, ollama, openwebui, hermes, odysseus). Each registered * service shows: current port from its config file, the listening * ports actually active, the systemd unit, and an editable port * input. Clicking Save edits the config file atomically (tmpfile * + fsync + rename) and runs `systemctl restart` on the service. * Unmapped listeners (ports with no matching registry entry) are * shown in a separate block so the operator can spot services * the editor doesn't yet know about. * * v0.0.31 REWRITE — PREVIOUS VERSION WAS READ-ONLY. * * The v0.0.30 panel could only list active nftables rules. v0.0.31 * turns it into a full firewall manager: * * - Template selector — operator picks from installed templates * under /usr/share/sysdeck/firewall/templates/ (vps-webserver.sh, * no-services.sh, plus any operator-dropped *.sh). Each template * is shown with its description and detected services. * - Apply / Stop / Restart buttons — invoke the template's start / * stop / restart action via the bridge under the cockpit * superuser channel (polkit). No `sudo` shell-out from JS. * - Service detection preview — runs the template's `detect` * action and renders the inventory (OS, interface, services * detected) before applying. * - Live ban-list table — ssh_abuse / port_scanners / connlimit_abuse * sets with per-IP Unban buttons and a Clear All button. * - Active ruleset table — refreshed after each mutating operation * so the operator sees the new state immediately. * * v0.0.36 BACKEND DROPDOWN + SECURITY CARD. * * - Backend selector — operator picks between custom / cilium / * sysdeck-fw. The bridge probes availability (cilium * installed? nftables installed? kernel BPF features?) and shows * an install hint if missing. The "Install" button triggers * bridge.firewall.installBackend (delegates to packages module). * - Cilium-specific sections — when cilium is the active backend, * the panel renders Cilium Status / Endpoints / Policy cards in * place of the nftables ruleset table. * - Security Card — renders the CVE-derived hardening checklist * (bridge.firewall.securityHardening). Documents the lessons * applied from Webmin, Cockpit, Ajenti, ISPConfig, Virtualmin, * cPanel, Plesk, CyberPanel, aaPanel, CloudPanel, HestiaCP, * VestaCP, Froxlor, InterWorx, BrainyCP, DirectAdmin, CWP CVE * disclosures. Full table in docs/SECURITY-HARDENING.md. * - Excluded backends info — explains why UFW, fwbuilder, iptables- * legacy, iptables-nft, Shorewall, Smoothwall Express (trademark), * and IPFire (trademark) are not in the dropdown. We took influence * from Smoothwall Express and IPFire for the sysdeck-fw backend; * we do not ship templates called "smoothwall" or "ipfire". * * Mutating ops go through bridge.firewall.apply / stop / restart / ban / * unban / clearBans / switchBackend / installBackend / ciliumPolicyApply, * which pass { superuser: 'try' } to cockpit.spawn. The cockpit bridge * prompts the operator for auth via polkit; the org.sysdeck.firewall.modify * action (shipped since v0.0.17, extended in v0.0.36 to authorize cilium * + cilium-agent + helm) authorizes the binaries. This is the "cockpit * way" per user directive v0.0.31. */ export async function mount(panel, { bridge, EventBus }) { panel.innerHTML = renderSkeleton(); // v0.0.47: services inventory no longer loaded here — the editor // moved to its own sidebar entry (sysdeck-services at order 45). const [backendsResp, templates, status, rules, chains, hardening] = await Promise.all([ safe(bridge.firewall.backends(), { active: 'custom', backends: [], excluded: [] }), safe(bridge.firewall.templates(), []), safe(bridge.firewall.status(), { state: 'unavailable', bans: {} }), safe(bridge.firewall.listRules(), []), safe(bridge.firewall.listChains(), []), safe(bridge.firewall.securityHardening(), { applied: [], cves_reviewed: [] }), ]); const activeBackend = backendsResp?.active || 'custom'; const backends = backendsResp?.backends || []; const excluded = backendsResp?.excluded || []; const activeTemplate = status?.active_template || null; const state = status?.state || 'unavailable'; const bans = status?.bans || {}; const bannedIpCount = status?.banned_ip_count || 0; const isCilium = activeBackend === 'cilium'; panel.innerHTML = `

Firewall Control

${escapeHtml(activeBackend)} · ${isCilium ? 'eBPF datapath' : 'nftables'} · ${state} ${!isCilium ? ` · ${chains.length} chains · ${rules.length} rules` : ''} · ${bannedIpCount} banned IP${bannedIpCount === 1 ? '' : 's'} ${activeTemplate ? ` · active: ${escapeHtml(activeTemplate)}` : ''}

${renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate)} ${isCilium ? await renderCiliumSections(bridge) : ''} ${!isCilium ? renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) : ''} ${renderControls(state, isCilium, activeBackend, backends)} ${renderDetectionSection()} ${!isCilium ? renderBans(bans, bannedIpCount) : ''} ${!isCilium ? renderRuleset(rules, chains) : ''} ${renderSecurityCard(hardening)} ${renderServicesLinkCard()} `; // v0.0.43: Store backends data for getSelectedTemplate() to access // (the Apply button handler needs to know which backend is active // to determine which template to apply). window.__sysdeckFirewallBackends = backends; window.__sysdeckFirewallActiveBackend = activeBackend; wireEvents(panel, { bridge, EventBus }); EventBus.emit('firewall.loaded', { ruleCount: rules.length, state, bannedIpCount, activeBackend, }); } // ── Render helpers ────────────────────────────────────────────────── function renderBackendSelector(backends, excluded, activeBackend, templates, activeTemplate) { if (!backends || !backends.length) { return `

Firewall Backend

No firewall backends available. This indicates a broken install — re-install the sysdeck package.

`; } // v0.0.43: Find the active backend object to determine whether it // has its own template (cilium → cilium, sysdeck-fw → sysdeck-fw). // If it does, the template selector is hidden — the backend IS the // template. Only 'custom' shows the template selector. const activeBackendObj = backends.find((b) => b.id === activeBackend) || backends[0]; const backendHasTemplate = activeBackendObj && activeBackendObj.template; const options = backends.map((b) => { const isActive = b.id === activeBackend; const installed = b.available?.installed; const techBadge = b.ebpf ? 'eBPF' : 'nftables'; const statusBadge = installed ? 'installed' : 'not installed'; return ` `; }).join(''); const excludedItems = excluded.map((e) => `
  • ${escapeHtml(e.id)} — ${escapeHtml(e.reason)}
  • `).join(''); return `

    Firewall Backend (${backends.length})

    ${options}

    Switching backend stops the previous backend cleanly before applying the new one. ${backendHasTemplate ? `The ${escapeHtml(activeBackendObj.name)} backend uses its own template (${escapeHtml(activeBackendObj.template)}) — no template selection needed.` : 'The custom backend lets you pick from the basic nftables templates below.'}

    ${excluded.length ? `
    Excluded backends (${excluded.length}) — click to expand
      ${excludedItems}
    ` : ''}
    `; } async function renderCiliumSections(bridge) { // Fetch Cilium status + endpoints + policy in parallel. const [statusResp, endpointsResp, policyResp] = await Promise.all([ safe(bridge.firewall.ciliumStatus(), { installed: false, output: '', stderr: '' }), safe(bridge.firewall.ciliumEndpoints(), { installed: false, endpoints: [] }), safe(bridge.firewall.ciliumPolicy(), { installed: false, policies: [] }), ]); const installed = statusResp?.installed || false; if (!installed) { return `

    Cilium eBPF Backend

    cilium-cli is not installed. Use the "Install via packages module" button on the backend card above, or install manually:

    sudo pacman -S cilium-cli      # Arch
    sudo apt install cilium-cli    # Debian
    helm repo add cilium https://helm.cilium.io/
    helm install cilium cilium/cilium -n kube-system
    `; } const endpointCount = Array.isArray(endpointsResp?.endpoints) ? endpointsResp.endpoints.length : 0; const policyCount = Array.isArray(policyResp?.policies) ? policyResp.policies.length : 0; return `

    Cilium Status

    ${escapeHtml(statusResp?.output || statusResp?.stderr || '(no output)')}

    Cilium Endpoints (${endpointCount})

    ${escapeHtml(JSON.stringify(endpointsResp?.endpoints || [], null, 2))}

    Cilium Policies (${policyCount})

    ${escapeHtml(JSON.stringify(policyResp?.policies || [], null, 2))}
    `; } function renderServicesLinkCard() { // v0.0.47: the Service / Port Editor moved to its own sidebar entry // (sysdeck-services at order 45). This card is a signpost — it tells // the operator where to find the editor and what it does. Keeping // a stub here preserves the workflow for operators who used to // scroll to the bottom of the Firewall panel for port edits. return `

    Service / Port Editor

    moved

    The service/port editor has been promoted to its own sidebar entry — Service / Ports at order 45 — for ease of access. It enumerates every listening TCP socket on the host via ss -tlnp (with a /proc/net/tcp fallback), cross-references against the SERVICES_REGISTRY in bridge/firewall.py, and lets you edit the port in the service's config file with an atomic write + systemctl restart.

    Click Service / Ports in the sidebar to open the editor. The bridge surface (bridge.firewall.services / service-info / set-service-port / restart-service) is unchanged from v0.0.44; a new bridge.services proxy was added in v0.0.47 so the new panel has a clean API surface.

    `; } function renderSecurityCard(hardening) { if (!hardening || !hardening.applied || !hardening.applied.length) { return ''; } const rows = hardening.applied.map((h) => ` ${escapeHtml(h.id || '')} ${escapeHtml(h.title || '')}
    ${escapeHtml(h.detail || '')}
    ${escapeHtml(h.cve || '')} `).join(''); const cveBadges = (hardening.cves_reviewed || []).slice(0, 12).map((cve) => `${escapeHtml(cve)}` ).join(''); const moreCount = (hardening.cves_reviewed || []).length - 12; return `

    Security Hardening (v0.0.36)

    ${hardening.applied.length} lessons applied

    Each hardening item maps to a real CVE disclosure in Webmin, Cockpit, Ajenti, ISPConfig, or Virtualmin. Full checklist in docs/SECURITY-HARDENING.md.

    ${rows}
    IDHardeningCVE
    CVEs reviewed: ${cveBadges} ${moreCount > 0 ? `+ ${moreCount} more` : ''}
    `; } function renderTemplateSelector(templates, activeTemplate, state, activeBackend, backends) { // v0.0.43: If the active backend has its own template (cilium → cilium, // sysdeck-fw → sysdeck-fw), DON'T render the template selector at all — // the backend IS the template. This fixes the v0.0.36 logic flaw where // two independent lists (backend + template) didn't coordinate. const activeBackendObj = backends?.find((b) => b.id === activeBackend); if (activeBackendObj?.template) { return ''; // backend has its own template — selector not needed } // For the 'custom' backend, filter templates to show ONLY the basic // nftables templates (vps-webserver, no-services). Exclude cilium + // sysdeck-fw — those are backend-specific and would conflict if applied // while the custom backend is active. const backendTemplates = new Set( (backends || []) .filter((b) => b.template) // backends with their own template .map((b) => b.template) ); const filteredTemplates = (templates || []).filter((t) => !backendTemplates.has(t.name) ); if (!filteredTemplates.length) { return `

    Templates

    No firewall templates found under /usr/share/sysdeck/firewall/templates/. Install the sysdeck package to ship the default templates (vps-webserver.sh, no-services.sh), or drop your own *.sh file there.

    `; } const items = filteredTemplates.map((t) => { const isActive = t.name === activeTemplate; return ` `; }).join(''); return `

    Templates (${filteredTemplates.length})

    ${items}
    `; } function renderControls(state, isCilium, activeBackend, backends) { const isRunning = state === 'running'; // v0.0.43: backend-aware Apply button label. let applyLabel; if (isCilium) { applyLabel = '▶ Apply Cilium Policy'; } else { const backend = backends?.find((b) => b.id === activeBackend); if (backend?.template) { applyLabel = `▶ Apply ${backend.template}`; } else { applyLabel = '▶ Apply Template'; } } return `

    Controls

    Apply / Restart / Stop prompt for the cockpit superuser password via polkit. The org.sysdeck.firewall.modify action authorizes /usr/bin/nft${isCilium ? ', /usr/bin/cilium, /usr/bin/cilium-agent, /usr/bin/helm' : ''}.

    `; } function renderDetectionSection() { return ` `; } function renderBans(bans, total) { const sets = Object.keys(bans); if (!sets.length) { return `

    Banned IPs (0)

    No ban sets available — firewall is not running.

    `; } const allBanned = new Set(); for (const set of sets) { for (const ip of (bans[set] || [])) allBanned.add(ip); } const banRows = [...allBanned].map((ip) => { const setsWithIp = sets.filter((s) => (bans[s] || []).includes(ip)); return ` ${escapeHtml(ip)} ${setsWithIp.map((s) => `${escapeHtml(s)}`).join('')} `; }).join(''); return `

    Banned IPs (${allBanned.size})

    ${banRows || ''}
    IPIn setsAction
    No banned IPs.

    Ban sets: ${sets.join(', ')}. ssh_abuse = SSH brute-force ban (1h timeout), port_scanners = port scan detection (1h timeout), connlimit_abuse = connection rate limit exceeded (10m timeout).

    `; } function renderRuleset(rules, chains) { return `

    Active Ruleset

    ${rules.length} rules in ${chains.length} chains
    ${rules.slice(0, 100).map((r) => ` `).join('') || ''}
    ChainRuleHandle
    ${escapeHtml(r.chain)} ${escapeHtml(r.spec)} ${r.handle}
    No rules — firewall is not running.
    ${rules.length > 100 ? `

    Showing first 100 of ${rules.length} rules.

    ` : ''}
    `; } // ── Event wiring ──────────────────────────────────────────────────── function wireEvents(panel, { bridge, EventBus }) { const output = (msg, isError = false) => { const card = panel.querySelector('#fw-output'); const pre = panel.querySelector('#fw-output-pre'); if (!card || !pre) return; card.style.display = 'block'; pre.textContent = msg; pre.style.color = isError ? 'var(--sysdeck-accent-danger)' : 'var(--sysdeck-fg)'; }; panel.querySelector('#btn-fw-output-close')?.addEventListener('click', () => { const card = panel.querySelector('#fw-output'); if (card) card.style.display = 'none'; }); const getSelectedTemplate = () => { // v0.0.43: The template to apply depends on the active backend. // If the backend has its own template (cilium → cilium, sysdeck-fw // → sysdeck-fw), use that — the template selector is hidden. // If the backend is 'custom', use the operator's selection from // the template radio buttons. const backendsResp = window.__sysdeckFirewallBackends; const activeBackend = window.__sysdeckFirewallActiveBackend; if (backendsResp && activeBackend) { const backend = backendsResp.find((b) => b.id === activeBackend); if (backend?.template) return backend.template; } // 'custom' backend — use the radio button selection. const checked = panel.querySelector('input[name="fw-template"]:checked'); if (checked) return checked.value; // If only one template, return it. const only = panel.querySelector('input[name="fw-template"]'); return only ? only.value : null; }; const getSelectedBackend = () => { const checked = panel.querySelector('input[name="fw-backend"]:checked'); return checked ? checked.value : null; }; // v0.0.36: backend dropdown — switch backend (stops old, applies new). panel.querySelector('#btn-fw-switch-backend')?.addEventListener('click', async () => { const backend = getSelectedBackend(); if (!backend) { output('Select a backend first.', true); return; } output(`Switching firewall backend to ${backend} ... (cockpit will prompt for auth)`); try { const r = await bridge.firewall.switchBackend(backend); const steps = (r.steps || []).map((s) => ` [${s.step}] rc=${s.rc} ${s.output ? '· ' + s.output.split('\n')[0] : ''}${s.stderr ? ' · stderr: ' + s.stderr.split('\n')[0] : ''}` ).join('\n'); output(r.switched ? `Backend switched: ${r.previous} → ${r.current}\n\nSteps:\n${steps}\n\nAvailable: ${JSON.stringify(r.available)}` : `Backend switch FAILED: ${JSON.stringify(r, null, 2)}`, !r.switched); if (r.switched) setTimeout(() => mount(panel, { bridge, EventBus }), 1200); } catch (err) { output(`Switch-backend error: ${err.message || err}`, true); } }); // v0.0.36: install backend deps (delegates to packages module). panel.querySelectorAll('.btn-fw-install-backend').forEach((btn) => { btn.addEventListener('click', async (ev) => { ev.preventDefault(); ev.stopPropagation(); const backend = btn.dataset.backend; if (!backend) return; output(`Installing packages for backend ${backend} ... (cockpit will prompt for auth)`); try { const r = await bridge.firewall.installBackend(backend); output(r.installed ? `Backend ${backend} installed.\nPackages: ${(r.packages || []).join(', ')}\n\n${r.output || ''}` : `Install FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`, !r.installed); if (r.installed) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Install-backend error: ${err.message || err}`, true); } }); }); // v0.0.36: apply Cilium default policy. panel.querySelector('#btn-fw-cilium-apply-policy')?.addEventListener('click', async () => { output('Applying Cilium default policy ... (cockpit will prompt for auth)'); try { const r = await bridge.firewall.ciliumPolicyApply('cilium-default.yaml'); output(r.applied ? `Cilium policy applied: ${r.policy}\n\n${r.output || ''}` : `Cilium policy apply FAILED.\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`, !r.applied); if (r.applied) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Cilium-policy-apply error: ${err.message || err}`, true); } }); panel.querySelector('#btn-fw-apply')?.addEventListener('click', async () => { const template = getSelectedTemplate(); if (!template) { output('Select a template first.', true); return; } output(`Applying template ${template} ... (cockpit will prompt for auth)`); try { const r = await bridge.firewall.apply(template); const msg = r.success ? `Template ${r.template} applied successfully.\n\n${r.output || ''}` : `Apply FAILED (rc=${r.rc}).\n\nstderr: ${r.stderr || '(empty)'}\n\nstdout: ${r.output || '(empty)'}`; output(msg, !r.success); if (r.success) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Apply error: ${err.message || err}`, true); } }); panel.querySelector('#btn-fw-restart')?.addEventListener('click', async () => { output('Restarting firewall ... (cockpit will prompt for auth)'); try { const r = await bridge.firewall.restart(); const ok = r.restarted; output(ok ? `Firewall restarted (template: ${r.template || 'none'}).\n\n${JSON.stringify(r.apply_result || {}, null, 2)}` : `Restart did not complete: ${JSON.stringify(r, null, 2)}`, !ok); if (ok) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Restart error: ${err.message || err}`, true); } }); panel.querySelector('#btn-fw-stop')?.addEventListener('click', async () => { output('Stopping firewall ... (cockpit will prompt for auth)'); try { const r = await bridge.firewall.stop(); output(r.stopped ? `Firewall stopped (method: ${r.method}).\n\n${r.output || ''}` : `Stop FAILED.\n\nstderr: ${r.stderr || '(empty)'}`, !r.stopped); if (r.stopped) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Stop error: ${err.message || err}`, true); } }); panel.querySelector('#btn-fw-detect')?.addEventListener('click', async () => { const card = panel.querySelector('#fw-detect-card'); const pre = panel.querySelector('#fw-detect-pre'); if (!card || !pre) return; card.style.display = 'block'; pre.textContent = 'Running service detection ...'; try { const r = await bridge.firewall.detect(); pre.textContent = r.output || `(no output)\n\nstderr: ${r.stderr || ''}`; EventBus.emit('firewall.detected', { template: r.active_template }); } catch (err) { pre.textContent = `Detect error: ${err.message || err}`; } }); panel.querySelector('#btn-fw-check')?.addEventListener('click', async () => { output('Validating ruleset ...'); try { const r = await bridge.firewall.check(); output(r.valid ? 'Ruleset is valid.' : `Validation FAILED.\n\nstderr: ${r.stderr || '(empty)'}`, !r.valid); } catch (err) { output(`Check error: ${err.message || err}`, true); } }); panel.querySelector('#btn-fw-clear-bans')?.addEventListener('click', async () => { output('Clearing all ban lists ... (cockpit will prompt for auth)'); try { const r = await bridge.firewall.clearBans(); output(r.cleared ? 'All ban lists cleared.' : `Clear-bans partial failure: ${JSON.stringify(r.sets)}`, !r.cleared); if (r.cleared) setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Clear-bans error: ${err.message || err}`, true); } }); panel.querySelectorAll('.btn-fw-unban').forEach((btn) => { btn.addEventListener('click', async () => { const ip = btn.dataset.ip; output(`Unbanning ${ip} ... (cockpit will prompt for auth)`); try { const r = await bridge.firewall.unban(ip); output(r.unbanned ? `${ip} unbanned from sets: ${Object.entries(r.sets).filter(([k, v]) => v).map(([k]) => k).join(', ') || '(was not in any set)'}` : `${ip} was not found in any ban set.`, !r.unbanned); setTimeout(() => mount(panel, { bridge, EventBus }), 800); } catch (err) { output(`Unban error: ${err.message || err}`, true); } }); }); panel.querySelector('#btn-fw-refresh')?.addEventListener('click', () => { mount(panel, { bridge, EventBus }); }); // v0.0.47: the .btn-svc-save / .btn-svc-restart handlers and the // .svc-port-input wiring were removed from this file — the entire // Service / Port Editor card moved to the new sysdeck-services // plugin at sidebar order 45. See plugins/sysdeck-services/services.js. // systemd unit subscription (kept from v0.0.30 — best-effort). if (panel._unsubscribeUnit) panel._unsubscribeUnit(); try { if (bridge.dbusProxies?.systemd) { panel._unsubscribeUnit = bridge.dbusProxies.systemd.subscribeToUnit( 'nftables.service', () => mount(panel, { bridge, EventBus }), ); } } catch { // systemd proxy unavailable — manual refresh still works. } } // ── Utilities ─────────────────────────────────────────────────────── async function safe(p, fallback) { try { const v = await p; return v ?? fallback; } catch { return fallback; } } function escapeHtml(s) { return String(s == null ? '' : s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function renderSkeleton() { return `
    `; }