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:
+14
-5
@@ -532,13 +532,21 @@ def main() -> None:
|
||||
|
||||
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...")
|
||||
_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):
|
||||
loop.add_signal_handler(sig, _on_shutdown, sig)
|
||||
loop.add_signal_handler(sig, lambda: loop.create_task(_shutdown()))
|
||||
|
||||
runner = web.AppRunner(app)
|
||||
loop.run_until_complete(runner.setup())
|
||||
@@ -562,10 +570,11 @@ def main() -> None:
|
||||
try:
|
||||
loop.run_forever()
|
||||
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():
|
||||
os.unlink(socket_path)
|
||||
logger.info("vacuum-walld stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+48
-1
@@ -66,7 +66,9 @@ def _parse_interfaces(output: str) -> list[str]:
|
||||
|
||||
|
||||
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}
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
@@ -76,6 +78,11 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
|
||||
key = key.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 key in ("masquerade", "ics"):
|
||||
info[key] = False
|
||||
@@ -116,6 +123,45 @@ def _parse_zone_output(zone: str, output: str) -> dict[str, Any]:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -347,6 +393,7 @@ __all__ = [
|
||||
"_normalize_target",
|
||||
"_now_iso",
|
||||
"_parse_active_zones",
|
||||
"_parse_all_zones_output",
|
||||
"_parse_forward_ports",
|
||||
"_parse_interfaces",
|
||||
"_parse_zone_output",
|
||||
|
||||
+6
-8
@@ -15,7 +15,7 @@ from typing import Any, ClassVar
|
||||
from lib.common import load_json, run, run_proc
|
||||
from lib.firewall import (
|
||||
_parse_active_zones,
|
||||
_parse_zone_output,
|
||||
_parse_all_zones_output,
|
||||
)
|
||||
from lib.firewall import (
|
||||
config_pending as _config_pending,
|
||||
@@ -401,7 +401,6 @@ def _collect_firewall() -> dict[str, Any]:
|
||||
Dict containing firewall zones, interfaces, rules, config, and
|
||||
pending changes.
|
||||
"""
|
||||
zone_names = run(["firewall-cmd", "--get-zones"], sudo=True).split()
|
||||
active_raw = run(["firewall-cmd", "--get-active-zones"], sudo=True)
|
||||
active = _parse_active_zones(active_raw)
|
||||
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())
|
||||
|
||||
# Collect all zones in a single call (replaces per-zone loop)
|
||||
zones: dict[str, dict[str, Any]] = {}
|
||||
for zn in zone_names:
|
||||
try:
|
||||
zones[zn] = _parse_zone_output(
|
||||
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
|
||||
)
|
||||
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||
zones = _parse_all_zones_output(all_zones_raw)
|
||||
except Exception:
|
||||
continue
|
||||
pass
|
||||
|
||||
# Load config
|
||||
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 "",
|
||||
}
|
||||
)
|
||||
except RuntimeError:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check config file on disk
|
||||
|
||||
@@ -12,6 +12,7 @@ WorkingDirectory={{ PROJECT_DIR }}
|
||||
ExecStart={{ PROJECT_DIR }}/.venv/bin/python -m daemon
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStopSec=15
|
||||
Environment=PATH=/usr/local/bin:/usr/bin
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Environment=ACME_HOME={{ PROJECT_DIR }}/data/acme
|
||||
|
||||
@@ -542,3 +542,89 @@ class TestLibParseForwardPorts:
|
||||
|
||||
def test_empty_string(self):
|
||||
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
@@ -35,8 +35,6 @@ class TestCollectAll:
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
if "--get-active-zones" in args:
|
||||
return "public\n eth0"
|
||||
if "--get-services" in args:
|
||||
@@ -45,9 +43,18 @@ class TestCollectAll:
|
||||
if "link" in args:
|
||||
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
||||
return ""
|
||||
if "--list-all" in args:
|
||||
return "target: default\ninterfaces: eth0\nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n"
|
||||
return ""
|
||||
if "--list-all-zones" in args:
|
||||
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
|
||||
result = _collect_firewall()
|
||||
@@ -62,10 +69,8 @@ class TestCollectAll:
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-zones" in args:
|
||||
return "public\ninternal"
|
||||
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:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
@@ -81,14 +86,27 @@ class TestCollectAll:
|
||||
"3: eth0.100@if100 inet 10.0.0.1/24\n"
|
||||
)
|
||||
return ""
|
||||
if "--list-all" in args:
|
||||
if "--list-all-zones" in args:
|
||||
return (
|
||||
"target: default\ninterfaces: eth0\nsources: "
|
||||
"services: \nports: \nprotocols: \nforward-ports: "
|
||||
"masquerade: no\nics: no\nrich-rules: "
|
||||
"icmp-blocks: \nmodule: \n"
|
||||
"public\n"
|
||||
" target: default\n"
|
||||
" interfaces: eth0\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
|
||||
result = _collect_firewall()
|
||||
|
||||
@@ -203,9 +203,7 @@ function pathRow(d, certs, domainPaths, state) {
|
||||
const multiPath = (domainPaths || []).length > 1;
|
||||
|
||||
let actions;
|
||||
if (isMgmt) {
|
||||
actions = Badge({ text: 'mgmt', variant: 'warning' });
|
||||
} else if (isWs) {
|
||||
if (isWs) {
|
||||
actions = ActionButton({
|
||||
url: '/api/proxy/domains/' + enc(d.domain),
|
||||
method: 'PUT',
|
||||
|
||||
Reference in New Issue
Block a user