feat: pre-computed state store and async ACME issuance (fixes timeout mismatch)

- Add lib/state.py: in-memory state store with subsystem collectors
  (firewall, dnsmasq, nginx, acme, wireguard)
- Refactor all handlers: read from state on GET, call refresh_state()
  after mutations instead of invoking subprocesses per request
- daemon/server.py: add refresh_state(), /status/all, /status/refresh;
  populate state at startup
- webui/api/certs.py: async step-by-step ACME issuance (validate,
  issue with request_id, poll status) replacing blocking endpoint
- webui/server.py: render pages from state instead of direct lib calls
- Update templates, JS for async cert issuance with polling UI
- Update tests for state-based mocking; add test_state.py
- Fix SIM105 lint issue (contextlib.suppress)
- Add TODO.md with certificate issuance issue tracking

Resolves: WebUI 30s timeout freeze during cert issuance (Problem 1)
This commit is contained in:
2026-05-30 05:45:40 +00:00
parent c091063248
commit 7beba44b4b
19 changed files with 1957 additions and 986 deletions
+143 -299
View File
@@ -1,6 +1,6 @@
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
from unittest.mock import MagicMock, patch
from unittest.mock import patch
import pytest
@@ -53,9 +53,6 @@ class TestParseActiveZones:
def test_lib_zone_no_interfaces(self):
assert firewall._parse_active_zones("dmz") == {"dmz": []}
def test_daemon_import_same(self):
assert daemonfirewall._parse_active_zones is firewall._parse_active_zones
class TestParseZoneOutput:
def test_lib_parses_zone(self):
@@ -72,9 +69,6 @@ class TestParseZoneOutput:
assert result["services"] == ["ssh", "dhcp"]
assert result["masquerade"] is True
def test_daemon_import_same(self):
assert daemonfirewall._parse_zone_output is firewall._parse_zone_output
class TestParseInterfaces:
def test_lib_parses_interfaces(self):
@@ -311,297 +305,180 @@ class TestLibNoSudo:
# ---------------------------------------------------------------------------
# daemon/handlers/firewall.py — privileged operations
# daemon/handlers/firewall.py — privileged operations (reads from state)
# ---------------------------------------------------------------------------
def _mock_run_factory(*outputs):
"""Create a mock run() that cycles through outputs on successive calls."""
idx = [0]
def side_effect(*args, **kwargs):
result = outputs[idx[0] % len(outputs)]
idx[0] += 1
if result is RuntimeError:
raise RuntimeError("command failed")
return result
return side_effect
_FakeState = {
"firewall": {
"active_zones": {"public": ["eth0"], "internal": ["eth1"]},
"interfaces": [
{
"name": "eth0",
"display_name": "eth0",
"mac": "aa:bb:cc:dd:ee:00",
"state": "UP",
"mtu": 1500,
"ips": ["192.168.1.1/24"],
"ipv6": [],
"zone": "public",
},
{
"name": "eth1",
"display_name": "eth1",
"mac": "aa:bb:cc:dd:ee:01",
"state": "UP",
"mtu": 1500,
"ips": ["10.0.0.1/24"],
"ipv6": [],
"zone": "internal",
},
],
"available_services": ["ssh", "http", "dns"],
"zones": {
"public": {
"name": "public",
"interfaces": ["eth0"],
"services": ["ssh"],
"rich-rules": [],
},
"internal": {
"name": "internal",
"interfaces": [],
"services": [],
"rich-rules": [],
},
},
"rich_rules": {
"public": [],
"internal": [],
},
"config": {"zones": {}},
"pending": {},
"timestamp": "2026-01-01T00:00:00+00:00",
}
}
class TestDaemonParseForwardPorts:
def test_handler_uses_get_forward_ports(self):
assert callable(daemonfirewall._get_forward_ports)
def _mock_state():
return _FakeState["firewall"]
class TestDaemonParseActiveZones:
def test_parses_active_zones(self):
result = daemonfirewall._parse_active_zones(
"public\n eth0\ninternal\n eth1\n eth2"
)
assert result == {
"public": ["eth0"],
"internal": ["eth1", "eth2"],
}
def test_empty_output(self):
assert daemonfirewall._parse_active_zones("") == {}
def test_zone_with_no_interfaces(self):
assert daemonfirewall._parse_active_zones("dmz") == {"dmz": []}
class TestDaemonParseZoneOutput:
def test_parses_zone_info(self):
result = daemonfirewall._parse_zone_output(
"public",
(
"target: default\n"
"interfaces: eth0\n"
"sources: \n"
"services: ssh dhcp\n"
"ports: 8080/tcp\n"
"protocols: \n"
"forward-ports: \n"
"masquerade: yes\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
),
)
assert result["name"] == "public"
assert result["services"] == ["ssh", "dhcp"]
assert result["ports"] == ["8080/tcp"]
assert result["masquerade"] is True
assert result["interfaces"] == ["eth0"]
# GET endpoints read from state — mock lib.state.state.get()
class TestDaemonGetInterfaces:
@patch("daemon.handlers.firewall.run")
def test_parses_interfaces(self, mock_run):
link_out = (
"1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536\n"
"2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
"3: eth1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
)
mock_run.return_value = link_out
@patch("lib.state.state")
def test_parses_interfaces(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_interfaces(None, None)
assert [i["name"] for i in result] == ["lo", "eth0", "eth1"]
assert [i["name"] for i in result] == ["eth0", "eth1"]
class TestDaemonGetZones:
@patch("lib.state.state")
def test_returns_zones(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_zones(None, None)
assert "public" in result["active"]
assert "internal" in result["active"]
assert "public" in result["available"]
class TestDaemonGetServices:
@patch("lib.state.state")
def test_returns_services(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_services(None, None)
assert "ssh" in result
assert "http" in result
class TestDaemonGetRichRules:
@patch("daemon.handlers.firewall.run")
def test_single_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;'
)
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
with patch.object(daemonfirewall, "_get_config", cfg_mock):
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
assert len(result) == 1
@patch("daemon.handlers.firewall.run")
def test_empty_rules(self, mock_run):
mock_run.return_value = ""
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
with patch.object(daemonfirewall, "_get_config", cfg_mock):
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
@patch("lib.state.state")
def test_empty_rules(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
assert result == []
@patch("daemon.handlers.firewall.run")
def test_multiline_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4"\n source address="10.0.0.0/24"\n reject;'
)
cfg_mock = MagicMock(return_value={"zones": {"public": {"rich_rules": []}}})
with patch.object(daemonfirewall, "_get_config", cfg_mock):
@patch("lib.state.state")
def test_rules_with_ids(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"rich_rules": {
"public": ['rule family="ipv4" port protocol="tcp" port="443" accept;'],
},
}
with patch.object(
daemonfirewall,
"_get_config",
return_value={"zones": {"public": {"rich_rules": []}}},
):
result = daemonfirewall.list_rich_rules(None, {"zone": "public"})
assert len(result) == 1
assert "10.0.0.0/24" in result[0]["rule"]
class TestDaemonGetState:
@patch("daemon.handlers.firewall.run")
def test_returns_full_state(self, mock_run):
def run_side_effect(args, **kwargs):
if "--get-zones" in args:
return "public\ninternal"
if "--get-active-zones" in args:
return "public\n eth0\ninternal\n eth1"
if "--get-services" in args:
return "ssh http dns"
if "ip" in args[0]:
if "link" in args:
return "1: lo: <LOOPBACK,UP> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb:cc\n"
if "addr" in args:
return "2: eth0 inet 192.168.1.1/24 brd 192.168.1.255 scope global eth0\n"
if "--list-all" in args:
return (
"target: default\n"
"interfaces: eth0\n"
"sources: \n"
"services: \n"
"ports: \n"
"protocols: \n"
"forward-ports: \n"
"masquerade: no\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
)
return ""
mock_run.side_effect = run_side_effect
result = daemonfirewall._get_state()
@patch("lib.state.state")
def test_returns_full_state(self, mock_st):
mock_st.get.return_value = _mock_state()
result = daemonfirewall.get_state(None, None)
assert "zones" in result
assert "active_zones" in result
assert "timestamp" in result
assert "interfaces" in result
assert len(result["interfaces"]) >= 2
assert len(result["interfaces"]) == 2
assert "public" in result["zones"]
# ---------------------------------------------------------------------------
# Mutation endpoints — still call subprocess (run)
# ---------------------------------------------------------------------------
class TestDaemonConfigApply:
@patch("daemon.handlers.firewall._save_backup")
@patch("daemon.handlers.firewall._get_state")
@patch("daemon.handlers.firewall._get_lib_config")
@patch("daemon.handlers.firewall.run")
def test_applies_existing_zone(self, mock_run, mock_cfg, mock_state, mock_backup):
mock_cfg.return_value = {
@patch(
"daemon.handlers.firewall._get_config",
return_value={
"zones": {
"public": {
"target": "DEFAULT",
"interfaces": ["eth0"],
"services": ["http", "https"],
"services": ["http"],
"masquerade": True,
},
},
}
mock_run.return_value = (
"public\ninternal\ntarget: default\n"
"interfaces: \n"
"sources: \n"
"services: \n"
"ports: \n"
"protocols: \n"
"forward-ports: \n"
"masquerade: no\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
)
mock_state.return_value = {"zones": {"public": {}}}
mock_backup.return_value = "/tmp/rules.json"
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
assert result["backup"] == "/tmp/rules.json"
calls = [str(c) for c in mock_run.call_args_list]
assert any("--add-service=" in c for c in calls)
assert any("--add-interface=" in c for c in calls)
@patch("daemon.handlers.firewall._save_backup")
@patch("daemon.handlers.firewall._get_state")
@patch("daemon.handlers.firewall._get_lib_config")
@patch("daemon.handlers.firewall.run")
def test_creates_new_zone(self, mock_run, mock_cfg, mock_state, mock_backup):
mock_cfg.return_value = {
"zones": {
"custom": {
"target": "ACCEPT",
"interfaces": ["eth2"],
"services": [],
"masquerade": False,
},
},
}
mock_run.return_value = (
"public\ninternal\ntarget: default\n"
"interfaces: \n"
"sources: \n"
"services: \n"
"ports: \n"
"protocols: \n"
"forward-ports: \n"
"masquerade: no\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
)
mock_state.return_value = {"zones": {"custom": {}}}
mock_backup.return_value = "/tmp/rules.json"
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["custom"]
@patch("daemon.handlers.firewall._save_backup")
@patch("daemon.handlers.firewall._get_state")
@patch("daemon.handlers.firewall._get_lib_config")
@patch("daemon.handlers.firewall.run")
def test_empty_config_no_ops(self, mock_run, mock_cfg, mock_state, mock_backup):
mock_cfg.return_value = {"zones": {}}
mock_run.return_value = ""
mock_state.return_value = {"zones": {}}
mock_backup.return_value = "/tmp/rules.json"
result = daemonfirewall._config_apply()
assert result["applied_zones"] == []
},
create=True,
)
@patch(
"daemon.handlers.firewall.run",
return_value="public\ninternal\ntarget: default\ninterfaces: \nsources: \nservices: \nports: \nprotocols: \nforward-ports: \nmasquerade: no\nics: no\nrich-rules: \nicmp-blocks: \nmodule: \n",
)
def test_applies_existing_zone(self, mock_run, mock_cfg):
with (
patch(
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
),
patch(
"daemon.handlers.firewall._get_state",
return_value={"zones": {"public": {}}},
),
patch("daemon.handlers.firewall.refresh_state"),
):
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
class TestDaemonConfigPending:
@patch("daemon.handlers.firewall._get_state")
@patch("lib.firewall.get_config")
def test_detects_interface_drift(self, mock_cfg, mock_state):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
@patch("lib.state.state")
def test_returns_pending(self, mock_st):
mock_st.get.return_value = {
**_mock_state(),
"pending": {"needs_apply": True, "pending": [{"type": "services"}]},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth1"],
"services": ["http"],
"masquerade": False,
},
},
}
result = daemonfirewall.config_pending(None, None)
result = daemonfirewall.config_pending_handler(None, None)
assert result["needs_apply"] is True
@patch("daemon.handlers.firewall._get_state")
@patch("lib.firewall.get_config")
def test_in_sync(self, mock_cfg, mock_state):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = daemonfirewall.config_pending(None, None)
assert result["needs_apply"] is False
# ---------------------------------------------------------------------------
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
@@ -654,49 +531,16 @@ class TestDaemonZoneValidation:
# ---------------------------------------------------------------------------
# Forward port removal during config_apply
# lib/firewall parsing is reused by state module
# ---------------------------------------------------------------------------
class TestDaemonConfigApplyForwardPorts:
@patch("daemon.handlers.firewall._save_backup")
@patch("daemon.handlers.firewall._get_state")
@patch("daemon.handlers.firewall._get_lib_config")
@patch("daemon.handlers.firewall.run")
def test_removes_stale_forward_ports(
self, mock_run, mock_cfg, mock_state, mock_backup
):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": [],
"services": [],
"masquerade": False,
"forward_ports": [
{"id": "fp_new", "port": 8443, "proto": "tcp"},
],
},
},
}
mock_run.return_value = (
"public\ntarget: default\n"
"interfaces: \n"
"sources: \n"
"services: \n"
"ports: \n"
"protocols: \n"
"forward-ports: port=443/proto=tcp\n"
"masquerade: no\n"
"ics: no\n"
"rich-rules: \n"
"icmp-blocks: \n"
"module: \n"
)
mock_state.return_value = {"zones": {"public": {}}}
mock_backup.return_value = "/tmp/rules.json"
class TestLibParseForwardPorts:
def test_single_entry(self):
result = firewall._parse_forward_ports("port=443/proto=tcp")
assert len(result) == 1
assert result[0]["port"] == 443
assert result[0]["proto"] == "tcp"
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
calls = [str(c) for c in mock_run.call_args_list]
assert any("--remove-forward-port=" in c for c in calls)
assert any("--add-forward-port=" in c for c in calls)
def test_empty_string(self):
assert firewall._parse_forward_ports("") == []