fix htmx refactor route mismatches and remaining TODO items
- 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
This commit is contained in:
+248
-119
@@ -9,12 +9,16 @@ Flask UI can inspect or restore previous configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from contextlib import suppress
|
||||
from datetime import UTC
|
||||
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")
|
||||
@@ -45,7 +49,12 @@ def _run(cmd: list[str], check: bool = True) -> str:
|
||||
|
||||
def _reload() -> None:
|
||||
"""Reload firewalld so permanent changes take effect immediately."""
|
||||
_run(["sudo", "firewall-cmd", "--reload"])
|
||||
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:
|
||||
@@ -54,6 +63,11 @@ def _ensure_data_dir() -> None:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -66,15 +80,7 @@ def get_available_zones() -> list[str]:
|
||||
|
||||
|
||||
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"],
|
||||
}
|
||||
"""
|
||||
"""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
|
||||
@@ -82,7 +88,6 @@ def get_active_zones() -> dict[str, list[str]]:
|
||||
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]
|
||||
@@ -99,13 +104,7 @@ def get_active_zones() -> dict[str, list[str]]:
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
"""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():
|
||||
@@ -117,7 +116,6 @@ def get_zone_info(zone: str) -> dict[str, Any]:
|
||||
value = value.strip()
|
||||
|
||||
if not value:
|
||||
# Lines like "interfaces: " or "masquerade: " when disabled
|
||||
if key in ("masquerade", "ics"):
|
||||
info[key] = False
|
||||
else:
|
||||
@@ -138,12 +136,10 @@ def get_zone_info(zone: str) -> dict[str, Any]:
|
||||
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", [])
|
||||
@@ -177,7 +173,6 @@ def get_interfaces() -> list[str]:
|
||||
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(":")
|
||||
@@ -212,14 +207,7 @@ def get_rich_rules(zone: str) -> list[str]:
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Create a new permanent zone in firewalld."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
@@ -230,16 +218,14 @@ def create_zone(zone: str, target: str = "default") -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
|
||||
|
||||
|
||||
def delete_zone(zone: str) -> None:
|
||||
"""Delete an existing zone.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the zone does not exist or the deletion fails.
|
||||
"""
|
||||
"""Delete an existing zone."""
|
||||
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
|
||||
_reload()
|
||||
logger.info("Firewall zone '%s' deleted", zone)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -248,12 +234,7 @@ def delete_zone(zone: str) -> None:
|
||||
|
||||
|
||||
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.
|
||||
"""Assign *interfaces* to *zone*, replacing any existing assignments."""
|
||||
try:
|
||||
current = get_zone_info(zone).get("interfaces", [])
|
||||
except Exception:
|
||||
@@ -270,7 +251,6 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Add the desired set.
|
||||
for iface in interfaces:
|
||||
_run(
|
||||
[
|
||||
@@ -282,6 +262,7 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
|
||||
|
||||
|
||||
def add_zone_interface(zone: str, iface: str) -> None:
|
||||
@@ -296,6 +277,7 @@ def add_zone_interface(zone: str, iface: str) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Interface '%s' added to zone '%s'", iface, zone)
|
||||
|
||||
|
||||
def remove_zone_interface(zone: str, iface: str) -> None:
|
||||
@@ -310,6 +292,7 @@ def remove_zone_interface(zone: str, iface: str) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Interface '%s' removed from zone '%s'", iface, zone)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -319,7 +302,6 @@ def remove_zone_interface(zone: str, iface: str) -> None:
|
||||
|
||||
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(
|
||||
@@ -344,6 +326,7 @@ def set_zone_services(zone: str, services: list[str]) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Zone '%s' services set to %s", zone, services)
|
||||
|
||||
|
||||
def add_zone_service(zone: str, service: str) -> None:
|
||||
@@ -358,6 +341,7 @@ def add_zone_service(zone: str, service: str) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Service '%s' added to zone '%s'", service, zone)
|
||||
|
||||
|
||||
def remove_zone_service(zone: str, service: str) -> None:
|
||||
@@ -372,6 +356,7 @@ def remove_zone_service(zone: str, service: str) -> None:
|
||||
]
|
||||
)
|
||||
_reload()
|
||||
logger.info("Service '%s' removed from zone '%s'", service, zone)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -379,12 +364,8 @@ def remove_zone_service(zone: str, service: str) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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``.
|
||||
"""
|
||||
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
|
||||
"""Add a rich rule to *zone* and persist to declarative config."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
@@ -395,13 +376,14 @@ def add_rich_rule(zone: str, rule: str) -> None:
|
||||
]
|
||||
)
|
||||
_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*.
|
||||
|
||||
The rule string must match exactly what was added.
|
||||
"""
|
||||
"""Remove a rich rule from *zone*."""
|
||||
_run(
|
||||
[
|
||||
"sudo",
|
||||
@@ -412,6 +394,55 @@ def remove_rich_rule(zone: str, rule: str) -> None:
|
||||
]
|
||||
)
|
||||
_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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -424,6 +455,7 @@ def set_masquerade(zone: str, enable: bool) -> None:
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -437,12 +469,8 @@ def add_forward_port(
|
||||
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).
|
||||
"""
|
||||
) -> 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}"
|
||||
@@ -461,6 +489,10 @@ def add_forward_port(
|
||||
]
|
||||
)
|
||||
_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(
|
||||
@@ -470,10 +502,7 @@ def remove_forward_port(
|
||||
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.
|
||||
"""
|
||||
"""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}"
|
||||
@@ -492,6 +521,80 @@ def remove_forward_port(
|
||||
]
|
||||
)
|
||||
_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"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -500,10 +603,7 @@ def remove_forward_port(
|
||||
|
||||
|
||||
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``
|
||||
"""
|
||||
"""Parse a single forward-port specifier into a structured dict."""
|
||||
result: dict[str, Any] = {}
|
||||
for piece in raw.split("/"):
|
||||
if "=" not in piece:
|
||||
@@ -533,12 +633,7 @@ 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.
|
||||
|
||||
The dict contains all zones with their per-zone configuration, all
|
||||
rich rules, masquerade settings, forward-port rules, and the set of
|
||||
active interfaces.
|
||||
"""
|
||||
"""Return the complete current state of firewalld as a Python dict."""
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for name in get_available_zones():
|
||||
try:
|
||||
@@ -558,70 +653,43 @@ def get_state() -> dict[str, Any]:
|
||||
|
||||
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.
|
||||
"""
|
||||
"""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.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""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*.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Apply the zone configuration described in *state*."""
|
||||
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
|
||||
@@ -643,7 +711,6 @@ def restore_backup(state: dict[str, Any]) -> None:
|
||||
check=False,
|
||||
)
|
||||
|
||||
# Rich rules
|
||||
for rule in zinfo.get("rich-rules", []):
|
||||
_run(
|
||||
[
|
||||
@@ -657,6 +724,7 @@ def restore_backup(state: dict[str, Any]) -> None:
|
||||
)
|
||||
|
||||
_reload()
|
||||
logger.info("Firewall backup restored, %d zones processed", len(zones_cfg))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -688,6 +756,7 @@ def config_set(cfg: dict[str, Any]) -> None:
|
||||
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:
|
||||
@@ -713,11 +782,7 @@ def _live_target_to_config(target: str) -> str:
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
"""Compare declarative config against live firewalld state, return diff."""
|
||||
cfg = config_get()
|
||||
live_state = get_state()
|
||||
cfg_zones = cfg.get("zones", {})
|
||||
@@ -779,6 +844,38 @@ def config_pending() -> dict[str, Any]:
|
||||
}
|
||||
)
|
||||
|
||||
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] = {
|
||||
@@ -793,14 +890,7 @@ def config_pending() -> dict[str, Any]:
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Apply the declarative config to live firewalld."""
|
||||
cfg = config_get()
|
||||
cfg_zones = cfg.get("zones", {})
|
||||
|
||||
@@ -836,11 +926,48 @@ def config_apply() -> dict[str, Any]:
|
||||
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,
|
||||
@@ -875,7 +1002,9 @@ __all__ = [
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user