Optimize firewall state collection and improve daemon shutdown

- Replace per-zone --list-all calls with single --list-all-zones in _collect_firewall
- Add _parse_all_zones_output() parser with rich rules/rich-rules normalization
- Convert daemon shutdown to async with proper runner cleanup and socket unlink
- Add TimeoutStopSec=15 to vacuum-walld.service for graceful stop
- Fix exception handling in _collect_dnsmasq
- Remove management badge from proxy path rows
This commit is contained in:
2026-06-28 00:54:01 +00:00
parent 80dd4e3272
commit 25a1943fce
7 changed files with 190 additions and 33 deletions
+14 -5
View File
@@ -532,13 +532,21 @@ def main() -> None:
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
def _on_shutdown(_sig: int) -> None: async def _shutdown() -> None:
"""Graceful shutdown: cancel poller, close runner, teardown."""
logger.info("Shutting down daemon...") logger.info("Shutting down daemon...")
_stop_polling() _stop_polling()
loop.stop() try:
await asyncio.wait_for(runner.cleanup(), timeout=5)
except asyncio.TimeoutError:
logger.warning("Runner cleanup timed out, abandoning")
if Path(socket_path).exists():
os.unlink(socket_path)
logger.info("vacuum-walld stopped")
loop.call_soon(loop.stop)
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _on_shutdown, sig) loop.add_signal_handler(sig, lambda: loop.create_task(_shutdown()))
runner = web.AppRunner(app) runner = web.AppRunner(app)
loop.run_until_complete(runner.setup()) loop.run_until_complete(runner.setup())
@@ -562,10 +570,11 @@ def main() -> None:
try: try:
loop.run_forever() loop.run_forever()
finally: finally:
loop.run_until_complete(runner.cleanup()) # _shutdown() handles cleanup when invoked via signal handler;
# this block is only reached if shutdown didn't happen cleanly
# (e.g., unexpected exit), in which case we unlink the socket.
if Path(socket_path).exists(): if Path(socket_path).exists():
os.unlink(socket_path) os.unlink(socket_path)
logger.info("vacuum-walld stopped")
if __name__ == "__main__": if __name__ == "__main__":
+48 -1
View File
@@ -66,7 +66,9 @@ def _parse_interfaces(output: str) -> list[str]:
def _parse_zone_output(zone: str, output: str) -> dict[str, Any]: def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
"""Parse ``firewall-cmd --zone=Z --list-all`` output.""" """Parse ``firewall-cmd --zone=Z --list-all`` or a zone block
from ``--list-all-zones`` output.
"""
info: dict[str, Any] = {"name": zone} info: dict[str, Any] = {"name": zone}
for line in output.splitlines(): for line in output.splitlines():
line = line.strip() line = line.strip()
@@ -76,6 +78,11 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
key = key.strip() key = key.strip()
value = value.strip() value = value.strip()
# --list-all-zones uses "rich rules" (space) while
# --zone=Z --list-all uses "rich-rules" (hyphen); normalize.
if key == "rich rules":
key = "rich-rules"
if not value: if not value:
if key in ("masquerade", "ics"): if key in ("masquerade", "ics"):
info[key] = False info[key] = False
@@ -116,6 +123,45 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
return info return info
def _parse_all_zones_output(output: str) -> dict[str, dict[str, Any]]:
"""Parse the combined ``firewall-cmd --list-all-zones`` output.
Returns a dict mapping each zone name to its parsed info dict
(same structure as ``_parse_zone_output``).
"""
zones: dict[str, dict[str, Any]] = {}
current_name: str | None = None
current_lines: list[str] = []
for raw_line in output.splitlines():
if not raw_line.strip():
continue
# Non-indented line starts a new zone block
if raw_line[0].isspace():
if current_name is not None:
current_lines.append(raw_line.strip())
else:
# Finalize previous zone
if current_name is not None and current_lines:
zones[current_name] = _parse_zone_output(
current_name, "\n".join(current_lines)
)
# Extract zone name (discard trailing parenthetical metadata)
name = raw_line.strip().split()[0]
if "(" in name:
name = name[: name.index("(")]
current_name = name
current_lines = []
# Finalize last zone
if current_name is not None and current_lines:
zones[current_name] = _parse_zone_output(
current_name, "\n".join(current_lines)
)
return zones
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers for parsing forward-port lines # Helpers for parsing forward-port lines
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -347,6 +393,7 @@ __all__ = [
"_normalize_target", "_normalize_target",
"_now_iso", "_now_iso",
"_parse_active_zones", "_parse_active_zones",
"_parse_all_zones_output",
"_parse_forward_ports", "_parse_forward_ports",
"_parse_interfaces", "_parse_interfaces",
"_parse_zone_output", "_parse_zone_output",
+6 -8
View File
@@ -15,7 +15,7 @@ from typing import Any, ClassVar
from lib.common import load_json, run, run_proc from lib.common import load_json, run, run_proc
from lib.firewall import ( from lib.firewall import (
_parse_active_zones, _parse_active_zones,
_parse_zone_output, _parse_all_zones_output,
) )
from lib.firewall import ( from lib.firewall import (
config_pending as _config_pending, config_pending as _config_pending,
@@ -401,7 +401,6 @@ def _collect_firewall() -> dict[str, Any]:
Dict containing firewall zones, interfaces, rules, config, and Dict containing firewall zones, interfaces, rules, config, and
pending changes. pending changes.
""" """
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True) active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
active = _parse_active_zones(active_raw) active = _parse_active_zones(active_raw)
services = run(["firewall-cmd", "--get-services"], sudo=True).split() or [] services = run(["firewall-cmd", "--get-services"], sudo=True).split() or []
@@ -458,14 +457,13 @@ def _collect_firewall() -> dict[str, Any]:
ifaces = list(iface_map.values()) ifaces = list(iface_map.values())
# Collect all zones in a single call (replaces per-zone loop)
zones: dict[str, dict[str, Any]] = {} zones: dict[str, dict[str, Any]] = {}
for zn in zone_names:
try: try:
zones[zn] = _parse_zone_output( all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True) zones = _parse_all_zones_output(all_zones_raw)
)
except Exception: except Exception:
continue pass
# Load config # Load config
fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json" fw_config_path = PROJECT_DIR / "config" / "firewall" / "config.json"
@@ -584,7 +582,7 @@ def _collect_dnsmasq() -> dict[str, Any]:
"interface": parts[4] if len(parts) > 4 else "", "interface": parts[4] if len(parts) > 4 else "",
} }
) )
except RuntimeError: except Exception:
pass pass
# Check config file on disk # Check config file on disk
+1
View File
@@ -12,6 +12,7 @@ WorkingDirectory={{ PROJECT_DIR }}
ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
TimeoutStopSec=15
Environment=PATH=/usr/local/bin:/usr/bin Environment=PATH=/usr/local/bin:/usr/bin
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
+86
View File
@@ -542,3 +542,89 @@ class TestLibParseForwardPorts:
def test_empty_string(self): def test_empty_string(self):
assert firewall._parse_forward_ports("") == [] assert firewall._parse_forward_ports("") == []
# ---------------------------------------------------------------------------
# lib/firewall.py — parse all zones output (--list-all-zones)
# ---------------------------------------------------------------------------
class TestParseAllZonesOutput:
def test_parses_single_zone(self):
result = firewall._parse_all_zones_output(
"public\n"
" target: default\n"
" interfaces: eth0\n"
" services: ssh http\n"
" masquerade: yes\n"
" rich rules: \n"
)
assert "public" in result
assert result["public"]["name"] == "public"
assert result["public"]["interfaces"] == ["eth0"]
assert result["public"]["services"] == ["ssh", "http"]
assert result["public"]["masquerade"] is True
assert result["public"]["rich-rules"] == []
def test_parses_multiple_zones(self):
result = firewall._parse_all_zones_output(
"public (default, active)\n"
" target: default\n"
" interfaces: eth0\n"
" services: ssh\n"
" masquerade: no\n"
" rich rules: \n"
"internal (active)\n"
" target: ACCEPT\n"
" interfaces: eth1\n"
" services: dhcp\n"
" masquerade: no\n"
" rich rules: \n"
"trusted\n"
" target: ACCEPT\n"
" interfaces: \n"
" services: \n"
" masquerade: no\n"
" rich rules: \n"
)
assert set(result.keys()) == {"public", "internal", "trusted"}
assert result["public"]["interfaces"] == ["eth0"]
assert result["internal"]["target"] == "ACCEPT"
assert result["trusted"]["services"] == []
def test_empty_output(self):
assert firewall._parse_all_zones_output("") == {}
assert firewall._parse_all_zones_output("\n \n") == {}
def test_handles_blank_lines_between_zones(self):
result = firewall._parse_all_zones_output(
"public\n"
" target: default\n"
" interfaces: eth0\n"
" rich rules: \n"
"\n"
"internal\n"
" target: ACCEPT\n"
" interfaces: eth1\n"
" rich rules: \n"
)
assert "public" in result
assert "internal" in result
assert result["public"]["interfaces"] == ["eth0"]
assert result["internal"]["interfaces"] == ["eth1"]
def test_all_default_fields_present(self):
result = firewall._parse_all_zones_output(
"dmz\n"
" target: default\n"
" interfaces: \n"
" services: \n"
" rich rules: \n"
)
zone = result["dmz"]
for field in (
"interfaces", "sources", "services", "ports", "protocols",
"forward-ports", "masquerade", "ics", "icmp-blocks", "module",
"target", "rich-rules",
):
assert field in zone, f"Missing field: {field}"
+32 -14
View File
@@ -35,8 +35,6 @@ class TestCollectAll:
from lib.state import _collect_firewall from lib.state import _collect_firewall
def run_side(args, **kwargs): def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args: if "--get-active-zones" in args:
return "public\n eth0" return "public\n eth0"
if "--get-services" in args: if "--get-services" in args:
@@ -45,9 +43,18 @@ class TestCollectAll:
if "link" in args: if "link" in args:
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n" return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
return "" return ""
if "--list-all" in args: if "--list-all-zones" in args:
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n" return (
return "" "public\n"
" target: default\n"
" interfaces: eth0\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
)
mock_run.side_effect = run_side mock_run.side_effect = run_side
result = _collect_firewall() result = _collect_firewall()
@@ -62,10 +69,8 @@ class TestCollectAll:
from lib.state import _collect_firewall from lib.state import _collect_firewall
def run_side(args, **kwargs): def run_side(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args: if "--get-active-zones" in args:
return "public\n eth0\ninternal eth0.100" return "public\n eth0\ninternal\n eth0.100"
if "--get-services" in args: if "--get-services" in args:
return "ssh http" return "ssh http"
if "ip" in args[0]: if "ip" in args[0]:
@@ -81,14 +86,27 @@ class TestCollectAll:
"3: eth0.100@if100 inet 10.0.0.1/24\n" "3: eth0.100@if100 inet 10.0.0.1/24\n"
) )
return "" return ""
if "--list-all" in args: if "--list-all-zones" in args:
return ( return (
"target: default\ninterfaces: eth0\nsources: " "public\n"
"services: \nports: \nprotocols: \nforward-ports: " " target: default\n"
"masquerade: no\nics: no\nrich-rules: " " interfaces: eth0\n"
"icmp-blocks: \nmodule: \n" " services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
"internal\n"
" target: ACCEPT\n"
" interfaces: eth0.100\n"
" services: \n"
" ports: \n"
" protocols: \n"
" forward-ports: \n"
" masquerade: no\n"
" rich rules: \n"
) )
return ""
mock_run.side_effect = run_side mock_run.side_effect = run_side
result = _collect_firewall() result = _collect_firewall()
+1 -3
View File
@@ -203,9 +203,7 @@ function pathRow(d, certs, domainPaths, state) {
const multiPath = (domainPaths || []).length > 1; const multiPath = (domainPaths || []).length > 1;
let actions; let actions;
if (isMgmt) { if (isWs) {
actions = Badge({ text: 'mgmt', variant: 'warning' });
} else if (isWs) {
actions = ActionButton({ actions = ActionButton({
url: '/api/proxy/domains/' + enc(d.domain), url: '/api/proxy/domains/' + enc(d.domain),
method: 'PUT', method: 'PUT',