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:
2026-05-17 01:15:52 +00:00
parent 0e7090a2cb
commit 37039351be
26 changed files with 1737 additions and 848 deletions
+30 -3
View File
@@ -6,6 +6,7 @@ static leases, and custom DNS records through sudo.
"""
import json
import logging
import os
import subprocess
from copy import deepcopy
@@ -15,6 +16,8 @@ from typing import Any
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "dnsmasq"
DATA_DIR = PROJECT_DIR / "data" / "dnsmasq"
@@ -101,6 +104,7 @@ def save_config(cfg: dict) -> None:
_ensure_dirs()
merged = _deep_merge(deepcopy(DEFAULT_CFG), cfg)
_save_json(CONFIG_PATH, merged)
logger.info("dnsmasq config saved")
def apply_config() -> None:
@@ -118,6 +122,7 @@ def apply_config() -> None:
check=True,
)
_sudo("systemctl", "reload", "dnsmasq")
logger.info("dnsmasq config written and reloaded")
# ───────── config generation ─────────────────────────────────────────
@@ -187,6 +192,23 @@ def set_dhcp_range(
ranges.append(entry)
save_config(cfg)
logger.info("DHCP range set for interface '%s': %s-%s", iface, start, end)
def remove_dhcp_range(iface: str, start: str, end: str) -> None:
"""Remove a DHCP range by interface + IP range."""
cfg = get_config()
cfg["dhcp"]["ranges"] = [
r
for r in cfg["dhcp"]["ranges"]
if not (
r.get("interface") == iface
and r.get("start") == start
and r.get("end") == end
)
]
save_config(cfg)
logger.info("DHCP range removed for interface '%s': %s-%s", iface, start, end)
def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
@@ -200,6 +222,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
if hostname:
leases[i]["hostname"] = hostname
save_config(cfg)
logger.info("Static DHCP lease updated: %s -> %s", mac, ip)
return
entry: dict[str, Any] = {"mac": mac, "ip": ip}
@@ -207,6 +230,7 @@ def add_static_lease(mac: str, ip: str, hostname: str | None = None) -> None:
entry["hostname"] = hostname
leases.append(entry)
save_config(cfg)
logger.info("Static DHCP lease added: %s -> %s", mac, ip)
def remove_static_lease(mac: str) -> None:
@@ -218,6 +242,7 @@ def remove_static_lease(mac: str) -> None:
if lease["mac"].lower() != mac.lower()
]
save_config(cfg)
logger.info("Static DHCP lease removed for MAC %s", mac)
# ───────── dns record management ─────────────────────────────────────
@@ -234,6 +259,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
if hostname:
records[i]["hostname"] = hostname
save_config(cfg)
logger.info("DNS record updated: %s -> %s", name, address)
return
entry: dict[str, Any] = {"name": name, "address": address}
@@ -241,6 +267,7 @@ def add_dns_record(name: str, address: str, hostname: str | None = None) -> None
entry["hostname"] = hostname
records.append(entry)
save_config(cfg)
logger.info("DNS record added: %s -> %s", name, address)
def remove_dns_record(name: str) -> None:
@@ -250,6 +277,7 @@ def remove_dns_record(name: str) -> None:
r for r in cfg["dns"]["custom_records"] if r["name"] != name
]
save_config(cfg)
logger.info("DNS record removed: %s", name)
# ───────── lease table ───────────────────────────────────────────────
@@ -299,6 +327,7 @@ def set_upstreams(servers: list[str]) -> None:
cfg = get_config()
cfg["dns"]["upstreams"] = list(servers)
save_config(cfg)
logger.info("DNS upstreams set to %s", servers)
def set_domain(domain: str | None) -> None:
@@ -306,6 +335,7 @@ def set_domain(domain: str | None) -> None:
cfg = get_config()
cfg["dns"]["domain"] = domain if domain else None
save_config(cfg)
logger.info("DNS domain set to '%s'", domain)
# ───────── status / info ─────────────────────────────────────────────
@@ -315,7 +345,6 @@ def get_status() -> dict:
"""Return service status, config summary, and current lease count."""
cfg = get_config()
# dnsmasq process check
try:
proc = subprocess.run(
["sudo", "systemctl", "is-active", "dnsmasq"],
@@ -326,7 +355,6 @@ def get_status() -> dict:
except Exception:
active = False
# config on disk
conf_exists = os.path.isfile(DNSMASQ_CONF)
if conf_exists:
try:
@@ -337,7 +365,6 @@ def get_status() -> dict:
else:
conf_on_disk = ""
# current expected config
expected = generate_conf(cfg)
leases = get_lease_table()
+248 -119
View File
@@ -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",
+81
View File
@@ -0,0 +1,81 @@
"""
logging - Centralized logging configuration for Vacuum Wall.
Call :func:`setup_logging` once at application startup. All other
modules obtain a logger via ``logging.getLogger(__name__)``.
Output:
* **stderr** (StreamHandler) - captured by systemd journald
* **data/logs/vacuum-wall.log** (RotatingFileHandler) - persisted for
viewing via the WebUI ``/logs`` page.
"""
import logging
import os
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
PROJECT_DIR = Path(__file__).resolve().parent.parent
_LOG_DIR = PROJECT_DIR / "data" / "logs"
_LOG_FILE = _LOG_DIR / "vacuum-wall.log"
_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
_BACKUP_COUNT = 3
_LOG_FMT = "[%(asctime)s] %(levelname)-8s %(name)s %(message)s"
_DATE_FMT = "%Y-%m-%d %H:%M:%S"
_initialized = False
def setup_logging(level: str | None = None) -> None:
"""Configure and enable root-level logging for the application.
Safe to call multiple times; subsequent calls are no-ops.
Args:
level: Override log level string (e.g. ``"DEBUG"``). If ``None``,
reads ``VACUUM_WALL_LOG_LEVEL`` from the environment, defaulting
to ``"INFO"``.
"""
global _initialized
if _initialized:
return
_initialized = True
if level is None:
level = os.environ.get("VACUUM_WALL_LOG_LEVEL", "INFO").upper()
valid_levels = {
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
numeric = valid_levels.get(level, logging.INFO)
root = logging.getLogger()
root.setLevel(numeric)
fmt = logging.Formatter(_LOG_FMT, datefmt=_DATE_FMT)
# stderr handler — feeds systemd journal
sh = logging.StreamHandler(sys.stderr)
sh.setFormatter(fmt)
root.addHandler(sh)
# rotating file handler
_LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = RotatingFileHandler(
str(_LOG_FILE),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
)
fh.setFormatter(fmt)
root.addHandler(fh)
# Silence noisy third-party loggers in production
for name in ("werkzeug", "urllib3"):
logging.getLogger(name).setLevel(logging.WARNING)
+21 -7
View File
@@ -6,12 +6,15 @@ bootstrap, basic-auth htpasswd files, and nginx reload cycles.
"""
import json
import logging
import os
import subprocess
from pathlib import Path
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_DIR = PROJECT_DIR / "config" / "nginx"
DATA_DIR = PROJECT_DIR / "data" / "nginx"
@@ -141,6 +144,13 @@ def add_domain(
entry["headers"] = extra_headers
cfg["domains"][domain] = entry
save_config(cfg)
logger.info(
"Proxy domain '%s' added -> %s:%d (%s)",
domain,
backend_host,
backend_port,
backend_proto,
)
def remove_domain(domain) -> None:
@@ -150,6 +160,7 @@ def remove_domain(domain) -> None:
site = SITES_DIR / f"{domain}.conf"
if site.exists():
site.unlink()
logger.info("Proxy domain '%s' removed", domain)
def update_domain(domain, **kwargs) -> None:
@@ -163,6 +174,7 @@ def update_domain(domain, **kwargs) -> None:
else:
entry[key] = val
save_config(cfg)
logger.info("Proxy domain '%s' updated: %s", domain, list(kwargs.keys()))
# ------------------------------------------------------------------
@@ -240,6 +252,8 @@ def write_all_sites() -> None:
if old.suffix == ".conf" and old.name not in written:
old.unlink()
logger.info("All nginx site configs written (%d sites)", len(written))
def write_include_file() -> None:
tmpl = ENV.get_template("nginx/include.conf")
@@ -282,6 +296,10 @@ def test_config() -> tuple[bool, str]:
output = (result.stderr or result.stdout or "").strip()
if not output and ok:
output = "nginx configuration test passed"
if ok:
logger.info("nginx config test passed")
else:
logger.error("nginx config test failed: %s", output)
return ok, output
@@ -293,6 +311,7 @@ def apply() -> None:
if not ok:
raise RuntimeError(f"nginx config test failed: {msg}")
_run(["sudo", "nginx", "-s", "reload"])
logger.info("nginx configuration applied and reloaded")
# ------------------------------------------------------------------
@@ -321,6 +340,7 @@ def set_management_proxy(
save_config(cfg)
if auth_user and auth_pass:
write_htpasswd(auth_user, auth_pass)
logger.info("Management proxy set to '%s'", domain)
# ------------------------------------------------------------------
@@ -329,13 +349,7 @@ def set_management_proxy(
def write_htpasswd(user, password) -> None:
"""
Append (or create) an htpasswd entry for *user*.
Uses passlib's apache_passwd hash so the file remains portable.
If passlib is unavailable falls back to Python's built-in crypt.
If the user already exists the line is replaced in-place.
"""
"""Append (or create) an htpasswd entry for *user*."""
_ensure_dirs()
hashed = _hash_password(password)
existing = {}
+26 -95
View File
@@ -6,6 +6,7 @@ the WireGuard tunnel interface.
"""
import json
import logging
import os
import subprocess
from datetime import UTC, datetime
@@ -13,6 +14,8 @@ from pathlib import Path
from jinja2 import Environment, FileSystemLoader
logger = logging.getLogger(__name__)
PROJECT_DIR = Path(__file__).resolve().parent.parent
CONFIG_PATH = str(PROJECT_DIR / "config" / "wireguard" / "config.json")
WG_CONF_PATH = "/etc/wireguard/wg0.conf"
@@ -65,15 +68,10 @@ def _default_config() -> dict:
def get_config() -> dict:
"""Load the current WireGuard configuration from the JSON store.
Returns the full config dict. If the file does not exist or is
unreadable, returns the default (empty) config skeleton.
"""
"""Load the current WireGuard configuration from the JSON store."""
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
# Backfill keys that might be missing from older snapshots.
defaults = _default_config()
cfg.setdefault("interface", defaults["interface"])
cfg["interface"].setdefault("name", defaults["interface"]["name"])
@@ -90,11 +88,7 @@ def get_config() -> dict:
def save_config(cfg: dict) -> None:
"""Persist *cfg* to the JSON store atomically.
Writes to a temporary file in the same directory and then renames
to avoid partial reads on crash.
"""
"""Persist *cfg* to the JSON store atomically."""
_ensure_dir(CONFIG_PATH)
tmp = CONFIG_PATH + ".tmp"
with open(tmp, "w") as f:
@@ -107,11 +101,7 @@ def save_config(cfg: dict) -> None:
def generate_keypair() -> tuple[str, str]:
"""Generate a WireGuard private/public key pair using ``wg`` CLI.
Returns:
``(private_key, public_key)`` as two 43-character base64 strings.
"""
"""Generate a WireGuard private/public key pair using ``wg`` CLI."""
res = _run([WG_BIN, "genkey"])
private_key = res.stdout.strip()
res2 = _run([WG_BIN, "pubkey"], input=private_key)
@@ -139,7 +129,7 @@ def apply() -> None:
"""Write the current config to disk and bring the tunnel up with wg-quick."""
cfg = get_config()
conf_text = generate_conf(cfg)
save_config(cfg) # ensure latest state persisted
save_config(cfg)
local_dir = PROJECT_DIR / "data" / "wireguard"
local_dir.mkdir(parents=True, exist_ok=True)
@@ -152,6 +142,7 @@ def apply() -> None:
local_tmp.unlink(missing_ok=True)
_run([WG_QUICK_BIN, "up", cfg["interface"]["name"]])
logger.info("WireGuard tunnel '%s' brought up", cfg["interface"]["name"])
def down() -> None:
@@ -159,19 +150,14 @@ def down() -> None:
cfg = get_config()
name = cfg["interface"]["name"]
_run([WG_QUICK_BIN, "down", name])
logger.info("WireGuard tunnel '%s' brought down", name)
# --- Status ---
def status() -> dict:
"""Query the live tunnel state via ``wg show``.
Returns a dict with keys:
- ``up`` (bool) - whether the interface is currently up.
- ``interface`` (dict) - name, public key, listen port, fwmark.
- ``peers`` (list[dict]) - per-peer status from ``wg show wg0``.
"""
"""Query the live tunnel state via ``wg show``."""
cfg = get_config()
name = cfg["interface"]["name"]
result = {
@@ -189,17 +175,6 @@ def status() -> dict:
except Exception:
return result
# Parse the wg show output.
# Format (multi-section, separated by blank lines or interleaved):
# interface:
# public key: ...
# listening port: ...
# peer: <key>
# endpoint: ...
# allowed ips: ...
# latest handshake: ...
# transfer: ...
# persistent-keepalive: ...
current_peer = None
peers: list[dict] = []
@@ -287,24 +262,7 @@ def add_peer(
persistent_keepalive: int | None = None,
preshared_key: str | None = None,
) -> dict:
"""Add (or update) a peer in the configuration.
If the peer has no public key yet, one will be generated
together with a matching private key (useful for client provi-
sioning). The returned dict mirrors the stored peer record
with an additional ``private_key`` field so the caller can
distribute the client credentials.
Args:
name: Human-readable identifier (dict key in config).
endpoint: e.g. ``203.0.113.1:51820``.
allowed_ips: CIDR list, e.g. ``["0.0.0.0/0"]``.
persistent_keepalive: Interval in seconds (or ``None``).
preshared_key: Optional PSK (base64 string).
Returns:
The peer dict as stored, plus ``private_key`` for client use.
"""
"""Add (or update) a peer in the configuration."""
cfg = get_config()
peers = cfg.setdefault("peers", {})
allowed_ips = allowed_ips or []
@@ -316,22 +274,21 @@ def add_peer(
peer["persistent_keepalive"] = persistent_keepalive
if preshared_key is not None:
peer["preshared_key"] = preshared_key
logger.info("WireGuard peer '%s' updated", name)
else:
# Generate a key pair for the new peer.
priv, pub = generate_keypair()
peer = {
"public_key": pub,
"private_key": priv, # stored so we can hand it to the client
"private_key": priv,
"endpoint": endpoint,
"allowed_ips": allowed_ips,
"persistent_keepalive": persistent_keepalive,
"preshared_key": preshared_key,
}
peers[name] = peer
logger.info("WireGuard peer '%s' added (pubkey=%s...)", name, pub[:16])
save_config(cfg)
# Return a copy that includes the private key (safe — used for provisioning).
peer_out = dict(peer)
return peer_out
@@ -341,33 +298,23 @@ def remove_peer(name: str) -> None:
cfg = get_config()
cfg.setdefault("peers", {}).pop(name, None)
save_config(cfg)
logger.info("WireGuard peer '%s' removed", name)
def get_peers() -> list[dict]:
"""List all configured peers (from the JSON store, *not* live).
Returns a list of dicts. Each dict includes ``name`` and all
stored fields **except** ``private_key`` (not exposed here).
"""
"""List all configured peers (from the JSON store, *not* live)."""
cfg = get_config()
peers = []
for name, info in cfg.get("peers", {}).items():
entry = dict(info)
entry["name"] = name
# Strip private key from the public listing.
entry.pop("private_key", None)
peers.append(entry)
return peers
def get_peer_status() -> list[dict]:
"""Return live peer status from ``wg show``.
Each element contains:
- ``public_key``, ``endpoint``, ``allowed_ips``,
``latest_handshake``, ``transfer_received``,
``transfer_sent``, ``persistent_keepalive``.
"""
"""Return live peer status from ``wg show``."""
st = status()
return st.get("peers", [])
@@ -401,7 +348,7 @@ def generate_client_conf(
client_addr = f"{prefix_base}.{peer_index}/{prefix}"
tmpl = ENV.get_template("wireguard-client.conf")
return tmpl.render(
conf = tmpl.render(
timestamp=datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
peer_name=peer_name,
client_priv=client_priv,
@@ -412,29 +359,25 @@ def generate_client_conf(
preshared_key=peer.get("preshared_key"),
persistent_keepalive=peer.get("persistent_keepalive"),
)
logger.info("Client config generated for peer '%s'", peer_name)
return conf
# --- Interface-level setters ---
def set_listen_port(port: int) -> None:
"""Update the server listen port in the stored configuration.
Does **not** hot-reload; call :func:`apply` afterwards to
activate the change.
"""
"""Update the server listen port in the stored configuration."""
if not (1 <= port <= 65535):
raise ValueError("Listen port must be in range 1..65535")
cfg = get_config()
cfg["interface"]["listen_port"] = port
save_config(cfg)
logger.info("WireGuard listen port set to %d", port)
def set_post_up(cmd: str | None) -> None:
"""Set (or clear) the PostUp hook command.
The command is passed verbatim to the generated wg0.conf.
"""
"""Set (or clear) the PostUp hook command."""
cfg = get_config()
cfg["interface"]["post_up"] = cmd
save_config(cfg)
@@ -451,25 +394,17 @@ def set_post_down(cmd: str | None) -> None:
def initialize() -> dict:
"""Perform first-time WireGuard setup.
Generates a fresh server key pair, writes the initial config
to disk, and returns the full config dict.
Call this once at appliance bootstrapping time. It will
**not** overwrite an existing config that already has a
non-empty private key.
"""
"""Perform first-time WireGuard setup."""
cfg = get_config()
if cfg["interface"].get("private_key"):
# Already initialised — return existing config.
return cfg
priv, pub = generate_keypair()
cfg["interface"]["private_key"] = priv
cfg["interface"]["public_key"] = pub
save_config(cfg)
logger.info("WireGuard initialised (pubkey=%s...)", pub[:16])
return cfg
@@ -477,11 +412,7 @@ def initialize() -> dict:
def _parse_wg_show(output: str) -> dict:
"""Internal parser for ``wg show`` multiline output.
Returns a dict keyed by peer public key with parsed values.
Used internally; ``status()`` is the public interface.
"""
"""Internal parser for ``wg show`` multiline output."""
peers: dict = {}
current = None