37039351be
- wireguard: POST /peers with JSON encoding (was /add-peer) - rules: delete by rule_id in URL path (was JSON body); pass rule objects with id from server; add hx-disable to initial render - nat: port forward delete uses URL path params to match blueprint - nat: masquerade toggle uses native hx-post/hx-vals (was inline fetch) - app.js renderers updated to use URL path deletes for rules and forwards - remove TODO.md
1016 lines
30 KiB
Python
1016 lines
30 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 logging
|
|
import os
|
|
import subprocess
|
|
from contextlib import suppress
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
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."""
|
|
try:
|
|
_run(["sudo", "firewall-cmd", "--reload"])
|
|
logger.info("firewalld reloaded")
|
|
except RuntimeError as exc:
|
|
logger.error("firewalld reload failed: %s", exc)
|
|
raise
|
|
|
|
|
|
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)
|
|
|
|
|
|
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(["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."""
|
|
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
|
|
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*."""
|
|
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:
|
|
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":
|
|
info[key] = [value] if value else []
|
|
else:
|
|
info[key] = value
|
|
|
|
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:
|
|
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."""
|
|
_run(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--set-target={target}",
|
|
"--permanent",
|
|
]
|
|
)
|
|
_reload()
|
|
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
|
|
|
|
|
|
def delete_zone(zone: str) -> None:
|
|
"""Delete an existing zone."""
|
|
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--remove-interface=" + iface,
|
|
"--permanent",
|
|
],
|
|
check=False,
|
|
)
|
|
|
|
for iface in interfaces:
|
|
_run(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--add-interface=" + iface,
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--add-interface=" + iface,
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--remove-interface=" + iface,
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"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()
|
|
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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--add-service={service}",
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--remove-service={service}",
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--add-rich-rule=" + rule,
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
"--remove-rich-rule=" + rule,
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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 = config_get()
|
|
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)
|
|
config_set(cfg)
|
|
return entry
|
|
|
|
|
|
def _unpersist_rich_rule(zone: str, rule: str) -> None:
|
|
"""Remove a rich rule from the declarative config by rule string."""
|
|
cfg = config_get()
|
|
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]
|
|
config_set(cfg)
|
|
|
|
|
|
def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
|
|
"""Look up a rich rule entry in the declarative config."""
|
|
cfg = config_get()
|
|
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 = config_get()
|
|
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(["sudo", "firewall-cmd", f"--zone={zone}", action, "--permanent"])
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--add-forward-port={fwd}",
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone}",
|
|
f"--remove-forward-port={fwd}",
|
|
"--permanent",
|
|
]
|
|
)
|
|
_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 = config_get()
|
|
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)
|
|
config_set(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 = config_get()
|
|
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)
|
|
]
|
|
config_set(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 = config_get()
|
|
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 = config_get()
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _parse_forward_port(raw: str) -> dict[str, Any]:
|
|
"""Parse a single forward-port specifier into a structured dict."""
|
|
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."""
|
|
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."""
|
|
_ensure_data_dir()
|
|
state = get_state()
|
|
with open(RULES_FILE, "w") as fh:
|
|
json.dump(state, fh, indent=2, default=str)
|
|
logger.info("Firewall state backup saved to %s", RULES_FILE)
|
|
return RULES_FILE
|
|
|
|
|
|
def load_backup() -> dict[str, Any]:
|
|
"""Read the JSON backup file and return the 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*."""
|
|
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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-forward-port={fp_str}",
|
|
"--permanent",
|
|
],
|
|
check=False,
|
|
)
|
|
|
|
for rule in zinfo.get("rich-rules", []):
|
|
_run(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-rich-rule={rule}",
|
|
"--permanent",
|
|
],
|
|
check=False,
|
|
)
|
|
|
|
_reload()
|
|
logger.info("Firewall backup restored, %d zones processed", len(zones_cfg))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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)
|
|
logger.info("Firewall declarative config saved")
|
|
|
|
|
|
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."""
|
|
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,
|
|
}
|
|
)
|
|
|
|
cfg_rules = {
|
|
r.get("rule") for r in zone_cfg.get("rich_rules", [])
|
|
}
|
|
live_rules = set(live_zone.get("rich-rules", []))
|
|
if cfg_rules != live_rules:
|
|
changes.append(
|
|
{
|
|
"zone": zone_name,
|
|
"type": "rich_rules",
|
|
"config_count": len(cfg_rules),
|
|
"live_count": len(live_rules),
|
|
}
|
|
)
|
|
|
|
cfg_fps = {
|
|
(fp.get("port"), fp.get("proto"))
|
|
for fp in zone_cfg.get("forward_ports", [])
|
|
}
|
|
live_fps = {
|
|
(fp.get("port"), fp.get("proto"))
|
|
for fp in live_zone.get("forward-ports", [])
|
|
}
|
|
if cfg_fps != live_fps:
|
|
changes.append(
|
|
{
|
|
"zone": zone_name,
|
|
"type": "forward_ports",
|
|
"config_count": len(cfg_fps),
|
|
"live_count": len(live_fps),
|
|
}
|
|
)
|
|
|
|
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."""
|
|
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)
|
|
|
|
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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-rich-rule={rule_str}",
|
|
"--permanent",
|
|
],
|
|
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(
|
|
[
|
|
"sudo",
|
|
"firewall-cmd",
|
|
f"--zone={zone_name}",
|
|
f"--add-forward-port={fp_str}",
|
|
"--permanent",
|
|
],
|
|
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,
|
|
}
|
|
|
|
|
|
__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_forward_port_by_id",
|
|
"remove_rich_rule",
|
|
"remove_rich_rule_by_id",
|
|
"remove_zone_interface",
|
|
"remove_zone_service",
|
|
"restore_backup",
|
|
"save_backup",
|
|
"set_masquerade",
|
|
"set_zone_interfaces",
|
|
"set_zone_services",
|
|
]
|