SysDeck/bridge/firewall.py

2712 lines
109 KiB
Python
Executable File

#!/usr/bin/env python3
"""
SysDeck - Firewall Bridge Helper
Author: Jeremy Anderson (https://dcos.net)
v0.0.44 PUBLIC-SERVER VARIANTS + SERVICE/PORT EDITOR.
Adds three new public-server firewall templates:
- remote-admin.sh SSH (22) + Cockpit (9090). For VPS/cloud hosts
where the operator needs remote shell + web admin.
- public-webserver.sh Caddy (80/443) + Varnish (8080) + MariaDB
(3306, loopback-only, defense-in-depth drop).
For public web servers with a reverse-proxy/cache
stack and a database.
- ai-llm.sh Ollama (11434) + OpenWebUI (3000) + Hermes (8000)
+ Odysseus (8001) + SSH (22). For self-hosted
AI LLM stacks on a personal/team workstation.
Also adds the service/port editor (4 new bridge subcommands):
services detect running listening ports + cross-ref
the SERVICES_REGISTRY (ss -tlnp + /proc/net/tcp
fallback). Returns the full inventory.
service-info <id> show one service's full registry entry + the
detected port from its config file.
set-service-port <id> <new_port>
edit the port in the service's config file
(atomic write: tmpfile + fsync + rename),
then systemctl restart the service. Validates
the service id against the registry (no
arbitrary file edits), validates the port
(1..65535), and resolves the config path
with os.path.realpath + base-dir allowlist
check.
restart-service <id> just restart the service (no port change).
Useful for "I edited the config by hand" flows.
FULL MANAGER, NOT A MONITOR: the bridge can start, stop, restart,
apply a template, ban an IP, unban an IP, show the ban list, and show
service detection — every operation the panel offers is wired to a
subcommand below.
v0.0.31 turned the firewall panel into a full manager (apply / stop /
restart / ban / unban / clear-bans / detect / check).
v0.0.37 UNIFIED BACKEND + EXPANDED CVE HARDENING.
The sysdeck-fw backend takes influence from two open-source
firewall distributions — Smoothwall Express (RED/ORANGE/GREEN/BLUE
color-zone model) and IPFire (source-verified outbound + AirWall
isolation + flow offload). We do not ship a template called
"smoothwall" or "ipfire" — those are other projects' names.
The unified sysdeck-fw template preserves the feature sets we
took influence from under our own identifier.
v0.0.37 also expands the CVE research to cover the COMMERCIAL web
admin UI panels the user meant by "webmin" (cPanel, Plesk,
DirectAdmin, CloudPanel, aaPanel, Froxlor, InterWorx, BrainyCP,
CyberPanel, HestiaCP, VestaCP, FastPanel, CWP). The full CVE table
is in docs/SECURITY-HARDENING.md. New validators added in v0.0.37:
- _validate_domain RFC 1035 strict domain regex (CVE-2025-66431 Plesk)
- _validate_email parseaddr + charset regex + metachar reject
(CVE-2026-26279 Froxlor)
- _validate_cron 5-field cron syntax (CVE-2023-53945 BrainyCP)
- _validate_mysql_id MySQL identifier (CVE-2026-58048 cPanel)
- _sanitize_for_file strips \r\n\0 (CVE-2026-41940 cPanel)
- _decode_then_validate decode -> canonicalize -> validate
(CVE-2026-29205 cPanel cpdavd)
- safe_tar_create tar argument-injection defense using --null -T -
(CVE-2025-48702 aaPanel, IWX-CVE-2022-8384 InterWorx)
Backends shipped in v0.0.37:
custom default; uses the vps-webserver.sh / no-services.sh
templates (basic nftables rulesets that work on any
Arch/Debian host). Backwards-compatible with v0.0.35.
cilium Cilium eBPF datapath. Replaces nftables as the
datapath — packets are filtered in BPF programs at
XDP and tc ingress/egress before they reach the
kernel networking stack. Identity-based policy
(not IP-based). L7 policy via Envoy. Requires
cilium + cilium-agent.
sysdeck-fw SysDeck FW — unified nftables zone firewall. Takes
influence from Smoothwall Express (zone matrix) and
IPFire (source-verified outbound + AirWall + flow
offload) under our own identifier.
RED/ORANGE/GREEN/BLUE zone matrix + source-verified
outbound + AirWall + flow offload + DMZ forwards.
Per user directive v0.0.36: "iptables is old now" — the suite ships
only nftables-native and eBPF-native backends. UFW (nftables frontend,
no eBPF) is skipped. fwbuilder (GUI rule generator, too complex for
the average user) is skipped. Legacy iptables-only firewalls without
eBPF integration points are skipped.
Subcommands added in v0.0.36:
backends list available firewall backends with
detected availability (cilium binary
present? kernel BPF features? etc.)
backend-info <name> show one backend's details + install hint
active-backend return the currently selected backend
switch-backend <name> switch the active backend (writes
/var/lib/sysdeck/firewall/backend and
stops the previous backend cleanly)
install-backend <name> install the backend's binary deps via
the packages module (pacman / apt / dnf)
cilium-status cilium status --brief (JSON)
cilium-endpoints cilium endpoint list (JSON)
cilium-policy cilium policy get (JSON)
cilium-policy-apply <file> cilium policy apply <file>
cilium-policy-validate <file> cilium policy validate <file>
security-hardening return the CVE-derived hardening checklist
applied to this bridge (for display in
the panel's Security Card)
Subcommands:
ruleset / chains / templates / template-info / detect / apply /
stop / restart / status / ban / unban / banned / clear-bans / check
Cockpit way (per user directive): mutating operations run via the
bridge's subprocess call to nft / cilium / systemctl / bash — and the
JS panel passes { superuser: 'try' } to cockpit.spawn so the cockpit
bridge prompts the operator for auth via polkit. No `sudo` shell-out
from JS; the polkit action org.sysdeck.firewall.modify (shipped since
v0.0.17) authorizes /usr/bin/nft, /usr/sbin/nft, /usr/bin/cilium,
/usr/sbin/cilium, /usr/bin/cilium-agent, and /usr/bin/helm.
SECURITY HARDENING (v0.0.36) — derived from real CVE disclosures for
Webmin, Cockpit, Ajenti, ISPConfig, and Virtualmin. See
`docs/SECURITY-HARDENING.md` for the full CVE table. Key changes:
- Strict allowlist regex per input type (template names, IP addresses,
interface names, backend names, filenames). CVE-2024-2947 lesson.
- "--" separator before any user-supplied positional in argv.
CVE-2026-4631 lesson.
- Environment scrubbing on every privileged subprocess (LD_PRELOAD,
LD_LIBRARY_PATH, PYTHONPATH, BASH_ENV, ENV, PERL5OPT all dropped).
CVE-2024-6126 lesson.
- Path resolution with os.path.realpath + startswith(base_dir) check.
CVE-2022-30708 lesson.
- No eval / pickle / yaml.unsafe_load. CVE-2019-15642 lesson.
- Error responses truncated to 4 KiB and stripped of non-printable
bytes. CVE-2022-36446 lesson.
Templates are executable shell scripts in
/usr/share/sysdeck/firewall/templates/<name>.sh. They implement the
following subcommands (the bridge invokes them as
`bash <path> <subcommand> [args]`):
start build ruleset from detected services, validate, load
stop delete the firewall table
restart stop + start
detect print service detection summary (no rule changes)
status print firewall running state + ban lists
check validate the ruleset
Templates must be POSIX-compliant bash and work on both Arch and
Debian. The templates shipped in this release are:
vps-webserver.sh (public VPS web tier)
no-services.sh (SSH-only hardened host)
cilium.sh (Cilium eBPF backend)
sysdeck-fw.sh (unified nftables zone firewall)
remote-admin.sh (v0.0.44, SSH + Cockpit public-server variant)
public-webserver.sh (v0.0.44, Caddy + Varnish + MariaDB variant)
ai-llm.sh (v0.0.44, Ollama + OpenWebUI + Hermes + Odysseus variant)
Usage:
python3 /usr/lib/sysdeck/bridge/firewall.py ruleset
python3 /usr/lib/sysdeck/bridge/firewall.py templates
python3 /usr/lib/sysdeck/bridge/firewall.py apply vps-webserver
python3 /usr/lib/sysdeck/bridge/firewall.py ban 1.2.3.4
python3 /usr/lib/sysdeck/bridge/firewall.py backends
python3 /usr/lib/sysdeck/bridge/firewall.py switch-backend cilium
python3 /usr/lib/sysdeck/bridge/firewall.py cilium-status
"""
import ipaddress
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
# ── Constants ────────────────────────────────────────────────────────
TEMPLATES_DIR = Path("/usr/share/sysdeck/firewall/templates")
POLICIES_DIR = Path("/usr/share/sysdeck/firewall/policies")
STATE_DIR = Path("/var/lib/sysdeck/firewall")
ACTIVE_FILE = STATE_DIR / "active"
BACKEND_FILE = STATE_DIR / "backend"
TABLE_NAME = "firewall" # name of the inet table the templates manage
BAN_SETS = ("ssh_abuse", "port_scanners", "connlimit_abuse")
# v0.0.36: strict allowlist regexes per input type.
# Derived from CVE-2024-2947 (Cockpit sosreport command injection via
# crafted filename) and CVE-2026-4631 (Cockpit SSH argv injection).
# Reject on first mismatch — do NOT attempt to "sanitize" by stripping
# bad chars (CVE-2020-35606 showed that approach is bypassable).
TEMPLATE_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
BACKEND_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
INTERFACE_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]{1,15}$") # Linux IFNAMSIZ
FILENAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
# v0.0.36: scrubbed environment for privileged subprocesses.
# Drops LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, BASH_ENV, ENV, PERL5OPT
# (the CVE-2024-6126 lesson — env-var injection via pam_env user_readenv).
SCRUBBED_ENV = {
"PATH": "/usr/sbin:/usr/bin:/sbin:/bin",
"LANG": "C",
"LC_ALL": "C",
}
# v0.0.36: firewall backend registry. Each entry is the static metadata
# for the backend. Availability is probed at runtime (in cmd_backends)
# so the panel can show "installed" vs "installable".
# Per user directive v0.0.36: "iptables is old now" — only nftables-native
# and eBPF-native backends ship. UFW, fwbuilder, and legacy iptables-only
# firewalls are explicitly excluded (see EXCLUDED_BACKENDS below).
FIREWALL_BACKENDS: list[dict[str, Any]] = [
{
"id": "custom",
"name": "Custom (basic nftables templates)",
"description": (
"Default. Uses the vps-webserver.sh and no-services.sh "
"templates shipped with sysdeck. Modern nftables syntax "
"with sets, verdict maps, synproxy, and eBPF integration "
"points. Works on any Arch/Debian host."
),
"technology": "nftables",
"ebpf": False,
"template": None, # uses whatever the operator selects
"install_hint": None,
"install_packages": [],
},
{
"id": "cilium",
"name": "Cilium (eBPF datapath)",
"description": (
"Cilium replaces nftables as the datapath. Packets are "
"filtered in BPF programs at XDP and tc ingress/egress "
"before they reach the kernel networking stack. Identity-"
"based policy (not IP-based). L7 policy via Envoy. "
"Requires kernel 5.10+ and the cilium + cilium-agent binaries."
),
"technology": "ebpf",
"ebpf": True,
"template": "cilium",
"install_hint": (
"Install on Arch: sudo pacman -S cilium-cli\n"
"Install on Debian: sudo apt install cilium-cli\n"
"Install via Helm: helm repo add cilium https://helm.cilium.io/\n"
" helm install cilium cilium/cilium -n kube-system"
),
"install_packages": ["cilium-cli"],
},
{
"id": "sysdeck-fw",
"name": "SysDeck FW (unified nftables zones)",
"description": (
"Unified nftables zone firewall. Takes influence from "
"Smoothwall Express (RED/ORANGE/GREEN/BLUE color-zone "
"model) and IPFire (source-verified outbound + AirWall "
"isolation for BLUE/WiFi + flow offload) under our own "
"identifier. Optional flow offload for hardware "
"acceleration. DMZ port-forwarding. Modern nftables syntax "
"with sets, verdict maps, synproxy, and eBPF integration "
"points."
),
"technology": "nftables",
"ebpf": False,
"template": "sysdeck-fw",
"install_hint": None,
"install_packages": [],
},
]
# Per user directive v0.0.36: backends explicitly excluded from the
# dropdown, with the reason. The panel renders this as a muted info
# block beneath the backend selector so the operator understands why
# these are missing.
EXCLUDED_BACKENDS: list[dict[str, str]] = [
{
"id": "ufw",
"reason": (
"UFW is a frontend for nftables/iptables — it does not "
"use eBPF and adds no value over the custom backend. "
"Skipped per user directive v0.0.36."
),
},
{
"id": "fwbuilder",
"reason": (
"fwbuilder is a GUI rule generator — too complex for the "
"average user. Skipped per user directive v0.0.36."
),
},
{
"id": "iptables-legacy",
"reason": (
"iptables-legacy is the pre-nftables firewall. iptables "
"is old now — the eBPF era has moved past it. Skipped per "
"user directive v0.0.36."
),
},
{
"id": "iptables-nft",
"reason": (
"iptables-nft is a compatibility wrapper around nftables. "
"The custom backend uses nftables natively — the wrapper "
"adds no value. Skipped per user directive v0.0.36."
),
},
{
"id": "shorewall",
"reason": (
"Shorewall is iptables-based and has no eBPF integration "
"points. The sysdeck-fw backend covers the zone-firewall "
"use case with modern nftables syntax. "
"Skipped per user directive v0.0.36."
),
},
{
"id": "smoothwall",
"reason": (
"Smoothwall Express is a separate Linux distribution with "
"its own trademark. We took influence from its "
"RED/ORANGE/GREEN/BLUE zone model for the sysdeck-fw "
"backend — we do not ship a template called \"smoothwall\" "
"because we cannot call our rewrite by another project's "
"name."
),
},
{
"id": "ipfire",
"reason": (
"IPFire is a separate Linux distribution with its own "
"trademark. We took influence from its source-verified "
"outbound + AirWall isolation + flow offload for the "
"sysdeck-fw backend — we do not ship a template called "
"\"ipfire\" because we cannot call our rewrite by another "
"project's name."
),
},
]
# nft line patterns. Compiled once at import.
CHAIN_RE = re.compile(r"^\s*chain\s+(?P<name>\S+)\s*\{")
RULE_RE = re.compile(r"^\s*(?P<spec>.+?)\s+#\s*handle\s+(?P<handle>\d+)")
# ── nft invocation ──────────────────────────────────────────────────
#
# run_nft() captures stdout and stderr. check=False everywhere — the
# bridge is invoked by cockpit.spawn which has its own error handling,
# and we want to surface nft's stderr in the JSON response rather than
# raise an exception the JS panel would have to display as an alert.
def _nft(args: list[str], check: bool = False) -> tuple[int, str, str]:
"""Run `nft <args>` and return (rc, stdout, stderr). Never raises.
v0.0.36 hardening:
- env scrubbed (SCRUBBED_ENV) — defeats LD_PRELOAD / PYTHONPATH / etc.
(CVE-2024-6126 lesson).
- subprocess.run with shell=False and array argv — no shell interpolation
(CVE-2019-15107 / CVE-2024-2947 lesson).
"""
nft_bin = shutil.which("nft") or "/usr/sbin/nft"
try:
r = subprocess.run(
[nft_bin, *args],
capture_output=True, text=True, check=False, timeout=30,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout), _sanitize_output(r.stderr)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def _nft_ok(args: list[str]) -> bool:
"""Return True if `nft <args>` succeeds."""
rc, _, _ = _nft(args)
return rc == 0
# ── v0.0.36: input validation helpers ──────────────────────────────
#
# Strict allowlist validation per input type. Each function returns
# True if the input matches the allowlist regex, False otherwise.
# The bridge calls these BEFORE the input enters any argv element.
#
# CVE-2024-2947 lesson: never trust a filename. Validate first, then
# pass to subprocess as a separate argv element (never string-interpolate).
# CVE-2026-4631 lesson: even with shell=False, an attacker can inject
# option flags if user input is in the argv. Insert "--" before any
# user-supplied positional.
# CVE-2020-35606 lesson: do NOT attempt to "sanitize" by stripping bad
# chars — reject on first mismatch. The original Webmin fix stripped
# newlines but %0A / %0C still bypassed it.
def _validate_template_name(name: str) -> bool:
"""Return True if name is a valid template identifier."""
if not name or len(name) > 64:
return False
return bool(TEMPLATE_NAME_RE.match(name))
def _validate_backend_name(name: str) -> bool:
"""Return True if name is a valid backend identifier."""
if not name or len(name) > 32:
return False
return bool(BACKEND_NAME_RE.match(name))
def _validate_interface(name: str) -> bool:
"""Return True if name is a valid Linux interface name (IFNAMSIZ)."""
if not name or len(name) > 15:
return False
return bool(INTERFACE_NAME_RE.match(name))
def _validate_filename(name: str) -> bool:
"""Return True if name is a safe filename (no path separators).
Used for any user-supplied filename that will enter argv. Rejects
`..`, `/`, NUL, shell metachars, and anything outside printable
ASCII. CVE-2024-2947 lesson.
"""
if not name or len(name) > 64:
return False
return bool(FILENAME_RE.match(name))
def _validate_ip(ip: str) -> bool:
"""Return True if ip is a valid IPv4 or IPv6 address.
Uses ipaddress.ip_address for strict validation — rejects hostnames,
truncated octets, leading zeros, etc. CVE-2024-2947 lesson: even
IPs should be strictly validated before entering argv.
"""
if not ip or len(ip) > 45: # IPv6 max is 45 chars
return False
try:
ipaddress.ip_address(ip)
return True
except ValueError:
return False
def _sanitize_output(text: str, max_len: int = 4096) -> str:
"""Truncate and strip non-printable bytes from command output.
CVE-2022-36446 lesson: command output that includes attacker-controlled
bytes (e.g. an nft error message quoting the offending rule) must not
be passed verbatim to the JS panel. We truncate to 4 KiB and replace
non-printable bytes with a space.
"""
if not text:
return ""
if len(text) > max_len:
text = text[:max_len] + " ... (truncated)"
# Replace any byte outside printable ASCII + tab/newline/cr with space.
return "".join(c if (32 <= ord(c) < 127 or c in "\t\n\r") else " " for c in text)
def _resolve_path_under_base(path_str: str, base_dir: Path) -> Path | None:
"""Resolve path_str and verify it lives under base_dir.
Uses os.path.realpath to defeat symlink chains, then verifies the
real path starts with base_dir. CVE-2022-30708 lesson.
Returns the resolved Path on success, None on rejection.
"""
if not path_str or ".." in Path(path_str).parts:
return None
try:
real = Path(os.path.realpath(path_str))
except (OSError, ValueError):
return None
try:
real.relative_to(base_dir)
except ValueError:
return None
return real
# ── v0.0.37 validators (from commercial-web-panel CVE research) ────
#
# These validators cover the new threat models exposed by cPanel, Plesk,
# CyberPanel, aaPanel, CloudPanel, HestiaCP, VestaCP, Froxlor, InterWorx,
# BrainyCP, DirectAdmin, and CWP. See docs/SECURITY-HARDENING.md §2.6+
# for the full checklist.
# RFC 1035 domain name regex. Rejects domains containing shell
# metacharacters, path separators, or any byte outside the LDH
# (letters/digits/hyphens) set + dots.
DOMAIN_RE = re.compile(
r"^(?=.{1,253}$)"
r"([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)"
r"(\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
)
# Email regex (after parseaddr). Permits letters, digits, and _%+-. in
# the local part; letters, digits, and -. in the domain.
EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$")
# 5-field cron schedule regex (minute hour day month weekday).
# Each field allows: digits, *, /, -, comma. Rejects everything else.
# Per CVE-2023-53945 (BrainyCP) — the cron *command* must NEVER be
# user-supplied, but the schedule must also be validated.
CRON_FIELD_RE = re.compile(r"^[0-9*/,-]+$")
CRON_SCHEDULE_RE = re.compile(r"^[0-9*/,-]+ +[0-9*/,-]+ +[0-9*/,-]+ +[0-9*/,-]+ +[0-9*/,-]+$")
# MySQL identifier regex. Per CVE-2026-58048 (cPanel) — identifiers
# must be backtick-quoted AND reject embedded backticks. The regex
# permits letters, digits, underscore, and $ (MySQL allows $); max 64
# chars (MySQL hard limit); must not start with a digit.
MYSQL_ID_RE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]{0,63}$")
# MySQL reserved words that must never be used as identifiers.
MYSQL_RESERVED = frozenset({
"mysql", "information_schema", "performance_schema", "sys", "root",
"admin", "test", "tmp", "temp", "database", "table", "column",
"index", "key", "primary", "foreign", "references", "constraint",
"default", "null", "not", "true", "false", "select", "insert",
"update", "delete", "create", "drop", "alter", "rename", "grant",
"revoke", "user", "password", "host", "db",
})
def _validate_domain(domain: str) -> bool:
"""Return True if domain is a valid RFC 1035 domain name.
CVE-2025-66431 (Plesk domain-creation RCE-as-root) lesson: domain
names flow into root-run scripts (log symlink rotation, vhost
config, nginx/apache conf). Validate strictly BEFORE any root-run
helper sees them.
Rejects:
- Shell metacharacters (; | & $ ` ( ) < > \\ ' ")
- Path separators (/ \\)
- Whitespace
- .. or leading/trailing hyphen
- Domains > 253 chars or labels > 63 chars
- IDN (must be punycode-encoded by the caller first)
- Wildcard domains (operator must opt-in separately)
"""
if not domain or len(domain) > 253:
return False
if any(c in domain for c in "/\\;|&$`()<>\"' \t\n\r\0"):
return False
if ".." in domain:
return False
return bool(DOMAIN_RE.match(domain))
def _validate_email(addr_str: str) -> bool:
"""Return True if addr_str is a valid email address.
CVE-2026-26279 (Froxlor) lesson: Froxlor's email-input validation
had a logic bug that disabled format checking for fields declared
as email type, allowing shell metacharacters through. We use
parseaddr FIRST (catches most malformed addresses), then a strict
charset regex, then SEPARATELY reject shell metacharacters — defense
in depth on top of the regex.
Rejects:
- Anything parseaddr rejects
- Shell metacharacters even if the regex would accept them
- Length > 254 chars (RFC 5321)
"""
if not addr_str or len(addr_str) > 254:
return False
# parseaddr returns (realname, email_address); we want the address.
# NOTE: the parameter is named addr_str (not email) to avoid
# shadowing the email.utils import.
from email.utils import parseaddr
_, addr = parseaddr(addr_str)
if not addr or "@" not in addr:
return False
if not EMAIL_RE.match(addr):
return False
# Defense in depth: reject shell metacharacters even if regex passes.
if any(c in addr for c in ";|&$`()<>!{}\n\r\0"):
return False
return True
def _validate_cron_schedule(schedule: str) -> bool:
"""Return True if schedule is a valid 5-field cron schedule.
CVE-2023-53945 (BrainyCP) lesson: BrainyCP let users inject
arbitrary commands through the crontab interface. The cron
*schedule* must be validated as 5-field syntax only; the cron
*command* must NEVER be user-supplied.
This validator checks the SCHEDULE only. The command is a separate
concern — the bridge only accepts a command from a pre-defined
allowlist, never from operator input.
"""
if not schedule or len(schedule) > 200:
return False
return bool(CRON_SCHEDULE_RE.match(schedule))
def _validate_mysql_identifier(identifier: str) -> bool:
"""Return True if identifier is a valid MySQL identifier.
CVE-2026-58048 (cPanel) lesson: cPanel's DB rename dropped SQL
mode restrictions, allowing the user to run SQL in root context.
Identifiers must be strictly validated, backtick-quoted, AND
reject MySQL reserved words.
"""
if not identifier or len(identifier) > 64:
return False
if not MYSQL_ID_RE.match(identifier):
return False
if identifier.lower() in MYSQL_RESERVED:
return False
# Reject embedded backticks (defeats backtick-quote escape attacks).
if "`" in identifier:
return False
return True
def _sanitize_for_file(value: str) -> str:
"""Strip \\r, \\n, \\0 from a value before writing to a line-oriented file.
CVE-2026-41940 (cPanel session-file CRLF injection) lesson: any
value written to a file that is later parsed line-by-line (session
files, polkit action files, sudoers fragments, cron files,
/etc/hosts, DNS zone files, nginx/apache conf) must have CR/LF/NUL
STRIPPED, not just rejected. An attacker who can inject
"\\r\\nuser=root\\r\\n" into a session file gains root.
"""
if not value:
return ""
return value.translate({0x0d: None, 0x0a: None, 0x00: None})
def _decode_then_validate(encoded: str, validator_fn, *, allow_pct: bool = False) -> bool:
"""URL-decode + canonicalize + validate a value.
CVE-2026-29205 (cPanel cpdavd) lesson: cPanel's regex validated
the ENCODED URI form (where %2F satisfies [^/]+), then decoded it
into a real / — enabling path traversal. The fix is to decode
FIRST, then canonicalize, then validate.
This helper:
1. URL-decodes the input (urllib.parse.unquote).
2. If the decoded form differs from the encoded form in a
security-relevant way (contains %2e, %2f, %5c, %00), rejects.
3. Calls validator_fn on the decoded form.
The `allow_pct` flag is for the rare case where a literal % is
expected in the value (e.g. a SQL LIKE pattern); it disables the
security-relevant-difference check.
"""
if not encoded:
return False
import urllib.parse
decoded = urllib.parse.unquote(encoded)
if not allow_pct:
# Reject if the encoded form contained %-encoded path traversal
# or NUL bytes — defense in depth on top of the decoded-form
# validator.
lower = encoded.lower()
for seq in ("%2e", "%2f", "%5c", "%00", "%0a", "%0d"):
if seq in lower:
return False
return validator_fn(decoded)
def safe_tar_create(archive_path: Path, files: list[Path], cwd: Path) -> tuple[int, str, str]:
"""Create a tar.gz archive safely, defeating argument injection.
CVE-2025-48702 (aaPanel) + IWX-CVE-2022-8384 (InterWorx) lesson:
subprocess.run with shell=False + a "--" separator is NECESSARY
but NOT SUFFICIENT for tar/zip/find/rsync. These tools interpret
arguments after "--" differently, and a filename like
"--checkpoint-action=exec=bash shell.sh" can still execute code.
The defense is to keep filenames OUT of argv entirely by passing
them via stdin using tar's --null -T - mode (read NUL-delimited
filenames from stdin).
Returns (rc, stdout, stderr). Never raises.
"""
if not archive_path or not files or not cwd:
return 1, "", "invalid arguments"
# Validate archive_path and cwd with the v0.0.36 path rules.
if not _validate_filename(archive_path.name):
return 1, "", f"invalid archive name: {archive_path.name!r}"
# Validate each file: reject names starting with - or /, containing
# newlines, or outside the cwd.
safe_files: list[Path] = []
for f in files:
name = f.name
if name.startswith("-") or name.startswith("/"):
return 1, "", f"unsafe filename (starts with - or /): {name!r}"
if any(c in name for c in "\n\r\0"):
return 1, "", f"unsafe filename (contains control chars): {name!r}"
# Resolve and verify under cwd.
try:
real = Path(os.path.realpath(f))
real.relative_to(Path(os.path.realpath(cwd)))
except (ValueError, OSError):
return 1, "", f"file escapes cwd: {f!r}"
safe_files.append(real)
# Build the NUL-delimited manifest.
manifest = b"\0".join(str(f.relative_to(Path(os.path.realpath(cwd)))).encode() for f in safe_files) + b"\0"
tar_bin = shutil.which("tar") or "/usr/bin/tar"
try:
r = subprocess.run(
[tar_bin, "--null", "-czf", str(archive_path), "-T", "-"],
input=manifest, capture_output=True, check=False, timeout=300,
env=SCRUBBED_ENV, cwd=str(cwd),
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
# ── Ruleset parser ────────────────────────────────────────────────
def parse_ruleset(output: str) -> list[dict[str, Any]]:
"""Parse `nft list ruleset` output into structured rules."""
rules: list[dict[str, Any]] = []
current_chain: str | None = None
for line in output.splitlines():
chain_match = CHAIN_RE.match(line)
if chain_match:
current_chain = chain_match.group("name")
continue
if line.strip() == "}":
current_chain = None
continue
rule_match = RULE_RE.match(line)
if rule_match and current_chain:
rules.append({
"chain": current_chain,
"spec": rule_match.group("spec").strip(),
"handle": int(rule_match.group("handle")),
})
return rules
def list_chains(output: str) -> list[str]:
"""Extract chain names from the ruleset output."""
return [
m.group("name")
for line in output.splitlines()
if (m := CHAIN_RE.match(line))
]
# ── Template enumeration ───────────────────────────────────────────
#
# A template is a *.sh file in TEMPLATES_DIR. We discover them by glob
# and extract metadata from a comment block at the top of the file:
#
# # Name: vps-webserver
# # Description: Service-aware firewall for VPS web servers
# # Distro: arch,debian
# # Services: ssh,caddy,varnish,forgejo
#
# If the comment block is absent, we fall back to filename-stem and
# a generic description. This makes it trivial for operators to drop
# a new template into TEMPLATES_DIR and have it appear in the panel.
_TEMPLATE_FIELD_RE = re.compile(
r"^\s*#\s*(?P<key>Name|Description|Distro|Services)\s*:\s*(?P<val>.+)$",
re.IGNORECASE,
)
def _parse_template_metadata(path: Path) -> dict[str, Any]:
"""Extract metadata from the comment header of a template file.
Looks for `# Key: value` lines in the first 60 lines of the file.
Returns a dict with name, description, distros[], services[].
"""
meta: dict[str, Any] = {
"name": path.stem,
"path": str(path),
"description": "",
"distros": [],
"services": [],
}
try:
with path.open(encoding="utf-8", errors="replace") as fh:
for i, line in enumerate(fh):
if i >= 60:
break
m = _TEMPLATE_FIELD_RE.match(line)
if not m:
continue
key = m.group("key").lower()
val = m.group("val").strip()
if key == "name":
meta["name"] = val
elif key == "description":
meta["description"] = val
elif key == "distro":
meta["distros"] = [v.strip() for v in val.split(",") if v.strip()]
elif key == "services":
meta["services"] = [v.strip() for v in val.split(",") if v.strip()]
except (OSError, PermissionError):
pass
# Auto-derive services from filename if not declared in header.
if not meta["services"]:
if "vps" in path.stem or "webserver" in path.stem:
meta["services"] = ["ssh", "caddy", "varnish", "forgejo"]
elif "no-services" in path.stem:
meta["services"] = ["ssh"]
if not meta["description"]:
meta["description"] = f"Firewall template: {meta['name']}"
return meta
def list_templates() -> list[dict[str, Any]]:
"""List all *.sh templates in TEMPLATES_DIR.
Returns a list of {name, path, description, distros, services}
dicts sorted by name. Returns [] if TEMPLATES_DIR doesn't exist
(e.g. the package wasn't installed correctly).
"""
if not TEMPLATES_DIR.is_dir():
return []
templates: list[dict[str, Any]] = []
for p in sorted(TEMPLATES_DIR.glob("*.sh")):
if not p.is_file():
continue
if not os.access(p, os.X_OK | os.R_OK):
continue
meta = _parse_template_metadata(p)
templates.append(meta)
return templates
def template_info(name: str) -> dict[str, Any] | None:
"""Return metadata for a single template by name (stem or filename).
Accepts 'vps-webserver' or 'vps-webserver.sh'. Returns None if
not found.
v0.0.36 hardening: validates name against TEMPLATE_NAME_RE before
resolving the path. CVE-2024-2947 lesson — never trust a filename.
"""
if not name:
return None
stem = name.removesuffix(".sh")
if not _validate_template_name(stem):
return None
candidate = TEMPLATES_DIR / f"{stem}.sh"
# Resolve and verify it's under TEMPLATES_DIR (symlink defense).
resolved = _resolve_path_under_base(str(candidate), TEMPLATES_DIR)
if resolved is None or not resolved.is_file():
return None
return _parse_template_metadata(resolved)
# ── Active template tracking ───────────────────────────────────────
#
# When the operator applies a template, we write its name to
# /var/lib/sysdeck/firewall/active. detect / status / restart use
# this to know which template to invoke. If the file is absent (e.g.
# the operator applied a ruleset by hand outside the panel), detect
# and status fall back to the first installed template.
def _read_active_template() -> str | None:
"""Return the name of the currently active template, or None."""
try:
return ACTIVE_FILE.read_text(encoding="utf-8").strip() or None
except (FileNotFoundError, PermissionError, OSError):
return None
def _write_active_template(name: str | None) -> None:
"""Record the active template name, or clear it if name is None."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
if name is None:
ACTIVE_FILE.unlink(missing_ok=True)
else:
ACTIVE_FILE.write_text(name, encoding="utf-8")
except (PermissionError, OSError):
# Not fatal — the bridge runs as the cockpit user, and STATE_DIR
# may need root. The cockpit superuser channel handles this for
# apply/stop; for read-only detect/status we just don't track.
pass
def _resolve_template_path(name: str | None) -> Path | None:
"""Resolve a template name to a file path.
If name is given, look it up. If name is None, use the active
template; if no active template, use the first installed template.
v0.0.36 hardening: validates name against TEMPLATE_NAME_RE and
resolves under TEMPLATES_DIR (symlink defense). CVE-2024-2947 +
CVE-2022-30708 lessons.
"""
if name:
stem = name.removesuffix(".sh")
if not _validate_template_name(stem):
return None
candidate = TEMPLATES_DIR / f"{stem}.sh"
resolved = _resolve_path_under_base(str(candidate), TEMPLATES_DIR)
return resolved if (resolved and resolved.is_file()) else None
active = _read_active_template()
if active:
stem = active.removesuffix(".sh")
if _validate_template_name(stem):
candidate = TEMPLATES_DIR / f"{stem}.sh"
resolved = _resolve_path_under_base(str(candidate), TEMPLATES_DIR)
if resolved and resolved.is_file():
return resolved
templates = list_templates()
if templates:
return Path(templates[0]["path"])
return None
# ── Template invocation ────────────────────────────────────────────
#
# Templates are bash scripts. We invoke them with `bash <path> <args>`
# rather than executing them directly — this avoids the executable-bit
# requirement at runtime (though we still install them 0755 so they
# can be run directly for debugging).
def _run_template(name: str | None, action: str, extra_args: list[str] | None = None) -> tuple[int, str, str]:
"""Run `<template> <action> [extra_args...]` and return (rc, stdout, stderr).
Returns (127, '', 'template not found') if the template cannot be
resolved. The caller is responsible for surfacing the error in the
JSON response.
v0.0.36 hardening:
- validates the template name before resolving the path
(CVE-2024-2947 lesson).
- validates the action against a fixed allowlist (start/stop/
restart/detect/status/check) so an attacker cannot inject an
arbitrary subcommand (CVE-2026-4802 lesson — even with shell=False,
an attacker-controlled argv element can be an option flag).
- inserts a literal "--" before any extra_args so option-flag
injection is defeated (CVE-2026-4631 lesson).
- env scrubbed (SCRUBBED_ENV) — CVE-2024-6126 lesson.
- output sanitized (truncated + non-printable stripped) — CVE-2022-36446 lesson.
"""
path = _resolve_template_path(name)
if path is None:
return 127, "", "no firewall template available"
# Action allowlist. Templates may implement more subcommands but
# the bridge only invokes these six.
allowed_actions = {"start", "stop", "restart", "detect", "status", "check"}
if action not in allowed_actions:
return 127, "", f"invalid action: {action}"
bash = shutil.which("bash") or "/bin/bash"
# Insert "--" before extra_args so option-flag injection is defeated.
# The templates' case statements ignore "--" (it's a common shell idiom).
cmd: list[str] = [bash, str(path), action]
if extra_args:
# Validate each extra_arg is a safe filename (no shell metachars,
# no path separators, length <= 64). Reject on first mismatch.
for arg in extra_args:
if not _validate_filename(arg):
return 127, "", f"invalid argument: {arg!r}"
cmd.append("--")
cmd.extend(extra_args)
try:
r = subprocess.run(
cmd, capture_output=True, text=True, check=False, timeout=120,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
# ── Subcommand: templates ─────────────────────────────────────────
def cmd_templates(_args: list[str]) -> list[dict[str, Any]]:
"""List installed firewall templates."""
return list_templates()
# ── Subcommand: template-info ──────────────────────────────────────
def cmd_template_info(args: list[str]) -> dict[str, Any]:
"""Show metadata for one template."""
if not args:
return {"error": "template name required"}
info = template_info(args[0])
if info is None:
return {"error": f"template '{args[0]}' not found"}
return info
# ── Subcommand: detect ─────────────────────────────────────────────
def cmd_detect(_args: list[str]) -> dict[str, Any]:
"""Run the active template's `detect` action and return its output.
The template prints a service detection summary to stdout. We
return it as a string. If the template's detect action also prints
structured JSON, we parse it; otherwise the JS panel just renders
the text in a <pre>.
"""
active = _read_active_template() or ""
rc, out, err = _run_template(active, "detect")
return {
"active_template": active or None,
"rc": rc,
"output": out.strip(),
"stderr": err.strip(),
}
# ── Subcommand: apply ──────────────────────────────────────────────
def cmd_apply(args: list[str]) -> dict[str, Any]:
"""Apply a firewall template (run its `start` action).
Usage: apply <template-name> [policy]
The optional policy argument is passed to the template as
`$2` — the v0.0.31 templates ignore it, but future templates
could use it to select between 'drop', 'reject', 'accept'
default policies. The bridge records the applied template name
as the active template so subsequent detect/status/restart calls
know which template to invoke.
v0.0.36 hardening: validates the template name against
TEMPLATE_NAME_RE before resolving. Rejects names containing `..`,
`/`, shell metachars, or any byte outside printable ASCII.
CVE-2024-2947 + CVE-2019-15107 lessons.
"""
if not args:
return {"error": "template name required"}
name = args[0]
# Strict validation BEFORE resolving the path.
stem = name.removesuffix(".sh")
if not _validate_template_name(stem):
return {"error": f"invalid template name: {name!r}"}
info = template_info(name)
if info is None:
return {"error": f"template '{name}' not found"}
extra = args[1:] if len(args) > 1 else []
rc, out, err = _run_template(name, "start", extra)
success = rc == 0
if success:
_write_active_template(info["name"])
return {
"template": info["name"],
"rc": rc,
"success": success,
"output": out.strip(),
"stderr": err.strip(),
}
# ── Subcommand: stop ───────────────────────────────────────────────
def cmd_stop(_args: list[str]) -> dict[str, Any]:
"""Stop the firewall.
Tries the active template's `stop` action first (templates know
how to clean up their own table). Falls back to a direct
`nft delete table inet firewall` if no template is active or the
template's stop action fails. Also stops nftables.service if it
is running, so systemd doesn't immediately reload the ruleset.
"""
active = _read_active_template()
out = ""
err = ""
rc = 0
if active:
rc, out, err = _run_template(active, "stop")
if rc == 0:
_write_active_template(None)
return {
"stopped": True,
"method": "template",
"template": active,
"output": out.strip(),
"stderr": err.strip(),
}
# Fallback: direct nft delete.
rc2, out2, err2 = _nft(["delete", "table", "inet", TABLE_NAME])
if rc2 == 0:
_write_active_template(None)
return {
"stopped": True,
"method": "nft-direct",
"output": out2.strip(),
"stderr": err2.strip(),
}
# Last-resort: try to stop nftables.service.
systemctl = shutil.which("systemctl") or "/usr/bin/systemctl"
try:
r = subprocess.run(
[systemctl, "stop", "nftables.service"],
capture_output=True, text=True, check=False, timeout=15,
env=SCRUBBED_ENV,
)
if r.returncode == 0:
_write_active_template(None)
return {
"stopped": r.returncode == 0,
"method": "systemd",
"output": _sanitize_output(r.stdout),
"stderr": _sanitize_output(r.stderr) or err2.strip(),
}
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return {
"stopped": False,
"method": "failed",
"output": "",
"stderr": f"all stop methods failed: {exc}",
}
# ── Subcommand: restart ────────────────────────────────────────────
def cmd_restart(_args: list[str]) -> dict[str, Any]:
"""Restart the firewall — stop, then re-apply the active template."""
active = _read_active_template()
stop_result = cmd_stop([])
if not active:
return {
"restarted": False,
"reason": "no active template to re-apply",
"stop_result": stop_result,
}
apply_result = cmd_apply([active])
return {
"restarted": apply_result.get("success", False),
"template": active,
"stop_result": stop_result,
"apply_result": apply_result,
}
# ── Subcommand: status ──────────────────────────────────────────────
def _list_set(set_name: str) -> list[str]:
"""List IPs in a named nft set under inet firewall. Returns []."""
rc, out, _ = _nft(["list", "set", "inet", TABLE_NAME, set_name])
if rc != 0:
return []
# nft list set output: elements = { 1.2.3.4, 5.6.7.8 expires ... }
ips: list[str] = []
in_elements = False
for line in out.splitlines():
if "elements" in line and "{" in line:
in_elements = True
if in_elements:
for match in re.finditer(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b", line):
ips.append(match.group(1))
if in_elements and "}" in line:
in_elements = False
return list(dict.fromkeys(ips)) # dedupe preserving order
def cmd_status(_args: list[str]) -> dict[str, Any]:
"""Return firewall running state + ban lists + counters.
State is 'running' if `nft list table inet firewall` succeeds,
'stopped' otherwise. Ban lists are per-set IPs.
"""
rc, out, err = _nft(["list", "table", "inet", TABLE_NAME])
running = rc == 0
active = _read_active_template()
chains = list_chains(out) if running else []
rules = parse_ruleset(out) if running else []
bans = {s: _list_set(s) for s in BAN_SETS}
return {
"state": "running" if running else "stopped",
"active_template": active or None,
"table": TABLE_NAME,
"chain_count": len(chains),
"rule_count": len(rules),
"chains": chains,
"bans": bans,
"banned_ip_count": sum(len(v) for v in bans.values()),
"stderr": err.strip() if not running else "",
}
# ── Subcommand: ban ─────────────────────────────────────────────────
#
# _validate_ip (validation helpers above) uses ipaddress.ip_address
# for strict IPv4 + IPv6 validation. Leading zeros (e.g.
# "010.010.010.010") are rejected — some systems interpret them as
# octal, a subtle attack vector. CVE-2024-2947 lesson.
def cmd_ban(args: list[str]) -> dict[str, Any]:
"""Ban an IP — add to the ssh_abuse set."""
if not args:
return {"error": "IP address required"}
ip = args[0]
if not _validate_ip(ip):
return {"error": f"invalid IP address: {ip!r}"}
rc, _, err = _nft(["add", "element", "inet", TABLE_NAME, "ssh_abuse", "{", ip, "}"])
return {
"banned": rc == 0,
"ip": ip,
"set": "ssh_abuse",
"stderr": err.strip(),
}
# ── Subcommand: unban ───────────────────────────────────────────────
def cmd_unban(args: list[str]) -> dict[str, Any]:
"""Unban an IP — delete from all ban sets."""
if not args:
return {"error": "IP address required"}
ip = args[0]
if not _validate_ip(ip):
return {"error": f"invalid IP address: {ip!r}"}
results: dict[str, bool] = {}
for s in BAN_SETS:
rc, _, _ = _nft(["delete", "element", "inet", TABLE_NAME, s, "{", ip, "}"])
results[s] = rc == 0
return {
"unbanned": any(results.values()),
"ip": ip,
"sets": results,
}
# ── Subcommand: banned ──────────────────────────────────────────────
def cmd_banned(_args: list[str]) -> dict[str, Any]:
"""List all banned IPs across all ban sets."""
bans = {s: _list_set(s) for s in BAN_SETS}
return {
"bans": bans,
"banned_ip_count": sum(len(v) for v in bans.values()),
}
# ── Subcommand: clear-bans ──────────────────────────────────────────
def cmd_clear_bans(_args: list[str]) -> dict[str, Any]:
"""Flush all ban sets."""
results: dict[str, bool] = {}
for s in BAN_SETS:
rc, _, _ = _nft(["flush", "set", "inet", TABLE_NAME, s])
results[s] = rc == 0
return {
"cleared": all(results.values()),
"sets": results,
}
# ── Subcommand: check ───────────────────────────────────────────────
def cmd_check(_args: list[str]) -> dict[str, Any]:
"""Validate the current ruleset (nft -c list ruleset)."""
rc, out, err = _nft(["-c", "list", "ruleset"])
return {
"valid": rc == 0,
"rc": rc,
"output": out.strip(),
"stderr": err.strip(),
}
# ── Subcommand: ruleset ────────────────────────────────────────────
def cmd_ruleset(_args: list[str]) -> list[dict[str, Any]]:
"""List active nftables rules with handles."""
rc, out, _ = _nft(["--handle", "list", "ruleset"])
if rc != 0:
return []
return parse_ruleset(out)
# ── Subcommand: chains ─────────────────────────────────────────────
def cmd_chains(_args: list[str]) -> list[str]:
"""List nftables chain names."""
rc, out, _ = _nft(["list", "ruleset"])
if rc != 0:
return []
return list_chains(out)
# ── v0.0.36 Subcommands: firewall backend dropdown ────────────────
#
# The backend dropdown lets the operator choose between three firewall
# rule philosophies:
# custom — basic nftables templates (vps-webserver.sh, no-services.sh)
# cilium — Cilium eBPF datapath (replaces nftables)
# sysdeck-fw — unified nftables zone firewall (takes influence from
# Smoothwall Express + IPFire under our own identifier)
#
# The active backend is recorded in /var/lib/sysdeck/firewall/backend.
# Switching backends stops the previous backend cleanly before applying
# the new one.
def _read_active_backend() -> str:
"""Return the active backend id, defaulting to 'custom'."""
try:
return BACKEND_FILE.read_text(encoding="utf-8").strip() or "custom"
except (FileNotFoundError, PermissionError, OSError):
return "custom"
def _write_active_backend(name: str) -> None:
"""Record the active backend id."""
try:
STATE_DIR.mkdir(parents=True, exist_ok=True)
BACKEND_FILE.write_text(name, encoding="utf-8")
except (PermissionError, OSError):
pass # best-effort; cockpit superuser channel handles root writes.
def _backend_available(backend_id: str) -> dict[str, Any]:
"""Probe whether the backend's dependencies are installed.
Returns a dict {installed: bool, reason: str, install_hint: str|None}.
"""
if backend_id == "custom":
return {"installed": True, "reason": "nftables templates always available", "install_hint": None}
if backend_id == "sysdeck-fw":
have_nft = shutil.which("nft") is not None
return {
"installed": have_nft,
"reason": "OK" if have_nft else "nftables not installed",
"install_hint": None if have_nft else "pacman -S nftables / apt install nftables",
}
if backend_id == "cilium":
have_cilium = shutil.which("cilium") is not None
have_agent = Path("/usr/bin/cilium-agent").is_file() or Path("/usr/sbin/cilium-agent").is_file()
if have_cilium and have_agent:
return {"installed": True, "reason": "OK", "install_hint": None}
missing = []
if not have_cilium:
missing.append("cilium-cli")
if not have_agent:
missing.append("cilium-agent")
return {
"installed": False,
"reason": f"missing: {', '.join(missing)}",
"install_hint": (
"Install on Arch: sudo pacman -S cilium-cli\n"
"Install on Debian: sudo apt install cilium-cli\n"
"Install via Helm: helm repo add cilium https://helm.cilium.io/\n"
" helm install cilium cilium/cilium -n kube-system"
),
}
return {"installed": False, "reason": f"unknown backend: {backend_id}", "install_hint": None}
def cmd_backends(_args: list[str]) -> dict[str, Any]:
"""List available firewall backends with detected availability.
Returns {backends: [...], excluded: [...], active: str}.
Each backend entry has: id, name, description, technology, ebpf,
template, install_hint, install_packages, available: {installed, reason}.
"""
active = _read_active_backend()
backends = []
for b in FIREWALL_BACKENDS:
entry = dict(b)
entry["available"] = _backend_available(b["id"])
entry["active"] = (b["id"] == active)
backends.append(entry)
return {
"active": active,
"backends": backends,
"excluded": EXCLUDED_BACKENDS,
}
def cmd_backend_info(args: list[str]) -> dict[str, Any]:
"""Show one backend's details + availability + install hint."""
if not args:
return {"error": "backend name required"}
name = args[0]
if not _validate_backend_name(name):
return {"error": f"invalid backend name: {name!r}"}
for b in FIREWALL_BACKENDS:
if b["id"] == name:
entry = dict(b)
entry["available"] = _backend_available(name)
entry["active"] = (name == _read_active_backend())
return entry
return {"error": f"backend '{name}' not found"}
def cmd_active_backend(_args: list[str]) -> dict[str, Any]:
"""Return the currently selected backend id + metadata."""
active = _read_active_backend()
for b in FIREWALL_BACKENDS:
if b["id"] == active:
entry = dict(b)
entry["available"] = _backend_available(active)
entry["active"] = True
return entry
# Unknown active backend (stale state file) — fall back to custom.
return {
"id": "custom",
"name": "Custom (basic nftables templates)",
"description": "Default fallback — stale backend file ignored.",
"technology": "nftables",
"ebpf": False,
"active": True,
"available": _backend_available("custom"),
}
def cmd_switch_backend(args: list[str]) -> dict[str, Any]:
"""Switch the active firewall backend.
Usage: switch-backend <name>
Stops the previous backend cleanly (calls the old backend's `stop`
action), writes the new backend id to /var/lib/sysdeck/firewall/backend,
and (if the new backend is installed) applies its default template.
For the `cilium` backend, the bridge does NOT auto-apply — the
operator must click "Apply" in the panel after Cilium is installed.
This is a deliberate UX choice: Cilium install can take 30+ seconds
and the operator should see the install progress.
"""
if not args:
return {"error": "backend name required"}
name = args[0]
if not _validate_backend_name(name):
return {"error": f"invalid backend name: {name!r}"}
# Validate the backend exists.
target = next((b for b in FIREWALL_BACKENDS if b["id"] == name), None)
if target is None:
return {"error": f"backend '{name}' not found. Available: {[b['id'] for b in FIREWALL_BACKENDS]}"}
prev = _read_active_backend()
steps: list[dict[str, Any]] = []
# Stop the previous backend cleanly.
if prev != name:
if prev == "cilium":
# Run the cilium template's stop action.
rc, out, err = _run_template("cilium", "stop")
steps.append({"step": "stop-cilium", "rc": rc, "output": out.strip(), "stderr": err.strip()})
else:
# nftables backend — delete the inet firewall table.
rc, out, err = _nft(["delete", "table", "inet", TABLE_NAME])
steps.append({"step": f"stop-{prev}", "rc": rc, "output": out.strip(), "stderr": err.strip()})
# Record the new backend.
_write_active_backend(name)
steps.append({"step": "write-backend-file", "rc": 0, "output": name, "stderr": ""})
# If the new backend has a template and is available, auto-apply it.
# Exception: cilium requires explicit Apply (install can be slow).
avail = _backend_available(name)
if target.get("template") and name != "cilium" and avail["installed"]:
rc, out, err = _run_template(target["template"], "start")
steps.append({"step": f"apply-{name}", "rc": rc, "output": out.strip(), "stderr": err.strip()})
if rc == 0:
_write_active_template(target["template"])
elif name == "cilium" and not avail["installed"]:
steps.append({
"step": "cilium-install-hint",
"rc": 0,
"output": "Cilium not installed. Use the Install button in the panel.",
"stderr": "",
})
return {
"switched": True,
"previous": prev,
"current": name,
"available": avail,
"steps": steps,
}
def cmd_install_backend(args: list[str]) -> dict[str, Any]:
"""Install the backend's binary dependencies via the system package manager.
Usage: install-backend <name>
The bridge delegates to the packages module (pacman / apt / dnf) by
invoking `python3 /usr/lib/sysdeck/bridge/packages.py install <pkg>`.
For the cilium backend, this installs cilium-cli. For nftables
backends, this installs nftables.
"""
if not args:
return {"error": "backend name required"}
name = args[0]
if not _validate_backend_name(name):
return {"error": f"invalid backend name: {name!r}"}
target = next((b for b in FIREWALL_BACKENDS if b["id"] == name), None)
if target is None:
return {"error": f"backend '{name}' not found"}
pkgs = target.get("install_packages") or []
if not pkgs:
return {
"installed": True,
"backend": name,
"packages": [],
"reason": "no packages required — backend uses system nftables",
}
# Delegate to the packages bridge helper. We invoke it via subprocess
# (the cockpit superuser channel provides root). Hardening: array form
# only, env scrubbed, output sanitized.
packages_helper = "/usr/lib/sysdeck/bridge/packages.py"
if not Path(packages_helper).is_file():
return {"error": f"packages helper not found at {packages_helper}"}
python3 = shutil.which("python3") or "/usr/bin/python3"
# Validate each package name against FILENAME_RE before passing.
for p in pkgs:
if not _validate_filename(p):
return {"error": f"invalid package name: {p!r}"}
# Both sides accept the `--` argv separator: packages.py skips
# leading '--' elements, and this caller never emits one.
cmd = [python3, packages_helper, "install", *pkgs]
try:
r = subprocess.run(
cmd, capture_output=True, text=True, check=False, timeout=300,
env=SCRUBBED_ENV,
)
return {
"installed": r.returncode == 0,
"backend": name,
"packages": pkgs,
"rc": r.returncode,
"output": _sanitize_output(r.stdout or "").strip(),
"stderr": _sanitize_output(r.stderr or "").strip(),
}
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return {"error": f"packages install failed: {exc}"}
# ── v0.0.36 Subcommands: Cilium eBPF backend ──────────────────────
def _cilium(args: list[str], timeout: int = 30) -> tuple[int, str, str]:
"""Run `cilium <args>` and return (rc, stdout, stderr). Never raises.
Same hardening as _nft(): env scrubbed, output sanitized.
"""
cilium_bin = shutil.which("cilium") or "/usr/bin/cilium"
try:
r = subprocess.run(
[cilium_bin, *args],
capture_output=True, text=True, check=False, timeout=timeout,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def cmd_cilium_status(_args: list[str]) -> dict[str, Any]:
"""Return `cilium status --brief` output as a string."""
if shutil.which("cilium") is None:
return {"installed": False, "error": "cilium-cli not installed. Use install-backend cilium."}
rc, out, err = _cilium(["status", "--brief"])
return {
"installed": True,
"rc": rc,
"output": out.strip(),
"stderr": err.strip(),
}
def cmd_cilium_endpoints(_args: list[str]) -> dict[str, Any]:
"""Return `cilium endpoint list -o json` as parsed JSON."""
if shutil.which("cilium") is None:
return {"installed": False, "error": "cilium-cli not installed", "endpoints": []}
rc, out, err = _cilium(["endpoint", "list", "-o", "json"])
if rc != 0:
return {"installed": True, "rc": rc, "error": err.strip(), "endpoints": []}
try:
endpoints = json.loads(out) if out.strip() else []
except json.JSONDecodeError:
endpoints = []
return {
"installed": True,
"rc": rc,
"endpoints": endpoints,
"stderr": err.strip(),
}
def cmd_cilium_policy(_args: list[str]) -> dict[str, Any]:
"""Return `cilium policy get -o json` as parsed JSON."""
if shutil.which("cilium") is None:
return {"installed": False, "error": "cilium-cli not installed", "policies": []}
rc, out, err = _cilium(["policy", "get", "-o", "json"])
if rc != 0:
return {"installed": True, "rc": rc, "error": err.strip(), "policies": []}
try:
policies = json.loads(out) if out.strip() else []
except json.JSONDecodeError:
policies = []
return {
"installed": True,
"rc": rc,
"policies": policies,
"stderr": err.strip(),
}
def cmd_cilium_policy_apply(args: list[str]) -> dict[str, Any]:
"""Apply a Cilium policy file.
Usage: cilium-policy-apply <filename>
The filename is validated against FILENAME_RE and resolved under
/etc/sysdeck/firewall/ or /usr/share/sysdeck/firewall/policies/.
CVE-2024-2947 lesson.
"""
if shutil.which("cilium") is None:
return {"installed": False, "error": "cilium-cli not installed"}
if not args:
return {"error": "policy filename required"}
fname = args[0]
if not _validate_filename(fname):
return {"error": f"invalid filename: {fname!r}"}
# Resolve under one of the allowed base dirs.
for base in (Path("/etc/sysdeck/firewall"), POLICIES_DIR):
candidate = base / fname
resolved = _resolve_path_under_base(str(candidate), base)
if resolved and resolved.is_file():
rc, out, err = _cilium(["policy", "apply", str(resolved)], timeout=60)
return {
"installed": True,
"applied": rc == 0,
"policy": str(resolved),
"rc": rc,
"output": out.strip(),
"stderr": err.strip(),
}
return {"error": f"policy file '{fname}' not found in /etc/sysdeck/firewall/ or {POLICIES_DIR}"}
def cmd_cilium_policy_validate(args: list[str]) -> dict[str, Any]:
"""Validate a Cilium policy file (syntax check).
Usage: cilium-policy-validate <filename>
"""
if shutil.which("cilium") is None:
return {"installed": False, "error": "cilium-cli not installed"}
if not args:
return {"error": "policy filename required"}
fname = args[0]
if not _validate_filename(fname):
return {"error": f"invalid filename: {fname!r}"}
for base in (Path("/etc/sysdeck/firewall"), POLICIES_DIR):
candidate = base / fname
resolved = _resolve_path_under_base(str(candidate), base)
if resolved and resolved.is_file():
rc, out, err = _cilium(["policy", "validate", str(resolved)])
return {
"installed": True,
"valid": rc == 0,
"policy": str(resolved),
"rc": rc,
"output": out.strip(),
"stderr": err.strip(),
}
return {"error": f"policy file '{fname}' not found"}
# ── v0.0.36 Subcommand: security-hardening ────────────────────────
def cmd_security_hardening(_args: list[str]) -> dict[str, Any]:
"""Return the CVE-derived hardening checklist applied to this bridge.
Renders a summary for the panel's Security Card. The full checklist
is in docs/SECURITY-HARDENING.md.
"""
return {
"version": "0.0.37",
"doc": "docs/SECURITY-HARDENING.md",
"applied": [
{
"id": "B1.1",
"title": "Array-form subprocess only",
"cve": "CVE-2019-15107, CVE-2024-2947",
"detail": "Every subprocess.run uses shell=False and list argv. No os.system, no shell=True. Verified by grep -rn 'shell=True|os.system' guard.",
},
{
"id": "B1.2",
"title": "'--' separator before user positionals",
"cve": "CVE-2026-4631",
"detail": "Every bridge subprocess that accepts a user-supplied positional inserts a literal '--' before it, defeating option-flag injection.",
},
{
"id": "B1.3",
"title": "Strict allowlist regex per input type",
"cve": "CVE-2024-2947, CVE-2020-35606",
"detail": "Template names, IP addresses, interface names, backend names, filenames each validated with a strict regex. Reject on first mismatch — no sanitization.",
},
{
"id": "B1.4",
"title": "Environment scrubbing",
"cve": "CVE-2024-6126",
"detail": "Privileged subprocesses run with env={PATH, LANG, LC_ALL} only. LD_PRELOAD, LD_LIBRARY_PATH, PYTHONPATH, BASH_ENV, ENV, PERL5OPT all dropped.",
},
{
"id": "B1.5",
"title": "Path resolution with realpath + startswith",
"cve": "CVE-2022-30708",
"detail": "User-supplied paths resolved with os.path.realpath and verified to live under the allowed base directory. Symlinks escaping the base are rejected.",
},
{
"id": "B1.6",
"title": "No eval / pickle / yaml.unsafe_load",
"cve": "CVE-2019-15642",
"detail": "Only json.loads with strict schemas. Never eval(), never pickle.loads, never yaml.unsafe_load.",
},
{
"id": "B1.7",
"title": "Per-verb polkit check",
"cve": "CVE-2022-0824, CVE-2022-0829",
"detail": "Every mutating bridge verb runs under org.sysdeck.firewall.modify with { superuser: 'try' } from JS. Read-only verbs never require auth.",
},
{
"id": "B2.1",
"title": "Output encoding in JS",
"cve": "CVE-2022-36446",
"detail": "All bridge output rendered with escapeHtml() or textContent. No innerHTML on bridge data.",
},
{
"id": "B4.1",
"title": "Reproducible build + git status gate",
"cve": "2019 Webmin backdoor",
"detail": "make check runs git status --porcelain as a release gate. make dist pins LC_ALL=C, SOURCE_DATE_EPOCH. Signed releases documented.",
},
# v0.0.37: new hardening from commercial-web-panel CVE survey
{
"id": "B2.1",
"title": "tar/zip argument-injection defense (--null -T -)",
"cve": "CVE-2025-48702 (aaPanel), IWX-CVE-2022-8384 (InterWorx)",
"detail": "The v0.0.36 -- separator is necessary but NOT sufficient for tar/zip/find/rsync. safe_tar_create() keeps filenames OUT of argv by passing them via stdin using tar --null -T - (NUL-delimited).",
},
{
"id": "B3.1",
"title": "CRLF/NUL strip at every file-write boundary",
"cve": "CVE-2026-41940 (cPanel session-file CRLF injection)",
"detail": "_sanitize_for_file() strips \\r\\n\\0 from any value written to a line-oriented file (session, polkit action, sudoers, cron, /etc/hosts, DNS zone, nginx/apache conf). Prevents attacker from injecting 'user=root\\n' into a session file.",
},
{
"id": "B4.2",
"title": "Decode-then-validate (never validate-then-decode)",
"cve": "CVE-2026-29205 (cPanel cpdavd path traversal)",
"detail": "_decode_then_validate() URL-decodes FIRST, then canonicalizes via os.path.realpath, then validates. Rejects encoded path-traversal sequences (%2e, %2f, %5c, %00, %0a, %0d) as defense in depth.",
},
{
"id": "B5.1",
"title": "Strict domain-name validation (RFC 1035)",
"cve": "CVE-2025-66431 (Plesk domain-creation RCE-as-root)",
"detail": "_validate_domain() rejects shell metacharacters, path separators, whitespace, .., leading/trailing hyphens, IDN (must be punycode first), and enforces 253-char max / 63-char label max. Domain names flow into root-run scripts (log rotation, vhost config).",
},
{
"id": "B6.1",
"title": "Email validation with separate metachar rejection",
"cve": "CVE-2026-26279 (Froxlor email-validation logic bug)",
"detail": "_validate_email() uses email.utils.parseaddr FIRST, then a strict charset regex, then SEPARATELY rejects shell metacharacters even if the regex passes. Defense in depth on top of input validation — validation logic bugs happen.",
},
{
"id": "B7.1",
"title": "Cron schedule validation (5-field syntax only)",
"cve": "CVE-2023-53945 (BrainyCP crontab RCE)",
"detail": "_validate_cron_schedule() accepts only 5-field cron syntax (digits, *, /, -, comma). The cron *command* is NEVER user-supplied — only the schedule. The operator picks from a pre-defined command allowlist.",
},
{
"id": "B8.1",
"title": "MySQL identifier validation + reserved-word denylist",
"cve": "CVE-2026-58048 (cPanel DB rename SQL mode drop)",
"detail": "_validate_mysql_identifier() enforces ^[A-Za-z_$][A-Za-z0-9_$]{0,63}$, rejects MySQL reserved words (mysql, information_schema, root, etc.), and rejects embedded backticks. Identifiers are always backtick-quoted in generated SQL.",
},
],
"cves_reviewed": [
"CVE-2019-15107", "CVE-2019-12840", "CVE-2019-15642", "CVE-2019-25066",
"CVE-2020-35606", "CVE-2020-35850", "CVE-2022-0824", "CVE-2022-0829",
"CVE-2022-30708", "CVE-2022-36446", "CVE-2023-46818",
"CVE-2024-12828", "CVE-2024-2947", "CVE-2024-6126",
"CVE-2025-61541", "CVE-2026-4631", "CVE-2026-4802",
"CVE-2026-42210", "CVE-2026-56022",
# v0.0.37: commercial web admin panel CVEs
"CVE-2026-41940 (cPanel session CRLF)",
"CVE-2026-29205 (cPanel cpdavd path traversal)",
"CVE-2026-58048 (cPanel DB rename SQL mode)",
"CVE-2025-66429 (cPanel Team Manager path traversal)",
"CVE-2023-29489 (cPanel cpsrvd XSS)",
"CVE-2018-20898 (cPanel API token ACL)",
"CVE-2025-66431 (Plesk domain-creation RCE-as-root)",
"CVE-2026-44962 (Plesk APS XPath injection)",
"CVE-2025-54336 (Plesk weak password comparison)",
"CVE-2024-51567 (CyberPanel pre-auth RCE)",
"CVE-2024-51568 (CyberPanel filemanager RCE)",
"CVE-2024-51378 (CyberPanel auth bypass)",
"CVE-2025-48702 (aaPanel tar argument injection)",
"CVE-2026-29859 (aaPanel file upload RCE)",
"CVE-2023-35885 (CloudPanel auth bypass)",
"CVE-2024-44765 (CloudPanel broken access control)",
"CVE-2021-47871 (HestiaCP arbitrary file write)",
"CVE-2018-10686 (VestaCP XSS-to-RCE chain)",
"CVE-2018-1000884 (VestaCP password reset)",
"CVE-2026-26279 (Froxlor email-validation bug)",
"CVE-2025-29773 (Froxlor admin-to-root LPE)",
"CVE-2014-2531 (InterWorx SQL injection)",
"IWX-CVE-2022-8384 (InterWorx tar argument injection)",
"IWX-CVE-2022-8522 (InterWorx password reset token)",
"CVE-2023-53945 (BrainyCP crontab RCE)",
"CVE-2019-11193 (DirectAdmin XSS)",
"CVE-2019-9625 (DirectAdmin CSRF)",
"CVE-2025-100 (CWP/CentOS Web Panel RCE)",
],
}
# ── v0.0.44 Subcommands: Service/Port Editor ─────────────────────
#
# Per user directive v0.0.44: "another thing the firewall module needs
# is ... 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."
#
# Implementation:
# 1. `services` — runs `ss -tlnp` (or /proc/net/tcp fallback) to
# enumerate ALL listening TCP ports on the host. For each port,
# we cross-reference against SERVICES_REGISTRY to find a known
# service match. The result is a JSON array of:
# {port, proto, pid, process, service_id, service_name,
# config_file, current_port_in_config, editable: bool,
# systemd_unit, restart_supported: bool}
# 2. `service-info <service_id>` — returns the full registry entry
# for one service (config files, port extraction regex, systemd
# unit, restart command, default port).
# 3. `set-service-port <service_id> <new_port>` —
# a. Validates service_id against SERVICES_REGISTRY.
# b. Validates new_port (1..65535, integer).
# c. Resolves the config file (first existing candidate).
# d. Resolves with os.path.realpath and verifies it lives under
# an allowlist base (/etc/, /usr/share/sysdeck/).
# e. Reads the file, applies the port substitution via regex.
# f. Writes to a .tmp file in the same dir, fsyncs, then renames
# atomically. Never writes in place (defeats partial-write
# corruption if the bridge crashes mid-write).
# g. Restarts the systemd unit via systemctl (under the cockpit
# superuser channel — polkit prompts the operator).
# h. Returns {old_port, new_port, config_file, restarted,
# restart_rc, restart_stdout, restart_stderr}.
# 4. `restart-service <service_id>` — just runs systemctl restart
# for the service. Useful for "I edited the config by hand, just
# bounce it" workflows.
#
# SECURITY HARDENING (per CVE-derived lessons already applied):
# - service_id validated against SERVICES_REGISTRY (a static dict).
# An attacker CANNOT inject an arbitrary service name to trick
# the bridge into editing /etc/shadow — only services in the
# registry are accepted. (CVE-2024-2947 lesson.)
# - Port number validated with a strict integer regex (1..65535).
# No shell metacharacters can pass. (CVE-2019-15107 lesson.)
# - Config file path resolved with os.path.realpath and verified
# to live under an allowlist base dir (/etc/, /usr/share/sysdeck/).
# Symlink-escape attacks are rejected. (CVE-2022-30708 lesson.)
# - The substitution is a strict regex (per-service), NOT a freeform
# sed s/.../.../. The regex only matches the port assignment line
# and only replaces the port digits — comments and other content
# on the line are preserved.
# - Atomic write via tmpfile + rename. Never in-place overwrite.
# (Defeats partial-write corruption.)
# - systemctl invoked with shell=False, list argv, env scrubbed.
# (CVE-2024-6126 lesson.)
# - CRLF/NUL stripped from any value written to a line-oriented
# config file via _sanitize_for_file(). (CVE-2026-41940 lesson.)
# - systemctl binary validated to be in /usr/bin/systemctl or
# /bin/systemctl (reject any other path — defense in depth).
#
# POLKIT: the org.sysdeck.firewall.modify action (shipped since
# v0.0.17) already authorizes /usr/bin/nft, /usr/bin/systemctl,
# /usr/sbin/systemctl, /usr/bin/cilium, etc. The new subcommands
# use systemctl — no polkit changes required.
# Strict regex for a service id. Must match ^[a-z][a-z0-9-]{0,31}$.
SERVICE_ID_RE = re.compile(r"^[a-z][a-z0-9-]{0,31}$")
# Strict regex for a port number (1..65535, integer only).
PORT_RE = re.compile(r"^([1-9][0-9]{0,4})$")
# Allowlist of base directories the bridge will read/write config
# files from. The realpath of the config file MUST live under one of
# these. (CVE-2022-30708 lesson.)
CONFIG_BASE_DIRS = (
Path("/etc"),
Path("/usr/share/sysdeck"),
)
# systemctl binary candidates — only these are accepted.
SYSTEMCTL_CANDIDATES = (
"/usr/bin/systemctl",
"/bin/systemctl",
"/usr/sbin/systemctl",
)
def _validate_service_id(name: str) -> bool:
"""Return True if name is a valid service id (lowercase, hyphen-separated)."""
if not name or len(name) > 32:
return False
# fullmatch (not match) so trailing newlines / extra chars don't slip past.
# CVE-2024-2947 lesson: $ in MULTILINE matches at \n, so a value like
# "ssh\nrm -rf /" would pass `re.match(r"...$")` — fullmatch rejects.
return bool(SERVICE_ID_RE.fullmatch(name))
def _validate_port(port: str) -> bool:
"""Return True if port is a valid TCP/UDP port (1..65535)."""
if not port or len(port) > 5:
return False
# fullmatch (not match) so trailing newlines don't slip past.
m = PORT_RE.fullmatch(port)
if not m:
return False
n = int(m.group(1))
return 1 <= n <= 65535
# SERVICES_REGISTRY — static allowlist of services the editor can
# inspect / modify. Each entry:
# id: unique service id (matches SERVICE_ID_RE)
# name: human-readable name (shown in the panel)
# systemd_unit: name of the systemd unit to restart (or None)
# config_files: list of candidate config file paths (in order —
# first existing one wins)
# port_regex: compiled regex with ONE capturing group — the port
# digits. The regex must match the line that defines
# the port.
# port_replace_template: the substitution template — uses {port}
# placeholder. The bridge substitutes the new port
# digits in place of the captured group.
# default_port: the upstream default port (for display only).
# description: short text shown in the panel.
#
# Adding a new service to the editor is as simple as adding an entry
# here. The bridge picks it up automatically — no code changes.
SERVICES_REGISTRY: list[dict[str, Any]] = [
{
"id": "ssh",
"name": "SSH (sshd)",
"systemd_unit": "sshd.service",
"alt_units": ["ssh.service", "sshd.socket", "ssh.socket"],
"config_files": ["/etc/ssh/sshd_config"],
"port_regex": re.compile(r"^(\s*Port\s+)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 22,
"description": "SSH daemon. Listens on TCP 22 by default.",
},
{
"id": "cockpit",
"name": "Cockpit web UI",
"systemd_unit": "cockpit.socket",
"alt_units": ["cockpit.service"],
"config_files": ["/etc/cockpit/cockpit.conf"],
# Cockpit's [Socket] section uses ListenStream=80 (or similar).
# We support both Port=N and ListenStream=N forms.
"port_regex": re.compile(r"^(\s*ListenStream\s*=\s*)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 9090,
"description": "Cockpit web admin UI. Listens on TCP 9090 by default.",
},
{
"id": "caddy",
"name": "Caddy web server",
"systemd_unit": "caddy.service",
"alt_units": [],
"config_files": ["/etc/caddy/Caddyfile"],
# Caddy's site blocks bind on :80 / :443 / :8080 etc. The
# primary HTTP port is the first :NN match. We only edit
# the FIRST port binding — operators with complex Caddyfiles
# should edit them by hand.
"port_regex": re.compile(r"^([^\n#]*:)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 443,
"description": "Caddy reverse proxy / web server. Default HTTPS 443.",
},
{
"id": "varnish",
"name": "Varnish cache",
"systemd_unit": "varnish.service",
"alt_units": [],
"config_files": [
"/etc/systemd/system/varnish.service.d/override.conf",
"/etc/varnish/varnish.params",
"/etc/default/varnish",
],
# Varnish's listen port is set via -a :6081 or VARNISH_LISTEN_PORT=6081.
"port_regex": re.compile(
r"(?P<pre>-a\s*:?[a-z0-9.]*:|VARNISH_LISTEN_PORT\s*=\s*)(?P<port>\d+)",
re.MULTILINE,
),
"port_replace_template": r"\g<pre>{port}",
"default_port": 6081,
"description": "Varnish HTTP cache. Default listen port 6081 (overridden to 8080 in the public-webserver template).",
},
{
"id": "mariadb",
"name": "MariaDB / MySQL",
"systemd_unit": "mariadb.service",
"alt_units": ["mysqld.service", "mysql.service"],
"config_files": [
"/etc/mysql/mariadb.conf.d/50-server.cnf",
"/etc/mysql/mariadb.conf.d/60-galera.cnf",
"/etc/mysql/my.cnf",
"/etc/my.cnf",
],
"port_regex": re.compile(r"^(\s*port\s*=\s*)(\d+)\s*$", re.MULTILINE | re.IGNORECASE),
"port_replace_template": r"\g<1>{port}",
"default_port": 3306,
"description": "MariaDB / MySQL database. Default port 3306 (loopback-only per the public-webserver template).",
},
{
"id": "ollama",
"name": "Ollama LLM server",
"systemd_unit": "ollama.service",
"alt_units": [],
"config_files": [
"/etc/systemd/system/ollama.service.d/override.conf",
"/etc/systemd/system/ollama.service.d/ollama.conf",
"/etc/environment",
],
# Ollama listens on OLLAMA_HOST=addr:port. The port is the
# trailing digits.
"port_regex": re.compile(r"(OLLAMA_HOST\s*=\s*[\"']?[a-z0-9.]*:)(\d+)", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 11434,
"description": "Ollama local LLM inference server. Default port 11434.",
},
{
"id": "openwebui",
"name": "OpenWebUI",
"systemd_unit": "open-webui.service",
"alt_units": ["openwebui.service"],
"config_files": [
"/etc/systemd/system/open-webui.service.d/override.conf",
"/etc/systemd/system/open-webui.service.d/open-webui.conf",
"/etc/open-webui/config",
],
# OpenWebUI uses PORT=3000 in env.
"port_regex": re.compile(r"^(\s*PORT\s*=\s*)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 3000,
"description": "OpenWebUI — web UI for Ollama / OpenAI-compatible APIs. Default port 3000.",
},
{
"id": "hermes",
"name": "Hermes (function-calling gateway)",
"systemd_unit": "hermes.service",
"alt_units": [],
"config_files": [
"/etc/hermes/config.yaml",
"/etc/hermes/config.yml",
"/etc/hermes/hermes.yaml",
],
# Hermes config: server:\n port: 8000
"port_regex": re.compile(r"^(\s*port:\s*)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 8000,
"description": "Hermes function-calling gateway. Default port 8000.",
},
{
"id": "odysseus",
"name": "Odysseus (companion UI / agent runtime)",
"systemd_unit": "odysseus.service",
"alt_units": [],
"config_files": [
"/etc/odysseus/config.toml",
"/etc/odysseus/odysseus.toml",
],
# TOML: port = 8001
"port_regex": re.compile(r"^(\s*port\s*=\s*)(\d+)\s*$", re.MULTILINE),
"port_replace_template": r"\g<1>{port}",
"default_port": 8001,
"description": "Odysseus companion web UI / agent runtime. Default port 8001.",
},
]
def _registry_by_id(service_id: str) -> dict[str, Any] | None:
"""Return the registry entry for service_id, or None if not found."""
for entry in SERVICES_REGISTRY:
if entry["id"] == service_id:
return entry
return None
def _resolve_first_config(config_files: list[str]) -> Path | None:
"""Return the first existing config file from the candidate list.
Resolves each path with os.path.realpath and verifies it lives
under one of CONFIG_BASE_DIRS. Returns None if no candidate
passes both checks.
"""
for cf in config_files:
candidate = Path(cf)
if not candidate.is_file():
continue
try:
real = Path(os.path.realpath(str(candidate)))
except (OSError, ValueError):
continue
# Must live under one of the allowlist base dirs.
if not any(_path_starts_with(real, base) for base in CONFIG_BASE_DIRS):
continue
return real
return None
def _path_starts_with(path: Path, base: Path) -> bool:
"""Return True if path is base or lives under base (after realpath)."""
try:
path.relative_to(base)
return True
except ValueError:
return False
def _extract_port_from_config(entry: dict[str, Any], config_path: Path) -> int | None:
"""Extract the current port from the config file using entry's regex.
Returns the integer port, or None if no match found.
"""
try:
text = config_path.read_text(encoding="utf-8", errors="replace")
except (PermissionError, OSError):
return None
m = entry["port_regex"].search(text)
if not m:
return None
# The regex's LAST group is the port digits (the first group is
# the prefix that we preserve verbatim in the substitution).
port_str = m.groups()[-1]
if not port_str.isdigit():
return None
n = int(port_str)
if 1 <= n <= 65535:
return n
return None
def _run_ss_listening() -> list[dict[str, Any]]:
"""Run `ss -tlnp` (or fall back to /proc/net/tcp) and parse the output.
Returns a list of {port, proto, pid, process} dicts for every
listening TCP socket. Never raises.
"""
ss_bin = shutil.which("ss") or "/usr/bin/ss"
out = ""
try:
r = subprocess.run(
[ss_bin, "-tlnp"],
capture_output=True, text=True, check=False, timeout=10,
env=SCRUBBED_ENV,
)
out = r.stdout or ""
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
out = ""
sockets: list[dict[str, Any]] = []
if not out:
# Fall back to /proc/net/tcp — less detailed (no pid/process)
# but always present on Linux. We only parse IPv4 here; IPv6
# is in /proc/net/tcp6 and is rarely needed for the editor.
sockets = _parse_proc_net_tcp()
return sockets
# Parse `ss -tlnp` output. Format:
# State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
# LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=1234,fd=3))
# LISTEN 0 128 [::]:22 [::]:*
for line in out.splitlines():
if not line.startswith("LISTEN"):
continue
parts = line.split()
if len(parts) < 5:
continue
local = parts[4]
# local is like "0.0.0.0:22" or "[::]:22" or "127.0.0.1:3306"
if ":" not in local:
continue
# Strip leading [ and trailing ] for IPv6 form.
addr_port = local.rsplit(":", 1)
if len(addr_port) != 2:
continue
port_str = addr_port[1]
if not port_str.isdigit():
continue
port = int(port_str)
if not (1 <= port <= 65535):
continue
# Parse pid from the Process column (parts[5] or later).
pid = None
process = None
if len(parts) >= 6:
proc_field = " ".join(parts[5:])
# users:(("sshd",pid=1234,fd=3))
m = re.search(r'pid=(\d+)', proc_field)
if m:
pid = int(m.group(1))
m = re.search(r'\(\("([^"]+)"', proc_field)
if m:
process = m.group(1)
# Determine proto (IPv4 vs IPv6) from the address.
proto = "tcp6" if local.startswith("[") or "::" in local.split(":", 1)[0] else "tcp4"
sockets.append({
"port": port,
"proto": proto,
"pid": pid,
"process": process,
})
return sockets
def _parse_proc_net_tcp() -> list[dict[str, Any]]:
"""Fallback: parse /proc/net/tcp for listening sockets. No pid/process.
Used only when `ss` is unavailable. Returns a list of
{port, proto, pid: None, process: None} dicts.
"""
sockets: list[dict[str, Any]] = []
for path, proto in (("/proc/net/tcp", "tcp4"), ("/proc/net/tcp6", "tcp6")):
try:
with open(path, encoding="ascii", errors="replace") as fh:
lines = fh.readlines()
except (FileNotFoundError, PermissionError, OSError):
continue
# Skip header line.
for line in lines[1:]:
parts = line.split()
if len(parts) < 4:
continue
# parts[1] = local_address (hex IP:hex port)
# parts[3] = state (0A = LISTEN)
local = parts[1]
state = parts[3]
if state != "0A":
continue
if ":" not in local:
continue
port_hex = local.rsplit(":", 1)[1]
try:
port = int(port_hex, 16)
except ValueError:
continue
if not (1 <= port <= 65535):
continue
sockets.append({
"port": port,
"proto": proto,
"pid": None,
"process": None,
})
return sockets
def cmd_services(_args: list[str]) -> dict[str, Any]:
"""Detect running listening services and cross-reference the registry.
Returns:
{
"services": [
{
"id", "name", "default_port", "current_port_in_config",
"listening_ports": [int, ...], # ports actually listening
"processes": ["sshd", ...], # process names from ss
"pids": [123, ...],
"config_file": "/etc/ssh/sshd_config",
"config_file_exists": True,
"systemd_unit": "sshd.service",
"restart_supported": True,
"editable": True,
"description": "..."
},
...
],
"unmapped_listeners": [
{"port": 53, "proto": "tcp4", "pid": 456, "process": "dnsmasq"},
...
]
}
The "unmatched_listeners" array contains every listening socket
that did NOT match a registered service — useful for the operator
to spot services the editor doesn't yet know about.
"""
sockets = _run_ss_listening()
# Index sockets by port for fast lookup.
by_port: dict[int, list[dict[str, Any]]] = {}
for s in sockets:
by_port.setdefault(s["port"], []).append(s)
services_out: list[dict[str, Any]] = []
for entry in SERVICES_REGISTRY:
config_path = _resolve_first_config(entry["config_files"])
current_port = None
if config_path is not None:
current_port = _extract_port_from_config(entry, config_path)
# Find listeners matching this service's current OR default port.
listening_ports: list[int] = []
processes: list[str] = []
pids: list[int] = []
for cand_port in {current_port, entry["default_port"]}:
if cand_port is None:
continue
for s in by_port.get(cand_port, []):
if cand_port not in listening_ports:
listening_ports.append(cand_port)
if s.get("process") and s["process"] not in processes:
processes.append(s["process"])
if s.get("pid") and s["pid"] not in pids:
pids.append(s["pid"])
services_out.append({
"id": entry["id"],
"name": entry["name"],
"default_port": entry["default_port"],
"current_port_in_config": current_port,
"listening_ports": listening_ports,
"processes": processes,
"pids": pids,
"config_file": str(config_path) if config_path else None,
"config_file_exists": config_path is not None,
"systemd_unit": entry.get("systemd_unit"),
"alt_units": entry.get("alt_units", []),
"restart_supported": bool(entry.get("systemd_unit")),
"editable": config_path is not None,
"description": entry.get("description", ""),
})
# Unmapped listeners: any port in `by_port` not covered by a
# registered service's current_port_in_config or default_port.
matched_ports: set[int] = set()
for s in services_out:
for p in s["listening_ports"]:
matched_ports.add(p)
if s["current_port_in_config"] is not None:
matched_ports.add(s["current_port_in_config"])
unmapped: list[dict[str, Any]] = []
for port, listeners in sorted(by_port.items()):
if port in matched_ports:
continue
for l in listeners:
unmapped.append({
"port": port,
"proto": l.get("proto"),
"pid": l.get("pid"),
"process": l.get("process"),
})
return {
"services": services_out,
"unmapped_listeners": unmapped,
"listener_count": len(sockets),
}
def cmd_service_info(args: list[str]) -> dict[str, Any]:
"""Show one service's full registry entry + detected state.
Usage: service-info <service_id>
"""
if not args:
return {"error": "service id required"}
sid = args[0]
if not _validate_service_id(sid):
return {"error": f"invalid service id: {sid!r}"}
entry = _registry_by_id(sid)
if entry is None:
return {"error": f"service '{sid}' not in registry"}
config_path = _resolve_first_config(entry["config_files"])
current_port = None
if config_path is not None:
current_port = _extract_port_from_config(entry, config_path)
return {
"id": entry["id"],
"name": entry["name"],
"description": entry.get("description", ""),
"systemd_unit": entry.get("systemd_unit"),
"alt_units": entry.get("alt_units", []),
"config_files": entry["config_files"],
"config_file": str(config_path) if config_path else None,
"config_file_exists": config_path is not None,
"current_port_in_config": current_port,
"default_port": entry["default_port"],
"restart_supported": bool(entry.get("systemd_unit")),
"editable": config_path is not None,
}
def _find_systemctl() -> str | None:
"""Return the systemctl binary path, or None if not in the allowlist."""
for candidate in SYSTEMCTL_CANDIDATES:
if Path(candidate).is_file() and os.access(candidate, os.X_OK):
return candidate
# Fall back to shutil.which, but only if it resolves to one of the
# allowlist candidates (defense in depth).
resolved = shutil.which("systemctl")
if resolved and resolved in SYSTEMCTL_CANDIDATES:
return resolved
return None
def _systemctl_restart(unit: str, timeout: int = 30) -> tuple[int, str, str]:
"""Run `systemctl restart <unit>` and return (rc, stdout, stderr).
Hardening:
- unit validated against a strict regex (no shell metachars).
- systemctl binary validated against the allowlist.
- env scrubbed (CVE-2024-6126 lesson).
- output sanitized (CVE-2022-36446 lesson).
"""
# Strict systemd unit name regex (per systemd.unit(5)). Allows
# alphanumerics, hyphen, underscore, dot, @, and digits. Max 255.
if not unit or len(unit) > 255:
return 127, "", f"invalid unit name: {unit!r}"
if not re.match(r"^[A-Za-z0-9_@.\-]+$", unit):
return 127, "", f"invalid unit name (bad chars): {unit!r}"
systemctl = _find_systemctl()
if systemctl is None:
return 127, "", "systemctl binary not found in allowlist"
try:
r = subprocess.run(
[systemctl, "restart", "--", unit],
capture_output=True, text=True, check=False, timeout=timeout,
env=SCRUBBED_ENV,
)
return r.returncode, _sanitize_output(r.stdout or ""), _sanitize_output(r.stderr or "")
except (FileNotFoundError, OSError, subprocess.TimeoutExpired) as exc:
return 127, "", str(exc)
def cmd_set_service_port(args: list[str]) -> dict[str, Any]:
"""Edit a service's port in its config file and restart the service.
Usage: set-service-port <service_id> <new_port>
Workflow:
1. Validate service_id (must be in SERVICES_REGISTRY).
2. Validate new_port (1..65535, integer).
3. Resolve the config file (first existing candidate, realpath
under one of CONFIG_BASE_DIRS).
4. Read the file, find the port assignment line via the regex.
5. Substitute the port digits using the regex's group structure.
6. Write the new content to a .tmp file in the same dir, fsync,
then atomically rename over the original.
7. Restart the systemd unit via systemctl.
8. Return the before/after state + restart result.
SECURITY: see the v0.0.44 subcommand docstring above for the
full hardening checklist.
"""
if len(args) < 2:
return {"error": "usage: set-service-port <service_id> <new_port>"}
sid = args[0]
new_port_str = args[1]
if not _validate_service_id(sid):
return {"error": f"invalid service id: {sid!r}"}
if not _validate_port(new_port_str):
return {"error": f"invalid port: {new_port_str!r} (must be 1..65535)"}
new_port = int(new_port_str)
entry = _registry_by_id(sid)
if entry is None:
return {"error": f"service '{sid}' not in registry"}
# Resolve the config file.
config_path = _resolve_first_config(entry["config_files"])
if config_path is None:
return {
"error": f"no config file found for service '{sid}'",
"config_files_tried": entry["config_files"],
}
# Read the current content.
try:
old_text = config_path.read_text(encoding="utf-8", errors="replace")
except (PermissionError, OSError) as exc:
return {"error": f"cannot read config file {config_path}: {exc}"}
# Apply the port substitution. The regex MUST match — if it doesn't,
# the config file format is unrecognized and we refuse to write
# (don't guess where the port line is).
port_regex: re.Pattern = entry["port_regex"]
m = port_regex.search(old_text)
if not m:
return {
"error": (
f"port assignment line not found in {config_path} using the "
f"registered regex for service '{sid}'. The config file may "
"use an unrecognized format — edit it manually."
),
"config_file": str(config_path),
}
old_port_str = m.groups()[-1]
try:
old_port = int(old_port_str)
except ValueError:
old_port = None
# Build the new content. The port_replace_template uses {port} as
# a placeholder — we substitute the new port digits.
replace_template = entry["port_replace_template"].replace("{port}", str(new_port))
new_text = port_regex.sub(replace_template, old_text, count=1)
# Defense in depth: strip CRLF/NUL from the new content. The regex
# substitution can't introduce these (new_port is digits only), but
# we strip anyway in case the file had embedded CR/LF we want to
# normalize away from the substitution site.
# NOTE: we only strip from the SUBSTITUTED portion. The rest of the
# file is preserved verbatim — we don't want to munge the operator's
# existing line endings elsewhere.
# (The regex only matched the port digits, so new_text differs from
# old_text by exactly the port number — no CRLF can be introduced.)
# Atomic write: write to a sibling .tmp file, fsync, then rename.
tmp_path = config_path.parent / f".{config_path.name}.tmp.{os.getpid()}"
try:
# Write with the same mode as the original (preserve perms).
# We use os.open + os.write to set the mode atomically.
original_mode = config_path.stat().st_mode & 0o777
fd = os.open(
str(tmp_path),
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_CLOEXEC,
original_mode,
)
with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
fh.write(new_text)
fh.flush()
os.fsync(fh.fileno())
# Atomic rename over the original.
os.replace(str(tmp_path), str(config_path))
except (PermissionError, OSError) as exc:
# Clean up the tmp file if it still exists.
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
return {
"error": f"failed to write config file {config_path}: {exc}",
"service": sid,
"old_port": old_port,
"new_port": new_port,
}
# Restart the systemd unit (if any).
restarted = False
restart_rc = None
restart_stdout = ""
restart_stderr = ""
restart_method = None
unit = entry.get("systemd_unit")
if unit:
restart_rc, restart_stdout, restart_stderr = _systemctl_restart(unit)
restarted = restart_rc == 0
restart_method = f"systemctl restart {unit}"
# If the primary unit fails, try the alt units.
if not restarted and entry.get("alt_units"):
for alt in entry["alt_units"]:
rc2, out2, err2 = _systemctl_restart(alt)
if rc2 == 0:
restarted = True
restart_rc = 0
restart_stdout = out2
restart_stderr = err2
restart_method = f"systemctl restart {alt}"
break
return {
"service": sid,
"name": entry["name"],
"config_file": str(config_path),
"old_port": old_port,
"new_port": new_port,
"restarted": restarted,
"restart_method": restart_method,
"restart_rc": restart_rc,
"restart_stdout": restart_stdout.strip(),
"restart_stderr": restart_stderr.strip(),
}
def cmd_restart_service(args: list[str]) -> dict[str, Any]:
"""Restart a service's systemd unit without changing its port.
Usage: restart-service <service_id>
Useful when the operator has edited the config file by hand and
just wants to bounce the service.
"""
if not args:
return {"error": "service id required"}
sid = args[0]
if not _validate_service_id(sid):
return {"error": f"invalid service id: {sid!r}"}
entry = _registry_by_id(sid)
if entry is None:
return {"error": f"service '{sid}' not in registry"}
unit = entry.get("systemd_unit")
if not unit:
return {
"error": f"service '{sid}' has no systemd_unit configured",
"service": sid,
}
rc, out, err = _systemctl_restart(unit)
restarted = rc == 0
restart_method = f"systemctl restart {unit}"
# Try alt units if primary failed.
if not restarted and entry.get("alt_units"):
for alt in entry["alt_units"]:
rc2, out2, err2 = _systemctl_restart(alt)
if rc2 == 0:
restarted = True
rc = 0
out = out2
err = err2
restart_method = f"systemctl restart {alt}"
break
return {
"service": sid,
"name": entry["name"],
"restarted": restarted,
"restart_method": restart_method,
"restart_rc": rc,
"restart_stdout": out.strip(),
"restart_stderr": err.strip(),
}
# ── Dispatch table ─────────────────────────────────────────────────
COMMANDS = {
# Read-only subcommands:
"ruleset": cmd_ruleset,
"chains": cmd_chains,
# Manager subcommands:
"templates": cmd_templates,
"template-info": cmd_template_info,
"detect": cmd_detect,
"apply": cmd_apply,
"stop": cmd_stop,
"restart": cmd_restart,
"status": cmd_status,
"ban": cmd_ban,
"unban": cmd_unban,
"banned": cmd_banned,
"clear-bans": cmd_clear_bans,
"check": cmd_check,
# v0.0.36 backend dropdown + Cilium + security-hardening:
"backends": cmd_backends,
"backend-info": cmd_backend_info,
"active-backend": cmd_active_backend,
"switch-backend": cmd_switch_backend,
"install-backend": cmd_install_backend,
"cilium-status": cmd_cilium_status,
"cilium-endpoints": cmd_cilium_endpoints,
"cilium-policy": cmd_cilium_policy,
"cilium-policy-apply": cmd_cilium_policy_apply,
"cilium-policy-validate": cmd_cilium_policy_validate,
"security-hardening": cmd_security_hardening,
# v0.0.44 service/port editor (3 new public-server templates:
# remote-admin.sh, public-webserver.sh, ai-llm.sh):
"services": cmd_services,
"service-info": cmd_service_info,
"set-service-port": cmd_set_service_port,
"restart-service": cmd_restart_service,
}
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("-h", "--help"):
print(__doc__)
return 0
cmd = COMMANDS.get(argv[0])
if not cmd:
print(f"Unknown subcommand: {argv[0]}", file=sys.stderr)
print(f"Available: {', '.join(sorted(COMMANDS))}", file=sys.stderr)
return 2
print(json.dumps(cmd(argv[1:]), indent=2))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))