refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+94 -129
View File
@@ -8,23 +8,22 @@ 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
from lib.common import load_json, run, save_json
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"
DATA_DIR: Path = PROJECT_DIR / "data" / "firewall"
RULES_FILE: Path = DATA_DIR / "rules.json"
CONFIG_DIR: Path = PROJECT_DIR / "config" / "firewall"
CONFIG_FILE: Path = CONFIG_DIR / "config.json"
DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
@@ -34,35 +33,16 @@ DEFAULT_CONFIG: dict[str, Any] = {"zones": {}}
# ---------------------------------------------------------------------------
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"])
run(["firewall-cmd", "--reload"], sudo=True)
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]
@@ -75,13 +55,13 @@ def _gen_id() -> str:
def get_available_zones() -> list[str]:
"""Return the list of all built-in (available) firewalld zone names."""
output = _run(["sudo", "firewall-cmd", "--get-zones"])
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(["sudo", "firewall-cmd", "--get-active-zones"])
output = run(["firewall-cmd", "--get-active-zones"], sudo=True)
zones: dict[str, list[str]] = {}
current_zone: str | None = None
for raw_line in output.splitlines():
@@ -105,7 +85,7 @@ def get_active_zones() -> dict[str, list[str]]:
def get_zone_info(zone: str) -> dict[str, Any]:
"""Return detailed information for *zone*."""
output = _run(["sudo", "firewall-cmd", f"--zone={zone}", "--list-all"])
output = run(["firewall-cmd", f"--zone={zone}", "--list-all"], sudo=True)
info: dict[str, Any] = {"name": zone}
for line in output.splitlines():
line = line.strip()
@@ -157,19 +137,19 @@ def get_zone_info(zone: str) -> dict[str, Any]:
def get_services() -> list[str]:
"""Return the list of available service names known to firewalld."""
output = _run(["sudo", "firewall-cmd", "--get-services"])
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(["sudo", "firewall-cmd", "--get-icmptypes"])
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"])
output = run(["ip", "-o", "link", "show"])
ifaces: list[str] = []
for line in output.splitlines():
if line:
@@ -182,7 +162,7 @@ def get_interfaces() -> list[str]:
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 = run(["firewall-cmd", f"--zone={zone}", "--list-rich-rules"], sudo=True)
output = output.strip()
if not output:
return []
@@ -208,14 +188,14 @@ def get_rich_rules(zone: str) -> list[str]:
def create_zone(zone: str, target: str = "default") -> None:
"""Create a new permanent zone in firewalld."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--set-target={target}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Firewall zone '%s' created (target=%s)", zone, target)
@@ -223,7 +203,7 @@ def create_zone(zone: str, target: str = "default") -> None:
def delete_zone(zone: str) -> None:
"""Delete an existing zone."""
_run(["sudo", "firewall-cmd", f"--zone={zone}", "--delete", "--permanent"])
run(["firewall-cmd", f"--zone={zone}", "--delete", "--permanent"], sudo=True)
_reload()
logger.info("Firewall zone '%s' deleted", zone)
@@ -240,26 +220,26 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
except Exception:
current = []
for iface in current:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
],
sudo=True,
check=False,
)
for iface in interfaces:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Zone '%s' interfaces set to %s", zone, interfaces)
@@ -267,14 +247,14 @@ def set_zone_interfaces(zone: str, interfaces: list[str]) -> None:
def add_zone_interface(zone: str, iface: str) -> None:
"""Add a single interface to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Interface '%s' added to zone '%s'", iface, zone)
@@ -282,14 +262,14 @@ def add_zone_interface(zone: str, iface: str) -> None:
def remove_zone_interface(zone: str, iface: str) -> None:
"""Remove a single interface from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-interface=" + iface,
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Interface '%s' removed from zone '%s'", iface, zone)
@@ -304,26 +284,26 @@ 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(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={svc}",
"--permanent",
],
sudo=True,
check=False,
)
for svc in services:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={svc}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Zone '%s' services set to %s", zone, services)
@@ -331,14 +311,14 @@ def set_zone_services(zone: str, services: list[str]) -> None:
def add_zone_service(zone: str, service: str) -> None:
"""Add a single service to *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Service '%s' added to zone '%s'", service, zone)
@@ -346,14 +326,14 @@ def add_zone_service(zone: str, service: str) -> None:
def remove_zone_service(zone: str, service: str) -> None:
"""Remove a single service from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-service={service}",
"--permanent",
]
],
sudo=True,
)
_reload()
logger.info("Service '%s' removed from zone '%s'", service, zone)
@@ -366,14 +346,14 @@ def remove_zone_service(zone: str, service: str) -> None:
def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
"""Add a rich rule to *zone* and persist to declarative config."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--add-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_persist_rich_rule(zone, rule)
@@ -384,14 +364,14 @@ def add_rich_rule(zone: str, rule: str) -> dict[str, Any]:
def remove_rich_rule(zone: str, rule: str) -> None:
"""Remove a rich rule from *zone*."""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
"--remove-rich-rule=" + rule,
"--permanent",
]
],
sudo=True,
)
_reload()
_unpersist_rich_rule(zone, rule)
@@ -400,7 +380,7 @@ def remove_rich_rule(zone: str, rule: str) -> None:
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 = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("rich_rules", [])
@@ -408,22 +388,22 @@ def _persist_rich_rule(zone: str, rule: str) -> dict[str, Any]:
rule_id = _gen_id()
entry = {"id": rule_id, "rule": rule}
existing_rules.append(entry)
config_set(cfg)
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 = config_get()
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]
config_set(cfg)
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 = config_get()
cfg = get_config()
for r in cfg.get("zones", {}).get(zone, {}).get("rich_rules", []):
if r.get("rule") == rule:
return r
@@ -432,7 +412,7 @@ def _get_rich_rule_entry(zone: str, rule: str) -> dict[str, Any]:
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()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for r in zone_cfg.get("rich_rules", []):
@@ -453,7 +433,7 @@ def remove_rich_rule_by_id(zone: str, rule_id: str) -> None:
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"])
run(["firewall-cmd", f"--zone={zone}", action, "--permanent"], sudo=True)
_reload()
logger.info("Masquerade %s on zone '%s'", "enabled" if enable else "disabled", zone)
@@ -479,14 +459,14 @@ def add_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--add-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_persist_forward_port(zone, port, protocol, toaddr, toport)
@@ -511,14 +491,14 @@ def remove_forward_port(
else:
fwd += f"/toaddr={toaddr}" if toaddr else ""
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone}",
f"--remove-forward-port={fwd}",
"--permanent",
]
],
sudo=True,
)
_reload()
_unpersist_forward_port(zone, port, protocol)
@@ -533,7 +513,7 @@ def _persist_forward_port(
toport: int | None = None,
) -> dict[str, Any]:
"""Add a forward port to the declarative config with a generated id."""
cfg = config_get()
cfg = get_config()
cfg.setdefault("zones", {})
cfg["zones"].setdefault(zone, {})
cfg["zones"][zone].setdefault("forward_ports", [])
@@ -548,26 +528,24 @@ def _persist_forward_port(
if toport:
entry["toport"] = toport
cfg["zones"][zone]["forward_ports"].append(entry)
config_set(cfg)
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 = config_get()
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)
]
config_set(cfg)
save_config(cfg)
def _get_forward_port_entry(
zone: str, port: int, protocol: str
) -> dict[str, Any]:
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()
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
@@ -577,7 +555,7 @@ def _get_forward_port_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()
cfg = get_config()
zone_cfg = cfg.get("zones", {}).get(zone, {})
entry = None
for fp in zone_cfg.get("forward_ports", []):
@@ -585,9 +563,7 @@ def remove_forward_port_by_id(zone: str, port: int, protocol: str) -> None:
entry = fp
break
if entry is None:
raise ValueError(
f"Forward port {port}/{protocol} not found in zone '{zone}'"
)
raise ValueError(f"Forward port {port}/{protocol} not found in zone '{zone}'")
remove_forward_port(
zone,
port,
@@ -658,19 +634,15 @@ def _now_iso() -> str:
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)
save_json(RULES_FILE, state)
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
return load_json(RULES_FILE)
def restore_backup(state: dict[str, Any]) -> None:
@@ -700,26 +672,26 @@ def restore_backup(state: dict[str, Any]) -> None:
if "toport" in fp:
parts.append(f"toport={fp['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"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(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -734,28 +706,20 @@ def restore_backup(state: dict[str, Any]) -> None:
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")
save_json(CONFIG_FILE, DEFAULT_CONFIG, indent=2)
def config_get() -> dict[str, Any]:
def get_config() -> 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)
return load_json(CONFIG_FILE)
def config_set(cfg: dict[str, Any]) -> None:
def save_config(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)
save_json(CONFIG_FILE, cfg, indent=2)
logger.info("Firewall declarative config saved")
@@ -783,7 +747,7 @@ def _live_target_to_config(target: str) -> str:
def config_pending() -> dict[str, Any]:
"""Compare declarative config against live firewalld state, return diff."""
cfg = config_get()
cfg = get_config()
live_state = get_state()
cfg_zones = cfg.get("zones", {})
live_zones = live_state.get("zones", {})
@@ -844,9 +808,7 @@ def config_pending() -> dict[str, Any]:
}
)
cfg_rules = {
r.get("rule") for r in zone_cfg.get("rich_rules", [])
}
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(
@@ -891,7 +853,7 @@ def config_pending() -> dict[str, Any]:
def config_apply() -> dict[str, Any]:
"""Apply the declarative config to live firewalld."""
cfg = config_get()
cfg = get_config()
cfg_zones = cfg.get("zones", {})
save_backup()
@@ -908,14 +870,14 @@ def config_apply() -> dict[str, Any]:
desired_target = _normalize_target(zone_cfg.get("target", "DEFAULT"))
if desired_target != "default":
with suppress(RuntimeError):
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--set-target={desired_target}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -927,16 +889,20 @@ def config_apply() -> dict[str, Any]:
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)
rule_str = (
rule_entry.get("rule", "")
if isinstance(rule_entry, dict)
else str(rule_entry)
)
if rule_str:
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-rich-rule={rule_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -950,14 +916,14 @@ def config_apply() -> dict[str, Any]:
if "toport" in fp_entry:
parts.append(f"toport={fp_entry['toport']}")
fp_str = "/".join(parts)
_run(
run(
[
"sudo",
"firewall-cmd",
f"--zone={zone_name}",
f"--add-forward-port={fp_str}",
"--permanent",
],
sudo=True,
check=False,
)
@@ -981,19 +947,17 @@ __all__ = [
"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_config",
"get_icmp_blocks",
"get_interfaces",
"get_rich_rules",
@@ -1009,6 +973,7 @@ __all__ = [
"remove_zone_service",
"restore_backup",
"save_backup",
"save_config",
"set_masquerade",
"set_zone_interfaces",
"set_zone_services",