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()
|
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
@@ -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",
|
||||||
|
|||||||
+8
-10
@@ -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:
|
all_zones_raw = run(["firewall-cmd", "--list-all-zones"], sudo=True)
|
||||||
zones[zn] = _parse_zone_output(
|
zones = _parse_all_zones_output(all_zones_raw)
|
||||||
zn, run(["firewall-cmd", f"--zone={zn}", "--list-all"], sudo=True)
|
except Exception:
|
||||||
)
|
pass
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user