refactor: introduce two-user daemon architecture with socket-based communication

- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
This commit is contained in:
2026-05-27 23:38:23 +00:00
parent 5ac69dfa7e
commit 200e078bc5
39 changed files with 4671 additions and 1810 deletions
+52 -674
View File
@@ -1,21 +1,16 @@
"""
firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance.
firewall.py - firewalld parsing helpers & declarative config for Vacuum Wall.
Wraps firewall-cmd CLI via sudo, manages zones, rules, masquerade/NAT,
and port-forwarding. All mutations are --permanent followed by --reload.
A JSON snapshot of all rules is persisted at DATA_DIR/rules.json so the
Flask UI can inspect or restore previous configurations.
Pure logic only — no subprocess or sudo calls.
All privileged commands are handled by daemon/handlers/firewall.py.
"""
import logging
from contextlib import suppress
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from lib.common import load_json, run, save_json
from lib.common import load_json, save_json
logger = logging.getLogger(__name__)
@@ -33,35 +28,8 @@ DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately."""
try:
run(["firewall-cmd", "--reload"], sudo=True)
logger.info("firewalld reloaded")
except RuntimeError as exc:
logger.error("firewalld reload failed: %s", exc)
raise
def _gen_id() -> str:
"""Generate a short unique identifier (8 hex characters)."""
return uuid4().hex[:8]
# ---------------------------------------------------------------------------
# Read-only queries
# ---------------------------------------------------------------------------
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = run(["firewall-cmd", "--get-zones"], sudo=True)
return output.split()
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces."""
output = run(["firewall-cmd", "--get-active-zones"], sudo=True)
def _parse_active_zones(output: str) -> dict[str, list[str]]:
"""Parse ``firewall-cmd --get-active-zones`` output."""
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
@@ -75,17 +43,30 @@ def get_active_zones() -> dict[str, list[str]]:
else zones.get(list(zones.keys())[-1], [])
)
for piece in stripped.split():
if piece.endswith(":"):
continue
if current_zone and piece not in current_ifaces:
current_ifaces.append(piece)
else:
current_zone = stripped
current_zone = stripped.removesuffix(" (default)")
zones[current_zone] = []
return zones
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*."""
output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
def _parse_interfaces(output: str) -> list[str]:
"""Parse ``ip -o link show`` output."""
ifaces: list[str] = []
for line in output.splitlines():
if line:
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
ifaces.append(name)
return ifaces
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` output."""
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
@@ -135,444 +116,6 @@ def get_zone_info(zone: str) -> dict[str, Any]:
return info
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = run(["firewall-cmd", "--get-services"], sudo=True)
return output.split()
def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names."""
output = run(["firewall-cmd", "--get-icmptypes"], sudo=True)
return output.split()
def get_interfaces() -> list[str]:
"""Return the list of network interfaces visible via iproute2."""
output = run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
parts = line.split()
if len(parts) >= 2:
name = parts[1].rstrip(":")
ifaces.append(name)
return ifaces
def get_rich_rules(zone: str) -> list[str]:
"""Return the rich rules defined for *zone* as a list of raw strings."""
output = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
output = output.strip()
if not output:
return []
rules: list[str] = []
current: list[str] = []
for line in output.splitlines():
raw = line.rstrip()
if not raw.endswith(";"):
current.append(raw)
else:
current.append(raw)
rules.append(" ".join(current))
current = []
if current:
rules.append(" ".join(current))
return rules
# ---------------------------------------------------------------------------
# Zone CRUD
# ---------------------------------------------------------------------------
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld."""
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
def delete_zone(zone: str) -> None:
"""Delete an existing zone."""
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Firewall zone '%s' deleted", zone)
# ---------------------------------------------------------------------------
# Interface assignment
# ---------------------------------------------------------------------------
def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
"""Assign *interfaces* to *zone*, replacing any existing assignments."""
try:
current = get_zone_info(zone).get("interfaces", [])
except Exception:
current = []
for iface in current:
run(
[
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in interfaces:
run(
[
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
run(
[
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Interface '%s' added to zone '%s'", iface, zone)
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
run(
[
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Interface '%s' removed from zone '%s'", iface, zone)
# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------
def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
current = get_zone_info(zone).get("services", [])
for svc in current:
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
sudo=True,
check=False,
)
for svc in services:
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Zone '%s' services set to %s", zone, services)
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Service '%s' added to zone '%s'", service, zone)
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
],
sudo=True,
)
_reload()
logger.info("Service '%s' removed from zone '%s'", service, zone)
# ---------------------------------------------------------------------------
# Rich rules
# ---------------------------------------------------------------------------
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to *zone* and persist to declarative config."""
run(
[
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
],
sudo=True,
)
_reload()
_persist_rich_rule(zone, rule)
rule_entry = _get_rich_rule_entry(zone, rule)
logger.info("Rich rule added to zone '%s': %s", zone, rule[:80])
return rule_entry
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*."""
run(
[
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
],
sudo=True,
)
_reload()
_unpersist_rich_rule(zone, rule)
logger.info("Rich rule removed from zone '%s': %s", zone, rule[:80])
def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to the declarative config with a generated id."""
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("rich_rules", [])
existing_rules = cfg["zones"][zone]["rich_rules"]
rule_id = _gen_id()
entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry)
save_config(cfg)
return entry
def _unpersist_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from the declarative config by rule string."""
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
rules = zone_cfg.get("rich_rules", [])
zone_cfg["rich_rules"] = [r for r in rules if r.get("rule") != rule]
save_config(cfg)
def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
"""Look up a rich rule entry in the declarative config."""
cfg = get_config()
for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []):
if r.get("rule") == rule:
return r
return {"rule": rule}
def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
"""Remove a rich rule from *zone* by its config id."""
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for r in zone_cfg.get("rich_rules", []):
if r.get("id") == rule_id:
entry = r
break
if entry is None:
raise ValueError(f"Rich rule '{rule_id}' not found in zone '{zone}'")
rule = entry["rule"]
remove_rich_rule(zone, rule)
# ---------------------------------------------------------------------------
# Masquerade (NAT)
# ---------------------------------------------------------------------------
def set_masquerade(zone: str, enable: bool) -> None:
"""Enable or disable masquerade (source-NAT) on *zone*."""
action = "--add-masquerade" if enable else "--remove-masquerade"
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
# ---------------------------------------------------------------------------
# Port forwarding
# ---------------------------------------------------------------------------
def add_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> dict[str, Any]:
"""Add a port forwarding rule to *zone* and persist to declarative config."""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
],
sudo=True,
)
_reload()
_persist_forward_port(zone, port, protocol, toaddr, toport)
fp_entry = _get_forward_port_entry(zone, port, protocol)
logger.info("Port forward added to zone '%s': %s", zone, fwd)
return fp_entry
def remove_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Remove a previously added port-forwarding rule from *zone*."""
fwd = f"port={port}/proto={protocol}"
if toaddr and toport:
fwd += f"/toaddr={toaddr}/toport={toport}"
elif toport:
fwd += f"/toport={toport}"
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
run(
[
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
],
sudo=True,
)
_reload()
_unpersist_forward_port(zone, port, protocol)
logger.info("Port forward removed from zone '%s': %s", zone, fwd)
def _persist_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id."""
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", [])
fp_id = _gen_id()
entry: dict[str, Any] = {
"id": fp_id,
"port": port,
"proto": protocol,
}
if toaddr:
entry["toaddr"] = toaddr
if toport:
entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry)
save_config(cfg)
return entry
def _unpersist_forward_port(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from the declarative config by port+proto."""
cfg = get_config()
fps = cfg.get("zones", {}).get(zone, {}).get("forward_ports", [])
cfg.setdefault("zones", {}).setdefault(zone, {})
cfg["zones"][zone]["forward_ports"] = [
fp for fp in fps if not (fp.get("port") == port and fp.get("proto") == protocol)
]
save_config(cfg)
def _get_forward_port_entry(zone: str, port: int, protocol: str) -> dict[str, Any]:
"""Look up a forward port entry in the declarative config."""
cfg = get_config()
for fp in cfg.get("zones", {}).get(zone, {}).get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol:
return fp
entry: dict[str, Any] = {"port": port, "proto": protocol}
return entry
def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
"""Remove a forward port from *zone* by port+proto (id used by API layer)."""
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for fp in zone_cfg.get("forward_ports", []):
if fp.get("port") == port and fp.get("proto") == protocol:
entry = fp
break
if entry is None:
raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'")
remove_forward_port(
zone,
port,
protocol,
toaddr=entry.get("toaddr"),
toport=entry.get("toport"),
)
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
@@ -608,36 +151,16 @@ def _parse_forward_ports(value: str) -> list[dict[str, Any]]:
# ---------------------------------------------------------------------------
def get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld as a Python dict."""
zones: dict[str, dict[str, Any]] = {}
for name in get_available_zones():
try:
zones[name] = get_zone_info(name)
except Exception:
continue
return {
"active_zones": get_active_zones(),
"interfaces": get_interfaces(),
"available_services": get_services(),
"zones": zones,
"rich_rules": {name: get_rich_rules(name) for name in zones},
"timestamp": _now_iso(),
}
def _now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string."""
return datetime.now(UTC).isoformat()
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk."""
state = get_state()
def save_backup(state: dict[str, Any]) -> str:
"""Write *state* to RULES_FILE on disk."""
save_json(RULES_FILE, state)
logger.info("Firewall state backup saved to %s", RULES_FILE)
return RULES_FILE
return str(RULES_FILE)
def load_backup() -> dict[str, Any]:
@@ -645,60 +168,6 @@ def load_backup() -> dict[str, Any]:
return load_json(RULES_FILE)
def restore_backup(state: dict[str, Any]) -> None:
"""Apply the zone configuration described in *state*."""
zones_cfg = state.get("zones", {})
for zone_name, zinfo in zones_cfg.items():
if zone_name not in get_available_zones():
target = zinfo.get("target", "default")
create_zone(zone_name, target)
services = zinfo.get("services", [])
set_zone_services(zone_name, services)
interfaces = zinfo.get("interfaces", [])
set_zone_interfaces(zone_name, interfaces)
if zinfo.get("masquerade"):
set_masquerade(zone_name, True)
for fp in zinfo.get("forward-ports", []):
if isinstance(fp, str):
fp_str = fp
else:
parts = [f"port={fp['port']}", f"proto={fp['proto']}"]
if "toaddr" in fp:
parts.append(f"toaddr={fp['toaddr']}")
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts)
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
for rule in zinfo.get("rich-rules", []):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
sudo=True,
check=False,
)
_reload()
logger.info("Firewall backup restored, %d zones processed", len(zones_cfg))
# ---------------------------------------------------------------------------
# Declarative config management (config/firewall/config.json)
# ---------------------------------------------------------------------------
@@ -745,12 +214,16 @@ def _live_target_to_config(target: str) -> str:
return "DEFAULT"
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff."""
cfg = get_config()
live_state = get_state()
def _compute_pending_changes(
cfg: dict[str, Any],
live_zones: dict[str, dict[str, Any]],
) -> dict[str, Any]:
"""Compare declarative config against live zone state, return diff.
Pure function — no subprocess calls. Caller is responsible for providing
live state (typically from the daemon).
"""
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
changes: list[dict[str, Any]] = []
unknown_live: dict[str, Any] = {}
@@ -851,93 +324,15 @@ def config_pending() -> dict[str, Any]:
}
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
def config_pending(state: dict[str, Any]) -> dict[str, Any]:
"""Compare declarative config against firewalld live state, return diff.
*state* is required — the daemon always passes live state via
`daemon.handlers.firewall.get_state()`.
"""
cfg = get_config()
cfg_zones = cfg.get("zones", {})
save_backup()
available = get_available_zones()
applied: list[str] = []
for zone_name, zone_cfg in cfg_zones.items():
need_create = zone_name not in available
if need_create:
target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
create_zone(zone_name, target)
else:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default":
with suppress(RuntimeError):
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
sudo=True,
check=False,
)
set_zone_services(zone_name, zone_cfg.get("services", []))
set_zone_interfaces(zone_name, zone_cfg.get("interfaces", []))
mq = zone_cfg.get("masquerade", False)
if mq is not None:
set_masquerade(zone_name, mq)
for rule_entry in zone_cfg.get("rich_rules", []):
rule_str = (
rule_entry.get("rule", "")
if isinstance(rule_entry, dict)
else str(rule_entry)
)
if rule_str:
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
for fp_entry in zone_cfg.get("forward_ports", []):
if isinstance(fp_entry, str):
fp_str = fp_entry
else:
parts = [f"port={fp_entry['port']}", f"proto={fp_entry['proto']}"]
if "toaddr" in fp_entry:
parts.append(f"toaddr={fp_entry['toaddr']}")
if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts)
run(
[
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
applied.append(zone_name)
_reload()
backup_path = save_backup()
logger.info("Firewall config applied to %d zones", len(applied))
return {
"applied_zones": applied,
"backup": backup_path,
}
live_zones = state.get("zones", {})
return _compute_pending_changes(cfg, live_zones)
__all__ = [
@@ -946,35 +341,18 @@ __all__ = [
"DATA_DIR",
"DEFAULT_CONFIG",
"RULES_FILE",
"_reload",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"config_apply",
"_compute_pending_changes",
"_ensure_config_file",
"_live_target_to_config",
"_normalize_target",
"_now_iso",
"_parse_active_zones",
"_parse_forward_ports",
"_parse_interfaces",
"_parse_zone_output",
"config_pending",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_config",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
"get_services",
"get_state",
"get_zone_info",
"load_backup",
"remove_forward_port",
"remove_forward_port_by_id",
"remove_rich_rule",
"remove_rich_rule_by_id",
"remove_zone_interface",
"remove_zone_service",
"restore_backup",
"save_backup",
"save_config",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
]