refactor: introduce two-user daemon architecture with socket-based communication

- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
This commit is contained in:
2026-05-27 23:38:23 +00:00
parent 5ac69dfa7e
commit 200e078bc5
39 changed files with 4671 additions and 1810 deletions
+494 -236
View File
@@ -1,17 +1,26 @@
from datetime import datetime
from unittest.mock import patch
"""Tests for lib/firewall.py (pure logic) and daemon/handlers/firewall.py (privilege boundary)."""
from unittest.mock import MagicMock, patch
import pytest
from daemon.handlers import firewall as daemonfirewall
from daemon.server import NotFoundError
from lib import firewall
# ---------------------------------------------------------------------------
# lib/firewall.py — pure parsing (no sudo)
# ---------------------------------------------------------------------------
class TestParseForwardPorts:
def test_single_entry(self):
def test_lib_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"
def test_multiple_entries(self):
def test_lib_multiple_entries(self):
result = firewall._parse_forward_ports(
"port=443/proto=tcp port=80/proto=tcp/toaddr=10.0.0.1/toport=8080"
)
@@ -21,144 +30,59 @@ class TestParseForwardPorts:
assert result[1]["toaddr"] == "10.0.0.1"
assert result[1]["toport"] == 8080
def test_empty_string(self):
def test_lib_empty_string(self):
assert firewall._parse_forward_ports("") == []
def test_daemon_no_redundant_import(self):
assert not hasattr(daemonfirewall, "_parse_forward_ports")
class TestGetActiveZones:
@patch("lib.firewall.run")
def test_parses_active_zones(self, mock_run):
mock_run.return_value = "public\n eth0\ninternal\n eth1\n eth2"
result = firewall.get_active_zones()
class TestParseActiveZones:
def test_lib_parses_zones(self):
result = firewall._parse_active_zones(
"public\n eth0\ninternal\n eth1\n eth2"
)
assert result == {
"public": ["eth0"],
"internal": ["eth1", "eth2"],
}
@patch("lib.firewall.run")
def test_empty_output(self, mock_run):
mock_run.return_value = ""
result = firewall.get_active_zones()
assert result == {}
def test_lib_empty_output(self):
assert firewall._parse_active_zones("") == {}
@patch("lib.firewall.run")
def test_zone_with_no_interfaces(self, mock_run):
mock_run.return_value = "dmz"
result = firewall.get_active_zones()
assert result == {"dmz": []}
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 TestGetZoneInfo:
@patch("lib.firewall.run")
def test_parses_zone_info(self, mock_run):
mock_run.return_value = (
"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"
class TestParseZoneOutput:
def test_lib_parses_zone(self):
result = firewall._parse_zone_output(
"public",
(
"target: default\n"
"interfaces: eth0\n"
"services: ssh dhcp\n"
"masquerade: yes\n"
),
)
result = firewall.get_zone_info("public")
assert result["name"] == "public"
assert result["services"] == ["ssh", "dhcp"]
assert result["ports"] == ["8080/tcp"]
assert result["masquerade"] is True
assert result["interfaces"] == ["eth0"]
assert result["sources"] == []
assert result["rich-rules"] == []
def test_daemon_import_same(self):
assert daemonfirewall._parse_zone_output is firewall._parse_zone_output
class TestGetInterfaces:
@patch("lib.firewall.run")
def test_parses_interfaces(self, mock_run):
mock_run.return_value = (
class TestParseInterfaces:
def test_lib_parses_interfaces(self):
result = firewall._parse_interfaces(
"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"
)
result = firewall.get_interfaces()
assert result == ["lo", "eth0", "eth1"]
class TestGetRichRules:
@patch("lib.firewall.run")
def test_single_rule(self, mock_run):
mock_run.return_value = (
'rule family="ipv4" port protocol="tcp" port="443" accept;'
)
result = firewall.get_rich_rules("public")
assert len(result) == 1
@patch("lib.firewall.run")
def test_empty_rules(self, mock_run):
mock_run.return_value = ""
result = firewall.get_rich_rules("public")
assert result == []
@patch("lib.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;'
)
result = firewall.get_rich_rules("public")
assert len(result) == 1
assert "10.0.0.0/24" in result[0]
class TestNowIso:
def test_returns_iso_string(self):
result = firewall._now_iso()
datetime.fromisoformat(result)
assert "+" in result
class TestAddForwardPort:
@patch("lib.firewall.run")
def test_forward_port_basic(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 443, "tcp", toaddr="10.0.0.5", toport=8080)
calls = [c[0][0] for c in mock_run.call_args_list]
assert any("--add-forward-port=" in str(c) for c in calls)
@patch("lib.firewall.run")
def test_forward_port_port_only(self, mock_run):
mock_run.return_value = ""
firewall.add_forward_port("public", 80, "tcp", toport=8080)
class TestGetState:
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.get_zone_info")
@patch("lib.firewall.get_active_zones")
@patch("lib.firewall.get_interfaces")
@patch("lib.firewall.get_services")
@patch("lib.firewall.get_rich_rules")
def test_returns_full_state(
self,
mock_rich,
mock_services,
mock_ifaces,
mock_active,
mock_zone_info,
mock_available,
):
mock_available.return_value = ["public", "internal"]
mock_active.return_value = {"public": ["eth0"]}
mock_ifaces.return_value = ["eth0", "eth1"]
mock_services.return_value = ["ssh", "http"]
mock_zone_info.return_value = {"name": "public", "services": []}
mock_rich.return_value = []
result = firewall.get_state()
assert "zones" in result
assert "active_zones" in result
assert "timestamp" in result
assert result == ["lo", "eth0"]
class TestNormalizeTarget:
@@ -192,6 +116,11 @@ class TestLiveTargetToConfig:
assert firewall._live_target_to_config("") == "DEFAULT"
# ---------------------------------------------------------------------------
# lib/firewall.py — config helpers (no sudo)
# ---------------------------------------------------------------------------
class TestEnsureConfigFile:
def test_creates_file_if_missing(self, tmp_path):
cfg_dir = tmp_path / "config" / "firewall"
@@ -248,26 +177,299 @@ class TestConfigSet:
assert content["zones"]["test"]["interfaces"] == ["eth0"]
class TestConfigApply:
class TestConfigPending:
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_applies_existing_zone(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
def test_detects_interface_drift(self, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
state = {
"zones": {
"public": {
"interfaces": ["eth1"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending(state)
assert result["needs_apply"] is True
assert any(c["type"] == "interfaces" for c in result["pending"])
@patch("lib.firewall.get_config")
def test_in_sync(self, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
state = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending(state)
assert result["needs_apply"] is False
@patch("lib.firewall.get_config")
def test_detects_services_drift(self, mock_cfg):
mock_cfg.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http", "ssh"],
"masquerade": False,
},
},
}
state = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending(state)
assert any(c["type"] == "services" for c in result["pending"])
@patch("lib.firewall.get_config")
def test_detects_unmanaged_zones(self, mock_cfg):
mock_cfg.return_value = {"zones": {}}
state = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": [],
"masquerade": False,
},
},
}
result = firewall.config_pending(state)
assert "public" in result["unmanaged_zones"]
# ---------------------------------------------------------------------------
# lib/firewall.py — parse zone output (used by both lib and daemon)
# ---------------------------------------------------------------------------
class TestGetZoneInfo:
def test_parses_zone_info(self):
result = firewall._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"]
assert result["sources"] == []
assert result["rich-rules"] == []
# ---------------------------------------------------------------------------
# lib/firewall.py — no sudo functions
# ---------------------------------------------------------------------------
class TestLibNoSudo:
def test_no_run_import(self):
import inspect
source = inspect.getsource(firewall)
assert "sudo=True" not in source, "lib/firewall.py must not call sudo"
# ---------------------------------------------------------------------------
# daemon/handlers/firewall.py — privileged operations
# ---------------------------------------------------------------------------
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
class TestDaemonParseForwardPorts:
def test_handler_uses_get_forward_ports(self):
assert callable(daemonfirewall._get_forward_ports)
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"]
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
result = daemonfirewall.get_interfaces(None, None)
assert [i["name"] for i in result] == ["lo", "eth0", "eth1"]
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"})
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):
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()
assert "zones" in result
assert "active_zones" in result
assert "timestamp" in result
assert "interfaces" in result
assert len(result["interfaces"]) >= 2
assert "public" in result["zones"]
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 = {
"zones": {
"public": {
@@ -278,34 +480,35 @@ class TestConfigApply:
},
},
}
mock_available.return_value = ["public", "internal"]
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 = firewall.config_apply()
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["public"]
assert result["backup"] == "/tmp/rules.json"
mock_set_ifaces.assert_called_once_with("public", ["eth0"])
mock_set_svcs.assert_called_once_with("public", ["http", "https"])
mock_set_mq.assert_called_once_with("public", True)
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("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_creates_new_zone(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
@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": {
@@ -316,18 +519,44 @@ class TestConfigApply:
},
},
}
mock_available.return_value = ["public", "internal"]
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 = firewall.config_apply()
result = daemonfirewall._config_apply()
assert result["applied_zones"] == ["custom"]
mock_create.assert_called_once_with("custom", "ACCEPT")
mock_set_ifaces.assert_called_once_with("custom", ["eth2"])
@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"] == []
class TestConfigPending:
class TestDaemonConfigPending:
@patch("daemon.handlers.firewall._get_state")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_interface_drift(self, mock_state, mock_cfg):
def test_detects_interface_drift(self, mock_cfg, mock_state):
mock_cfg.return_value = {
"zones": {
"public": {
@@ -346,13 +575,12 @@ class TestConfigPending:
},
},
}
result = firewall.config_pending()
result = daemonfirewall.config_pending(None, None)
assert result["needs_apply"] is True
assert any(c["type"] == "interfaces" for c in result["pending"])
@patch("daemon.handlers.firewall._get_state")
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_in_sync(self, mock_state, mock_cfg):
def test_in_sync(self, mock_cfg, mock_state):
mock_cfg.return_value = {
"zones": {
"public": {
@@ -371,74 +599,104 @@ class TestConfigPending:
},
},
}
result = firewall.config_pending()
result = daemonfirewall.config_pending(None, None)
assert result["needs_apply"] is False
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_services_drift(self, mock_state, mock_cfg):
# ---------------------------------------------------------------------------
# Zone validation in add_rich_rule, remove_rich_rule, remove_forward_port
# ---------------------------------------------------------------------------
class TestDaemonZoneValidation:
@patch("daemon.handlers.firewall.run")
def test_add_rich_rule_invalid_zone(self, mock_run):
mock_run.return_value = "public\ninternal"
with (
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
patch.object(daemonfirewall, "_save_config"),
pytest.raises(NotFoundError),
):
daemonfirewall.add_rich_rule(
None,
{
"zone": "nonexistent",
"rule": "rule accept",
},
)
@patch("daemon.handlers.firewall.run")
def test_remove_rich_rule_invalid_zone(self, mock_run):
mock_run.return_value = "public\ninternal"
with (
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
pytest.raises(NotFoundError),
):
daemonfirewall.remove_rich_rule(
None, {"zone": "nonexistent", "id": "abc123"}
)
@patch("daemon.handlers.firewall.run")
def test_remove_forward_port_invalid_zone(self, mock_run):
mock_run.return_value = "public\ninternal"
with (
patch.object(daemonfirewall, "_get_config", return_value={"zones": {}}),
pytest.raises(NotFoundError),
):
daemonfirewall.remove_forward_port(
None,
{
"zone": "nonexistent",
"port": 443,
"proto": "tcp",
},
)
# ---------------------------------------------------------------------------
# Forward port removal during config_apply
# ---------------------------------------------------------------------------
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": ["eth0"],
"services": ["http", "ssh"],
"masquerade": False,
},
},
}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
},
},
}
result = firewall.config_pending()
assert any(c["type"] == "services" for c in result["pending"])
@patch("lib.firewall.get_config")
@patch("lib.firewall.get_state")
def test_detects_unmanaged_zones(self, mock_state, mock_cfg):
mock_cfg.return_value = {"zones": {}}
mock_state.return_value = {
"zones": {
"public": {
"interfaces": ["eth0"],
"interfaces": [],
"services": [],
"masquerade": False,
"forward_ports": [
{"id": "fp_new", "port": 8443, "proto": "tcp"},
],
},
},
}
result = firewall.config_pending()
assert "public" in result["unmanaged_zones"]
class TestConfigEmptyZones:
@patch("lib.firewall.get_config")
@patch("lib.firewall.save_backup")
@patch("lib.firewall.get_available_zones")
@patch("lib.firewall.create_zone")
@patch("lib.firewall.set_zone_services")
@patch("lib.firewall.set_zone_interfaces")
@patch("lib.firewall.set_masquerade")
@patch("lib.firewall._reload")
def test_empty_config_no_ops(
self,
mock_reload,
mock_set_mq,
mock_set_ifaces,
mock_set_svcs,
mock_create,
mock_available,
mock_backup,
mock_cfg,
):
mock_cfg.return_value = {"zones": {}}
mock_available.return_value = []
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"
result = firewall.config_apply()
assert result["applied_zones"] == []
mock_create.assert_not_called()
mock_set_ifaces.assert_not_called()
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)