Files
vacuum-wall/lib/firewall.py
T
mteehan 6106c1434d Add declarative firewall config with save-then-apply workflow
New two-step config flow: POST /config saves desired state to
config/firewall/config.json, GET /config/pending diffs against live
firewalld state, POST /config/apply synchronizes live state.  Adds target
normalization helpers and full test coverage for config CRUD and pending
diff logic.
2026-05-14 03:31:49 +00:00

887 lines
25 KiB
Python

"""
firewall.py - firewalld manager for Vacuum Wall SSL proxy firewall appliance.
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.
"""
import json
import os
import subprocess
from contextlib import suppress
from datetime import UTC
from pathlib import Path
from typing import Any
PROJECT_DIR = Path(__file__).resolve().parent.parent
DATA_DIR: str = str(PROJECT_DIR / "data" / "firewall")
RULES_FILE: str = os.path.join(DATA_DIR, "rules.json")
CONFIG_DIR = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _run(cmd: list[str], check: bool = True) -> str:
"""Run a command via subprocess and return its stdout.
Callers must include ``"sudo"`` as the first argument when the
command requires elevated privileges.
Raises:
RuntimeError: When ``check=True`` and the process exits non-zero.
"""
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
return result.stdout.strip()
def _reload() -> None:
"""Reload firewalld so permanent changes take effect immediately."""
_run(["sudo", "firewall-cmd", "--reload"])
def _ensure_data_dir() -> None:
"""Create the data directory tree if it does not exist."""
os.makedirs(DATA_DIR, exist_ok=True)
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
# ---------------------------------------------------------------------------
# Read-only queries
# ---------------------------------------------------------------------------
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"])
return output.split()
def get_active_zones() -> dict[str, list[str]]:
"""Return a dict mapping active zone names to their assigned interfaces.
Example return value::
{
"public": ["eth0"],
"internal": ["eth1"],
}
"""
output = _run(["sudo", "firewall-cmd", "--get-active-zones"])
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
stripped = raw_line.strip()
if not stripped:
continue
# Indented lines belong to the current zone section.
if raw_line.startswith(" "):
current_ifaces = (
zones[current_zone]
if current_zone
else zones.get(list(zones.keys())[-1], [])
)
for piece in stripped.split():
if current_zone and piece not in current_ifaces:
current_ifaces.append(piece)
else:
current_zone = stripped
zones[current_zone] = []
return zones
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*.
Keys in the returned dict include:
``name``, ``target``, ``interfaces``, ``sources``, ``services``,
``ports``, ``protocols``, ``forward-ports``, ``masquerade``,
``rich-rules``, ``ics``, ``icmp-blocks``, ``module``.
"""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
if not line or ":" not in line:
continue
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
if not value:
# Lines like "interfaces: " or "masquerade: " when disabled
if key in ("masquerade", "ics"):
info[key] = False
else:
info[key] = []
else:
if key in (
"interfaces",
"sources",
"services",
"ports",
"protocols",
"icmp-blocks",
"module",
):
info[key] = value.split()
elif key == "forward-ports":
info[key] = _parse_forward_ports(value)
elif key in ("masquerade", "ics"):
info[key] = value.lower() == "yes"
elif key == "rich-rules":
# rich-rules can span multiple lines; we'll parse below.
info[key] = [value] if value else []
else:
info[key] = value
# rich-rules may already have been set; if not, default to empty.
info.setdefault("rich-rules", [])
info.setdefault("interfaces", [])
info.setdefault("sources", [])
info.setdefault("services", [])
info.setdefault("ports", [])
info.setdefault("protocols", [])
info.setdefault("forward-ports", [])
info.setdefault("masquerade", False)
info.setdefault("ics", False)
info.setdefault("icmp-blocks", [])
info.setdefault("module", [])
info.setdefault("target", "default")
return info
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"])
return output.split()
def get_icmp_blocks() -> list[str]:
"""Return the list of available ICMP block names."""
output = _run(["sudo", "firewall-cmd", "--get-icmptypes"])
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:
# Format: "NUM: NAME: <FLAGS> ..."
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(["sudo", "firewall-cmd", f"--zone={zone}", "--list-rich-rules"])
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.
Args:
zone: Name of the zone to create.
Raises:
RuntimeError: If the zone already exists or creation fails.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
)
_reload()
def delete_zone(zone: str) -> None:
"""Delete an existing zone.
Raises:
RuntimeError: If the zone does not exist or the deletion fails.
"""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
_reload()
# ---------------------------------------------------------------------------
# Interface assignment
# ---------------------------------------------------------------------------
def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
"""Assign *interfaces* to *zone*, replacing any existing assignments.
Existing interfaces on the zone are removed first so only the
provided list remains.
"""
# Remove current permanent interfaces for this zone.
try:
current = get_zone_info(zone).get("interfaces", [])
except Exception:
current = []
for iface in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
check=False,
)
# Add the desired set.
for iface in interfaces:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
)
_reload()
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Service management
# ---------------------------------------------------------------------------
def set_zone_services(zone: str, services: list[str]) -> None:
"""Set services for *zone*, replacing any previously allowed services."""
# Remove all current services.
current = get_zone_info(zone).get("services", [])
for svc in current:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
check=False,
)
for svc in services:
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
)
_reload()
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
)
_reload()
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Rich rules
# ---------------------------------------------------------------------------
def add_rich_rule(zone: str, rule: str) -> None:
"""Add a rich rule to *zone*.
The *rule* argument should be a fully-formed rich-rule expression,
e.g. ``rule family="ipv4" port protocol="tcp" port="443" accept``.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
)
_reload()
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*.
The rule string must match exactly what was added.
"""
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# 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(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
_reload()
# ---------------------------------------------------------------------------
# Port forwarding
# ---------------------------------------------------------------------------
def add_forward_port(
zone: str,
port: int,
protocol: str,
toaddr: str | None = None,
toport: int | None = None,
) -> None:
"""Add a port forwarding rule to *zone*.
Forward traffic arriving on ``port/protocol`` to
``toaddr:toport`` (or just ``toport`` when *toaddr* is omitted).
"""
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(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
)
_reload()
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*.
All parameters must match the original rule exactly.
"""
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(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
)
_reload()
# ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines
# ---------------------------------------------------------------------------
def _parse_forward_port(raw: str) -> dict[str, Any]:
"""Parse a single forward-port specifier into a structured dict.
Input: ``port=443/proto=tcp/toaddr=192.168.1.5/toport=8080``
"""
result: dict[str, Any] = {}
for piece in raw.split("/"):
if "=" not in piece:
continue
key, _, val = piece.partition("=")
if key == "port":
result["port"] = int(val)
elif key == "proto":
result["proto"] = val
elif key == "toaddr":
result["toaddr"] = val
elif key == "toport":
result["toport"] = int(val)
return result
def _parse_forward_ports(value: str) -> list[dict[str, Any]]:
"""Parse the 'forward-ports' line into a list of structured dicts."""
if not value:
return []
return [_parse_forward_port(raw) for raw in value.split()]
# ---------------------------------------------------------------------------
# State snapshot / backup helpers
# ---------------------------------------------------------------------------
def get_state() -> dict[str, Any]:
"""Return the complete current state of firewalld as a Python dict.
The dict contains all zones with their per-zone configuration, all
rich rules, masquerade settings, forward-port rules, and the set of
active interfaces.
"""
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."""
from datetime import datetime
return datetime.now(UTC).isoformat()
def save_backup() -> str:
"""Capture the full state and write it to RULES_FILE on disk.
Returns:
Absolute path to the written file.
"""
_ensure_data_dir()
state = get_state()
with open(RULES_FILE, "w") as fh:
json.dump(state, fh, indent=2, default=str)
return RULES_FILE
def load_backup() -> dict[str, Any]:
"""Read the JSON backup file and return the state dict.
Use :func:`restore_backup` to actually apply the loaded state.
Raises:
FileNotFoundError: When no backup file exists at RULES_FILE.
json.JSONDecodeError: When the file is not valid JSON.
Returns:
The loaded state dict.
"""
with open(RULES_FILE) as fh:
state: dict[str, Any] = json.load(fh)
return state
def restore_backup(state: dict[str, Any]) -> None:
"""Apply the zone configuration described in *state*.
Walks every zone in *state*["zones"] and re-creates services,
interfaces, forward ports, masquerade, and rich rules.
This is a *merge*: zones not present in the snapshot are **not**
touched.
"""
zones_cfg = state.get("zones", {})
for zone_name, zinfo in zones_cfg.items():
# Ensure the zone exists.
if zone_name not in get_available_zones():
target = zinfo.get("target", "default")
create_zone(zone_name, target)
# Services
services = zinfo.get("services", [])
set_zone_services(zone_name, services)
# Interfaces
interfaces = zinfo.get("interfaces", [])
set_zone_interfaces(zone_name, interfaces)
# Masquerade
if zinfo.get("masquerade"):
set_masquerade(zone_name, True)
# Forward ports (stored as dicts, or raw strings from old backups)
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(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
check=False,
)
# Rich rules
for rule in zinfo.get("rich-rules", []):
_run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
check=False,
)
_reload()
# ---------------------------------------------------------------------------
# Declarative config management (config/firewall/config.json)
# ---------------------------------------------------------------------------
def _ensure_config_file() -> None:
"""Create config directory and file if they do not exist."""
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
if not CONFIG_FILE.exists():
with open(CONFIG_FILE, "w") as fh:
json.dump(DEFAULT_CONFIG, fh, indent=2)
fh.write("\n")
def config_get() -> dict[str, Any]:
"""Return the declarative config from ``config/firewall/config.json``."""
_ensure_config_file()
with open(CONFIG_FILE) as fh:
return json.load(fh)
def config_set(cfg: dict[str, Any]) -> None:
"""Write *cfg* to ``config/firewall/config.json`` (atomic replace)."""
_ensure_config_file()
tmp = CONFIG_FILE.with_name(CONFIG_FILE.name + ".tmp")
with open(tmp, "w") as fh:
json.dump(cfg, fh, indent=2)
fh.write("\n")
os.replace(tmp, CONFIG_FILE)
def _normalize_target(target: str) -> str:
"""Map between config JSON target names and firewalld target values."""
if target == "ACCEPT":
return "ACCEPT"
if target == "DROP":
return "DROP"
if target == "REJECT":
return "REJECT"
return "default"
def _live_target_to_config(target: str) -> str:
"""Map firewalld target value back to config JSON canonical form."""
if target == "ACCEPT":
return "ACCEPT"
if target == "DROP":
return "DROP"
if target == "REJECT":
return "REJECT"
return "DEFAULT"
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff.
Returns a dict with ``pending`` (list of change dicts), ``needs_apply``
(bool), and ``live_zones`` (dict of zones not yet in config).
"""
cfg = config_get()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
changes: list[dict[str, Any]] = []
unknown_live: dict[str, Any] = {}
for zone_name, zone_cfg in cfg_zones.items():
live_zone = live_zones.get(zone_name, {})
if not zone_cfg.get("interfaces"):
continue
cfg_ifaces = set(zone_cfg.get("interfaces", []))
live_ifaces = set(live_zone.get("interfaces", []))
if cfg_ifaces != live_ifaces:
changes.append(
{
"zone": zone_name,
"type": "interfaces",
"config": sorted(cfg_ifaces),
"live": sorted(live_ifaces),
}
)
cfg_services = set(zone_cfg.get("services", []))
live_services = set(live_zone.get("services", []))
if cfg_services != live_services:
changes.append(
{
"zone": zone_name,
"type": "services",
"config": sorted(cfg_services),
"live": sorted(live_services),
}
)
cfg_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
live_target = live_zone.get("target", "default")
if cfg_target != live_target:
changes.append(
{
"zone": zone_name,
"type": "target",
"config": cfg_target,
"live": live_target,
}
)
cfg_mq = zone_cfg.get("masquerade", False)
live_mq = live_zone.get("masquerade", False)
if cfg_mq != live_mq:
changes.append(
{
"zone": zone_name,
"type": "masquerade",
"config": cfg_mq,
"live": live_mq,
}
)
for zone_name in live_zones:
if zone_name not in cfg_zones:
unknown_live[zone_name] = {
"interfaces": live_zones[zone_name].get("interfaces", []),
}
return {
"pending": changes,
"needs_apply": len(changes) > 0,
"unmanaged_zones": unknown_live,
}
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld.
Takes a snapshot via ``save_backup()`` first, then reconciles each zone
in the config (create/update, interfaces, services, masquerade), reloads,
and takes another snapshot.
Returns a dict with ``applied_zones`` and a ``backup`` path.
"""
cfg = config_get()
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(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
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)
applied.append(zone_name)
_reload()
backup_path = save_backup()
return {
"applied_zones": applied,
"backup": backup_path,
}
__all__ = [
"CONFIG_DIR",
"CONFIG_FILE",
"DATA_DIR",
"DEFAULT_CONFIG",
"RULES_FILE",
"_reload",
"_run",
"add_forward_port",
"add_rich_rule",
"add_zone_interface",
"add_zone_service",
"config_apply",
"config_get",
"config_pending",
"config_set",
"create_zone",
"delete_zone",
"get_active_zones",
"get_available_zones",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
"get_services",
"get_state",
"get_zone_info",
"load_backup",
"remove_forward_port",
"remove_rich_rule",
"remove_zone_interface",
"remove_zone_service",
"restore_backup",
"save_backup",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",
]