812 lines
41 KiB
JavaScript
Executable File
812 lines
41 KiB
JavaScript
Executable File
/*
|
|
* 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 = `
|
|
<header>
|
|
<h2 class="suite-panel-title">Firewall Control</h2>
|
|
<p class="suite-panel-subtitle">
|
|
<span class="suite-badge ${isCilium ? 'info' : 'success'}">${escapeHtml(activeBackend)}</span>
|
|
· ${isCilium ? 'eBPF datapath' : 'nftables'}
|
|
· <span class="suite-badge ${state === 'running' ? 'success' : 'danger'}">${state}</span>
|
|
${!isCilium ? ` · ${chains.length} chains · ${rules.length} rules` : ''}
|
|
· ${bannedIpCount} banned IP${bannedIpCount === 1 ? '' : 's'}
|
|
${activeTemplate ? ` · active: <code>${escapeHtml(activeTemplate)}</code>` : ''}
|
|
</p>
|
|
</header>
|
|
|
|
${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()}
|
|
|
|
<div id="fw-output" class="suite-card" style="display:none">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Operation Output</h3>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-output-close">✕</button>
|
|
</div>
|
|
<pre class="suite-mono" id="fw-output-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
|
|
</div>
|
|
`;
|
|
|
|
// 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 `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Firewall Backend</h3>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted">
|
|
No firewall backends available. This indicates a
|
|
broken install — re-install the <code>sysdeck</code>
|
|
package.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
// 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
|
|
? '<span class="suite-badge info" style="margin-left:0.5rem">eBPF</span>'
|
|
: '<span class="suite-badge" style="margin-left:0.5rem">nftables</span>';
|
|
const statusBadge = installed
|
|
? '<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(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}
|
|
${statusBadge}
|
|
${isActive ? '<span class="suite-badge success" style="margin-left:0.5rem">active</span>' : ''}
|
|
<div class="suite-muted" style="margin-top:0.25rem">${escapeHtml(b.description || '')}</div>
|
|
${!installed && b.available?.install_hint
|
|
? `<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:6px;border-radius:4px;font-size:0.75rem;white-space:pre-wrap">${escapeHtml(b.available.install_hint)}</pre>`
|
|
: ''}
|
|
${!installed && b.install_packages && b.install_packages.length
|
|
? `<button class="suite-btn suite-btn-ghost btn-fw-install-backend" data-backend="${escapeHtml(b.id)}" style="margin-top:0.5rem">Install via packages module</button>`
|
|
: ''}
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
const excludedItems = excluded.map((e) => `
|
|
<li><code>${escapeHtml(e.id)}</code> — ${escapeHtml(e.reason)}</li>
|
|
`).join('');
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Firewall Backend (${backends.length})</h3>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-switch-backend">Switch Backend</button>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
${options}
|
|
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
|
|
Switching backend stops the previous backend cleanly
|
|
before applying the new one.
|
|
${backendHasTemplate
|
|
? `The <strong>${escapeHtml(activeBackendObj.name)}</strong> backend uses its own template (<code>${escapeHtml(activeBackendObj.template)}</code>) — no template selection needed.`
|
|
: 'The <strong>custom</strong> backend lets you pick from the basic nftables templates below.'}
|
|
</p>
|
|
${excluded.length ? `
|
|
<details style="margin-top:0.75rem">
|
|
<summary class="suite-muted" style="cursor:pointer;font-size:0.8rem">
|
|
Excluded backends (${excluded.length}) — click to expand
|
|
</summary>
|
|
<ul class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem;padding-left:1.5rem">
|
|
${excludedItems}
|
|
</ul>
|
|
</details>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Cilium eBPF Backend</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted">
|
|
cilium-cli is not installed. Use the "Install via
|
|
packages module" button on the backend card above,
|
|
or install manually:
|
|
</p>
|
|
<pre class="suite-mono" style="margin-top:0.5rem;background:#1a1a1a;padding:8px;border-radius:4px;font-size:0.8rem">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</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
const endpointCount = Array.isArray(endpointsResp?.endpoints) ? endpointsResp.endpoints.length : 0;
|
|
const policyCount = Array.isArray(policyResp?.policies) ? policyResp.policies.length : 0;
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Cilium Status</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(statusResp?.output || statusResp?.stderr || '(no output)')}</pre>
|
|
</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Cilium Endpoints (${endpointCount})</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(JSON.stringify(endpointsResp?.endpoints || [], null, 2))}</pre>
|
|
</div>
|
|
</div>
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Cilium Policies (${policyCount})</h3>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-cilium-apply-policy">Apply Default Policy</button>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<pre class="suite-mono" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:200px">${escapeHtml(JSON.stringify(policyResp?.policies || [], null, 2))}</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Service / Port Editor</h3>
|
|
<span class="suite-badge info">moved</span>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted" style="margin-bottom:0.5rem;font-size:0.9rem">
|
|
The service/port editor has been promoted to its own
|
|
sidebar entry — <strong>Service / Ports</strong> at
|
|
order 45 — for ease of access. It enumerates every
|
|
listening TCP socket on the host via
|
|
<code>ss -tlnp</code> (with a
|
|
<code>/proc/net/tcp</code> fallback), cross-references
|
|
against the <code>SERVICES_REGISTRY</code> in
|
|
<code>bridge/firewall.py</code>, and lets you edit
|
|
the port in the service's config file with an atomic
|
|
write + <code>systemctl restart</code>.
|
|
</p>
|
|
<p class="suite-muted" style="font-size:0.85rem">
|
|
Click <strong>Service / Ports</strong> in the sidebar
|
|
to open the editor. The bridge surface
|
|
(<code>bridge.firewall.services</code> /
|
|
<code>service-info</code> /
|
|
<code>set-service-port</code> /
|
|
<code>restart-service</code>) is unchanged from
|
|
v0.0.44; a new <code>bridge.services</code> proxy
|
|
was added in v0.0.47 so the new panel has a clean
|
|
API surface.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderSecurityCard(hardening) {
|
|
if (!hardening || !hardening.applied || !hardening.applied.length) {
|
|
return '';
|
|
}
|
|
const rows = hardening.applied.map((h) => `
|
|
<tr>
|
|
<td class="suite-table-mono">${escapeHtml(h.id || '')}</td>
|
|
<td><strong>${escapeHtml(h.title || '')}</strong><div class="suite-muted" style="font-size:0.8rem">${escapeHtml(h.detail || '')}</div></td>
|
|
<td class="suite-table-mono suite-muted">${escapeHtml(h.cve || '')}</td>
|
|
</tr>
|
|
`).join('');
|
|
const cveBadges = (hardening.cves_reviewed || []).slice(0, 12).map((cve) =>
|
|
`<span class="suite-badge" style="margin-right:0.25rem;font-size:0.7rem">${escapeHtml(cve)}</span>`
|
|
).join('');
|
|
const moreCount = (hardening.cves_reviewed || []).length - 12;
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Security Hardening (v0.0.36)</h3>
|
|
<span class="suite-muted" style="font-size:0.8rem">${hardening.applied.length} lessons applied</span>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted" style="margin-bottom:0.5rem;font-size:0.85rem">
|
|
Each hardening item maps to a real CVE disclosure in
|
|
Webmin, Cockpit, Ajenti, ISPConfig, or Virtualmin.
|
|
Full checklist in <code>docs/SECURITY-HARDENING.md</code>.
|
|
</p>
|
|
<table class="suite-table">
|
|
<thead><tr><th>ID</th><th>Hardening</th><th>CVE</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
<div style="margin-top:0.75rem">
|
|
<span class="suite-muted" style="font-size:0.8rem">CVEs reviewed: </span>
|
|
${cveBadges}
|
|
${moreCount > 0 ? `<span class="suite-muted" style="font-size:0.8rem">+ ${moreCount} more</span>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Templates</h3>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted">
|
|
No firewall templates found under
|
|
<code>/usr/share/sysdeck/firewall/templates/</code>.
|
|
Install the <code>sysdeck</code> package to ship the
|
|
default templates (vps-webserver.sh, no-services.sh),
|
|
or drop your own <code>*.sh</code> file there.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
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(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>' : ''}
|
|
<div class="suite-muted" style="margin-top:0.25rem">${escapeHtml(t.description || '')}</div>
|
|
${t.services && t.services.length ? `<div class="suite-muted" style="margin-top:0.25rem">Services: ${t.services.map((s) => `<span class="suite-badge info" style="margin-right:0.25rem">${escapeHtml(s)}</span>`).join('')}</div>` : ''}
|
|
${t.distros && t.distros.length ? `<div class="suite-muted" style="margin-top:0.25rem">Distros: ${t.distros.map((d) => `<span class="suite-badge" style="margin-right:0.25rem">${escapeHtml(d)}</span>`).join('')}</div>` : ''}
|
|
</label>
|
|
`;
|
|
}).join('');
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Templates (${filteredTemplates.length})</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
${items}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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 `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Controls</h3>
|
|
</div>
|
|
<div class="suite-card-body">
|
|
<div class="suite-row" style="gap:0.5rem">
|
|
<button class="suite-btn suite-btn-primary" id="btn-fw-apply" ${isRunning ? 'disabled' : ''}>${escapeHtml(applyLabel)}</button>
|
|
<button class="suite-btn" id="btn-fw-restart" ${!isRunning ? 'disabled' : ''}>↻ Restart</button>
|
|
<button class="suite-btn" id="btn-fw-stop" ${!isRunning ? 'disabled' : ''}>■ Stop</button>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-detect">🔎 Detect Services</button>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-check">✓ Validate</button>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-refresh">↻ Refresh</button>
|
|
</div>
|
|
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
|
|
Apply / Restart / Stop prompt for the cockpit superuser
|
|
password via polkit. The
|
|
<code>org.sysdeck.firewall.modify</code> action authorizes
|
|
<code>/usr/bin/nft</code>${isCilium ? ', <code>/usr/bin/cilium</code>, <code>/usr/bin/cilium-agent</code>, <code>/usr/bin/helm</code>' : ''}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderDetectionSection() {
|
|
return `
|
|
<div class="suite-card" id="fw-detect-card" style="display:none">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Service Detection</h3>
|
|
</div>
|
|
<pre class="suite-mono" id="fw-detect-pre" style="white-space:pre-wrap;background:#1a1a1a;padding:8px;border-radius:4px;overflow:auto;max-height:300px"></pre>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderBans(bans, total) {
|
|
const sets = Object.keys(bans);
|
|
if (!sets.length) {
|
|
return `
|
|
<div class="suite-card">
|
|
<h3 class="suite-card-title">Banned IPs (0)</h3>
|
|
<div class="suite-card-body">
|
|
<p class="suite-muted">No ban sets available — firewall is not running.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
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 `
|
|
<tr>
|
|
<td class="suite-table-mono">${escapeHtml(ip)}</td>
|
|
<td>${setsWithIp.map((s) => `<span class="suite-badge info" style="margin-right:0.25rem">${escapeHtml(s)}</span>`).join('')}</td>
|
|
<td><button class="suite-btn suite-btn-ghost btn-fw-unban" data-ip="${escapeHtml(ip)}">Unban</button></td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Banned IPs (${allBanned.size})</h3>
|
|
<button class="suite-btn suite-btn-ghost" id="btn-fw-clear-bans" ${!allBanned.size ? 'disabled' : ''}>Clear All</button>
|
|
</div>
|
|
<table class="suite-table">
|
|
<thead><tr><th>IP</th><th>In sets</th><th>Action</th></tr></thead>
|
|
<tbody>
|
|
${banRows || '<tr><td colspan="3" class="suite-muted">No banned IPs.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
<p class="suite-muted" style="margin-top:0.5rem;font-size:0.8rem">
|
|
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).
|
|
</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderRuleset(rules, chains) {
|
|
return `
|
|
<div class="suite-card">
|
|
<div class="suite-card-header">
|
|
<h3 class="suite-card-title">Active Ruleset</h3>
|
|
<span class="suite-muted" style="font-size:0.8rem">${rules.length} rules in ${chains.length} chains</span>
|
|
</div>
|
|
<table class="suite-table">
|
|
<thead>
|
|
<tr><th>Chain</th><th>Rule</th><th>Handle</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
${rules.slice(0, 100).map((r) => `
|
|
<tr>
|
|
<td><span class="suite-badge info">${escapeHtml(r.chain)}</span></td>
|
|
<td class="suite-table-mono">${escapeHtml(r.spec)}</td>
|
|
<td class="suite-table-mono suite-muted">${r.handle}</td>
|
|
</tr>
|
|
`).join('') || '<tr><td colspan="3" class="suite-muted">No rules — firewall is not running.</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
${rules.length > 100 ? `<p class="suite-muted">Showing first 100 of ${rules.length} rules.</p>` : ''}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── 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, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function renderSkeleton() {
|
|
return `<div class="suite-skeleton">
|
|
<div class="suite-skeleton-line w-1/3"></div>
|
|
<div class="suite-skeleton-line w-2/3"></div>
|
|
<div class="suite-skeleton-line w-1/2"></div>
|
|
</div>`;
|
|
}
|