ac52918df5
Post-DHCP-incident hardening per HARDEN.md.
- apply guard: refuse (ConflictError, `force` overrides) when a
network-managed interface would end up in no zone; absent
`interfaces` key = hands-off, explicit `[]` = unassign-all
- surface `uncovered_interfaces` in firewall state (lo/wg* filtered)
+ advisory in /api/status/pending; zones.js banner + interfaces-picker
last-zone confirm
- target drift (Option A): absent or default-normalizing target is
unmanaged: not diffed, never re-set by apply; create_zone runs
--new-zone first and sets non-default targets only; importer omits
the target key for default zones
- FirewallToDhcpSync keeps stale DHCP ranges and flags them instead of
deleting; `dnsmasq` affected only on a real gateway mutation
- real pre-apply recovery snapshot in data/firewall/rules.json
({timestamp, default_zone, zones, config}); drop the empty post-apply
skeleton
- daemon shutdown: bounded grace for in-flight tasks + suppressed
teardown exception noise on SIGTERM
- also carries the firewall service-descriptions feature
(get_service_descriptions + service_descriptions state field + UI)
- tests + docs across firewall/status/state/sync/schema; ruff clean,
867 passing
1037 lines
35 KiB
Python
1037 lines
35 KiB
Python
"""Tests for lib/sync.py — event bus, sync primitives, and transitive deps."""
|
|
|
|
import logging
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from lib.sync import (
|
|
DnsToFirewallSync,
|
|
EventBus,
|
|
FirewallToDhcpSync,
|
|
NetworkToAllSync,
|
|
SyncEvent,
|
|
SyncResult,
|
|
WgToFirewallSync,
|
|
get_affected,
|
|
)
|
|
|
|
|
|
class TestSyncEvent:
|
|
def test_defaults(self):
|
|
e = SyncEvent(subsystem="dnsmasq", action="config_saved")
|
|
assert e.subsystem == "dnsmasq"
|
|
assert e.action == "config_saved"
|
|
assert e.payload == {}
|
|
|
|
def test_with_payload(self):
|
|
e = SyncEvent("dnsmasq", "config_saved", {"interface": "eth0"})
|
|
assert e.payload == {"interface": "eth0"}
|
|
|
|
|
|
class TestSyncResult:
|
|
def test_defaults(self):
|
|
r = SyncResult()
|
|
assert r.affected_subsystems == []
|
|
assert r.changes == []
|
|
assert r.applied is False
|
|
|
|
|
|
class TestEventBus:
|
|
def test_subscribe_then_emit(self):
|
|
bus = EventBus()
|
|
handler = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"]))
|
|
bus.subscribe("dnsmasq", "config_saved", handler)
|
|
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved", {"iface": "eth0"}))
|
|
assert result.affected_subsystems == ["firewall"]
|
|
handler.assert_called_once()
|
|
|
|
def test_no_subscribers(self):
|
|
bus = EventBus()
|
|
result = bus.emit(SyncEvent("unknown", "config_saved"))
|
|
assert result.affected_subsystems == []
|
|
|
|
def test_subscriber_returns_none(self):
|
|
bus = EventBus()
|
|
bus.subscribe("dnsmasq", "config_saved", MagicMock(return_value=None))
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert result.affected_subsystems == []
|
|
|
|
def test_subscriber_exception_contained(self):
|
|
bus = EventBus()
|
|
failing = MagicMock(side_effect=RuntimeError("boom"))
|
|
good = MagicMock(return_value=SyncResult(affected_subsystems=["firewall"]))
|
|
bus.subscribe("dnsmasq", "config_saved", failing)
|
|
bus.subscribe("dnsmasq", "config_saved", good)
|
|
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert result.affected_subsystems == ["firewall"]
|
|
assert good.called
|
|
|
|
def test_multiple_handlers_aggregated(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(return_value=SyncResult(affected_subsystems=["firewall"])),
|
|
)
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(return_value=SyncResult(affected_subsystems=["networkd"])),
|
|
)
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert result.affected_subsystems == ["firewall", "networkd"]
|
|
|
|
def test_changes_aggregated(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(return_value=SyncResult(changes=["Added dhcp to zone"])),
|
|
)
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert result.changes == ["Added dhcp to zone"]
|
|
|
|
def test_applied_flag(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(return_value=SyncResult(applied=True)),
|
|
)
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert result.applied is True
|
|
|
|
|
|
class TestLoopGuard:
|
|
def test_recursive_emit_dropped(self):
|
|
"""If a subscriber emits the same event key, it is silently dropped."""
|
|
bus = EventBus()
|
|
events_seen = []
|
|
|
|
def handler(event):
|
|
events_seen.append(event.subsystem)
|
|
if event.subsystem == "dnsmasq" and not event.payload.get("_cascade"):
|
|
bus.emit(SyncEvent("dnsmasq", "config_saved", {"loop": True}))
|
|
return SyncResult(affected_subsystems=[event.subsystem])
|
|
|
|
bus.subscribe("dnsmasq", "config_saved", handler)
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert events_seen == ["dnsmasq"]
|
|
assert result.affected_subsystems == ["dnsmasq"]
|
|
|
|
def test_cascade_different_key_applies(self):
|
|
"""When subscriber affects a different subsystem, cascade fires."""
|
|
bus = EventBus()
|
|
calls = []
|
|
|
|
def dns_handler(event):
|
|
calls.append(("dns", event.subsystem))
|
|
return SyncResult(affected_subsystems=["firewall"])
|
|
|
|
def fw_handler(event):
|
|
calls.append(("fw", event.subsystem))
|
|
return SyncResult(affected_subsystems=["firewall"])
|
|
|
|
bus.subscribe("dnsmasq", "config_saved", dns_handler)
|
|
bus.subscribe("firewall", "config_saved", fw_handler)
|
|
result = bus.emit(SyncEvent("dnsmasq", "config_saved"))
|
|
assert calls == [("dns", "dnsmasq"), ("fw", "firewall")]
|
|
assert "firewall" in result.affected_subsystems
|
|
|
|
|
|
class TestGetAffected:
|
|
def test_single(self):
|
|
result = get_affected(["dnsmasq"])
|
|
assert "dnsmasq" in result
|
|
|
|
def test_single_target(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"firewall"},
|
|
)
|
|
result = get_affected(["dnsmasq"], event_bus=bus)
|
|
# Note: get_affected uses module singleton _bus, not local bus
|
|
assert "dnsmasq" in result
|
|
|
|
def test_transitive(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"firewall"},
|
|
)
|
|
bus.subscribe(
|
|
"firewall",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"dnsmasq"},
|
|
)
|
|
result = get_affected(["dnsmasq"], event_bus=bus)
|
|
assert "dnsmasq" in result
|
|
assert "firewall" in result
|
|
|
|
def test_chain(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"network",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"firewall"},
|
|
)
|
|
bus.subscribe(
|
|
"firewall",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"dnsmasq"},
|
|
)
|
|
result = get_affected(["network"], event_bus=bus)
|
|
assert "network" in result
|
|
assert "firewall" in result
|
|
assert "dnsmasq" in result
|
|
|
|
def test_multi_base(self):
|
|
bus = EventBus()
|
|
bus.subscribe(
|
|
"dnsmasq",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"firewall"},
|
|
)
|
|
bus.subscribe(
|
|
"wireguard",
|
|
"config_saved",
|
|
MagicMock(),
|
|
targets={"firewall"},
|
|
)
|
|
result = get_affected(["dnsmasq", "wireguard"], event_bus=bus)
|
|
assert "dnsmasq" in result
|
|
assert "wireguard" in result
|
|
assert "firewall" in result
|
|
|
|
def test_no_targets(self):
|
|
bus = EventBus()
|
|
bus.subscribe("dnsmasq", "config_saved", MagicMock())
|
|
result = get_affected(["dnsmasq"], event_bus=bus)
|
|
assert "dnsmasq" in result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subscriber tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDnsToFirewallSync:
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_adds_dhcp_dns(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "192.168.2.100",
|
|
"end": "192.168.2.200",
|
|
}
|
|
]
|
|
},
|
|
"dns": {},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh"],
|
|
"masquerade": False,
|
|
}
|
|
}
|
|
}
|
|
|
|
result = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert "firewall" in result.affected_subsystems
|
|
mock_fw_save.assert_called_once()
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
assert "dhcp" in saved_cfg["zones"]["internal"]["services"]
|
|
assert "dns" in saved_cfg["zones"]["internal"]["services"]
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_removes_dhcp_dns_no_ranges(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}, "dns": {}}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh", "dhcp", "dns"],
|
|
"masquerade": True,
|
|
}
|
|
}
|
|
}
|
|
|
|
result = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert "firewall" in result.affected_subsystems
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
assert "dhcp" not in saved_cfg["zones"]["internal"]["services"]
|
|
assert "dns" not in saved_cfg["zones"]["internal"]["services"]
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_idempotent(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "192.168.2.100",
|
|
"end": "192.168.2.200",
|
|
}
|
|
]
|
|
},
|
|
"dns": {},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh", "dhcp", "dns"],
|
|
"masquerade": True,
|
|
}
|
|
}
|
|
}
|
|
|
|
r1 = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
r2 = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
|
|
assert r1 is not None
|
|
assert r2 is not None
|
|
mock_fw_save.assert_not_called()
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_cascade_skip(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved", {"_cascade": "firewall"})
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_empty_dnsmasq_config(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.return_value = {}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh"],
|
|
"masquerade": False,
|
|
}
|
|
}
|
|
}
|
|
|
|
result = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert "firewall" not in result.affected_subsystems
|
|
mock_fw_save.assert_not_called()
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
def test_exception_contained(self, mock_dm_get, mock_fw_get, mock_fw_save):
|
|
mock_dm_get.side_effect = RuntimeError("db error")
|
|
|
|
result = DnsToFirewallSync.on_dnsmasq_config_saved(
|
|
SyncEvent("dnsmasq", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.affected_subsystems == []
|
|
assert result.changes == []
|
|
mock_fw_save.assert_not_called()
|
|
|
|
|
|
class TestWgToFirewallSync:
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_creates_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {"alice": {"public_key": "abc123"}},
|
|
}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert "firewall" in result.affected_subsystems
|
|
mock_fw_save.assert_called_once()
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
assert "vpn" in saved_cfg["zones"]
|
|
assert "wg0" in saved_cfg["zones"]["vpn"]["interfaces"]
|
|
assert saved_cfg["zones"]["vpn"]["masquerade"] is True
|
|
assert len(saved_cfg["zones"]["vpn"]["rich_rules"]) == 1
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_existing_vpn_zone_unchanged(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {"alice": {"public_key": "abc123"}},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_not_called()
|
|
assert result.affected_subsystems == []
|
|
assert not result.changes
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_no_peers_no_create(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {},
|
|
}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_not_called()
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_disabled_cleanup_vpn_zone(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
"""When WireGuard is disabled, vpn zone entries are cleaned up."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": ""},
|
|
"peers": {},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
},
|
|
{
|
|
"rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept',
|
|
"_source": "wg",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_called_once()
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
vpn_zone = saved_cfg["zones"]["vpn"]
|
|
assert "wg0" not in vpn_zone.get("interfaces", [])
|
|
assert vpn_zone.get("masquerade") is False
|
|
assert len(vpn_zone.get("rich_rules", [])) == 0
|
|
assert any("interface" in c.lower() for c in result.changes)
|
|
assert any("masquerade" in c.lower() for c in result.changes)
|
|
assert any("rich rule" in c.lower() for c in result.changes)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {"alice": {"public_key": "abc123"}},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
r1 = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
r2 = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert r1 is not None
|
|
assert r2 is not None
|
|
mock_fw_save.assert_not_called()
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_cascade_skip(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {"alice": {"public_key": "abc123"}},
|
|
}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved", {"_cascade": "firewall"})
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_allowed_ips_adds_rules(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
"""Inter-zone rules are created for peer allowed_ips."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {
|
|
"alice": {
|
|
"public_key": "abc123",
|
|
"allowed_ips": ["192.168.10.0/24"],
|
|
},
|
|
"bob": {
|
|
"public_key": "def456",
|
|
"allowed_ips": ["10.20.0.0/16"],
|
|
},
|
|
},
|
|
}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_called_once()
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
vpn_zone = saved_cfg["zones"]["vpn"]
|
|
rules = {r.get("rule", "") for r in vpn_zone.get("rich_rules", [])}
|
|
rule_sources = {
|
|
r.get("rule", ""): r.get("_source") for r in vpn_zone.get("rich_rules", [])
|
|
}
|
|
|
|
# UDP 51820 rule
|
|
assert 'rule family="ipv4" port protocol="udp" port="51820" accept' in rules
|
|
assert (
|
|
rule_sources['rule family="ipv4" port protocol="udp" port="51820" accept']
|
|
== "wg"
|
|
)
|
|
|
|
# Inter-zone rules for each allowed_ips subnet
|
|
assert (
|
|
'rule family="ipv4" destination address="192.168.10.0/24" accept' in rules
|
|
)
|
|
assert 'rule family="ipv4" destination address="10.20.0.0/16" accept' in rules
|
|
assert (
|
|
rule_sources[
|
|
'rule family="ipv4" destination address="192.168.10.0/24" accept'
|
|
]
|
|
== "wg"
|
|
)
|
|
assert (
|
|
rule_sources['rule family="ipv4" destination address="10.20.0.0/16" accept']
|
|
== "wg"
|
|
)
|
|
|
|
# Check change descriptions
|
|
assert any("inter-zone rule" in c for c in result.changes)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_allowed_ips_no_duplicate(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
"""Existing allowed_ips rules are not duplicated."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {
|
|
"alice": {
|
|
"public_key": "abc123",
|
|
"allowed_ips": ["192.168.10.0/24"],
|
|
},
|
|
},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
},
|
|
{
|
|
"rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept',
|
|
"_source": "wg",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_not_called()
|
|
assert result.affected_subsystems == []
|
|
assert result.changes == []
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_cleanup_preserves_manual_rules(
|
|
self, mock_wg_get, mock_fw_get, mock_fw_save
|
|
):
|
|
"""Cleanup only removes rules with _source='wg', not manual rules."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": ""},
|
|
"peers": {},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
},
|
|
{
|
|
"rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept',
|
|
"_source": "wg",
|
|
},
|
|
{
|
|
"rule": 'rule family="ipv4" destination address="10.0.0.0/24" accept',
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_called_once()
|
|
saved_cfg = mock_fw_save.call_args[0][0]
|
|
vpn_zone = saved_cfg["zones"]["vpn"]
|
|
# Only the manual rule (without _source="wg") should remain
|
|
assert len(vpn_zone.get("rich_rules", [])) == 1
|
|
assert (
|
|
vpn_zone["rich_rules"][0]["rule"]
|
|
== 'rule family="ipv4" destination address="10.0.0.0/24" accept'
|
|
)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_allowed_ips_empty(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
"""Peers with empty allowed_ips don't generate rules."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {
|
|
"alice": {
|
|
"public_key": "abc123",
|
|
"allowed_ips": [],
|
|
}
|
|
},
|
|
}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_called_once()
|
|
assert not any("inter-zone rule" in c for c in result.changes)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.wireguard.get_config")
|
|
def test_allowed_ips_idempotent(self, mock_wg_get, mock_fw_get, mock_fw_save):
|
|
"""Existing rules for allowed_ips subnets are not duplicated."""
|
|
mock_wg_get.return_value = {
|
|
"interface": {"name": "wg0"},
|
|
"peers": {
|
|
"alice": {
|
|
"public_key": "abc123",
|
|
"allowed_ips": ["192.168.10.0/24"],
|
|
}
|
|
},
|
|
}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"vpn": {
|
|
"interfaces": ["wg0"],
|
|
"masquerade": True,
|
|
"rich_rules": [
|
|
{
|
|
"rule": 'rule family="ipv4" port protocol="udp" port="51820" accept',
|
|
"_source": "wg",
|
|
},
|
|
{
|
|
"rule": 'rule family="ipv4" destination address="192.168.10.0/24" accept',
|
|
"_source": "wg",
|
|
},
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = WgToFirewallSync.on_wireguard_config_saved(
|
|
SyncEvent("wireguard", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert not any("inter-zone rule" in c for c in result.changes)
|
|
mock_fw_save.assert_not_called()
|
|
assert result.affected_subsystems == []
|
|
assert result.changes == []
|
|
|
|
|
|
class TestFirewallToDhcpSync:
|
|
@patch("lib.dnsmasq.save_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_flags_uncovered_range_without_deleting(
|
|
self, mock_fw_get, mock_dm_get, mock_dm_save, caplog
|
|
):
|
|
caplog.set_level(logging.WARNING)
|
|
mock_fw_get.return_value = {
|
|
"zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}}
|
|
}
|
|
mock_dm_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "192.168.2.100",
|
|
"end": "192.168.2.200",
|
|
},
|
|
{
|
|
"interface": "eth2",
|
|
"start": "10.0.0.100",
|
|
"end": "10.0.0.200",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.affected_subsystems == []
|
|
assert result.changes == [
|
|
"DHCP range on 'eth2' has no firewall zone coverage — "
|
|
"inactive until a zone covers it"
|
|
]
|
|
assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text
|
|
|
|
# Config untouched: no save, both ranges kept
|
|
mock_dm_save.assert_not_called()
|
|
assert len(mock_dm_get.return_value["dhcp"]["ranges"]) == 2
|
|
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_no_changes_when_all_valid(self, mock_fw_get, mock_dm_get):
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh", "dhcp", "dns"],
|
|
}
|
|
}
|
|
}
|
|
mock_dm_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "192.168.2.100",
|
|
"end": "192.168.2.200",
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.affected_subsystems == []
|
|
assert result.changes == []
|
|
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_warns_no_range_for_dhcp_service(self, mock_fw_get, mock_dm_get, caplog):
|
|
caplog.set_level(logging.INFO)
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh", "dhcp"],
|
|
}
|
|
}
|
|
}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert any("no DHCP range" in c for c in result.changes)
|
|
assert result.affected_subsystems == []
|
|
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_cascade_skip(self, mock_fw_get, mock_dm_get):
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved", {"_cascade": "dnsmasq"})
|
|
)
|
|
|
|
assert result is None
|
|
|
|
@patch("lib.dnsmasq.save_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_keeps_global_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save, caplog):
|
|
"""Global and uncovered ranges are both kept, never removed."""
|
|
caplog.set_level(logging.WARNING)
|
|
mock_fw_get.return_value = {
|
|
"zones": {"internal": {"interfaces": ["eth1"], "services": ["ssh"]}}
|
|
}
|
|
mock_dm_get.return_value = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{"start": "192.168.1.100", "end": "192.168.1.200"},
|
|
{
|
|
"interface": "eth2",
|
|
"start": "10.0.0.100",
|
|
"end": "10.0.0.200",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.affected_subsystems == []
|
|
assert any(
|
|
"DHCP range on 'eth2' has no firewall zone coverage" in c
|
|
for c in result.changes
|
|
)
|
|
assert "DHCP range on 'eth2' has no firewall zone coverage" in caplog.text
|
|
|
|
# No save; both ranges (global + eth2) kept in the untouched config
|
|
mock_dm_save.assert_not_called()
|
|
ranges = mock_dm_get.return_value["dhcp"]["ranges"]
|
|
assert len(ranges) == 2
|
|
assert ranges[0]["start"] == "192.168.1.100"
|
|
assert ranges[0].get("interface") is None or ranges[0]["interface"] == ""
|
|
|
|
@patch("lib.dnsmasq.save_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.firewall.get_config")
|
|
def test_exception_contained(self, mock_fw_get, mock_dm_get, mock_dm_save):
|
|
mock_fw_get.side_effect = RuntimeError("db error")
|
|
|
|
result = FirewallToDhcpSync.on_firewall_config_saved(
|
|
SyncEvent("firewall", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert result.affected_subsystems == []
|
|
mock_dm_save.assert_not_called()
|
|
|
|
|
|
class TestNetworkToAllSync:
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.network.get_config")
|
|
def test_suggests_dhcp_range(
|
|
self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save, caplog
|
|
):
|
|
mock_net_get.return_value = {
|
|
"interfaces": {
|
|
"eth1": {
|
|
"addresses": ["192.168.2.1/24"],
|
|
"dhcp": "no",
|
|
}
|
|
}
|
|
}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh"],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = NetworkToAllSync.on_network_config_saved(
|
|
SyncEvent("network", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
assert any("no DHCP range" in c for c in result.changes)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.network.get_config")
|
|
def test_zone_sync_firewall(
|
|
self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save
|
|
):
|
|
mock_net_get.return_value = {
|
|
"interfaces": {"eth1": {"addresses": ["192.168.2.1/24"]}}
|
|
}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth2"],
|
|
"services": ["ssh"],
|
|
}
|
|
}
|
|
}
|
|
|
|
result = NetworkToAllSync.on_network_config_saved(
|
|
SyncEvent("network", "config_saved")
|
|
)
|
|
|
|
assert result is not None
|
|
mock_fw_save.assert_called_once()
|
|
assert any("not in any zone" in c for c in result.changes)
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.network.get_config")
|
|
def test_no_auto_create_ranges(
|
|
self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save
|
|
):
|
|
mock_net_get.return_value = {
|
|
"interfaces": {
|
|
"eth1": {
|
|
"addresses": ["192.168.2.1/24"],
|
|
"dhcp": "no",
|
|
}
|
|
}
|
|
}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
mock_fw_get.return_value = {
|
|
"zones": {
|
|
"internal": {
|
|
"interfaces": ["eth1"],
|
|
"services": ["ssh"],
|
|
}
|
|
}
|
|
}
|
|
|
|
with patch("lib.dnsmasq.save_config") as mock_dm_save:
|
|
NetworkToAllSync.on_network_config_saved(
|
|
SyncEvent("network", "config_saved")
|
|
)
|
|
mock_dm_save.assert_not_called()
|
|
|
|
@patch("lib.firewall.save_config")
|
|
@patch("lib.firewall.get_config")
|
|
@patch("lib.dnsmasq.get_config")
|
|
@patch("lib.network.get_config")
|
|
def test_cascade_no_skip(
|
|
self, mock_net_get, mock_dm_get, mock_fw_get, mock_fw_save
|
|
):
|
|
"""NetworkToAllSync does not check _cascade and processes normally."""
|
|
mock_net_get.return_value = {"interfaces": {}}
|
|
mock_dm_get.return_value = {"dhcp": {"ranges": []}}
|
|
mock_fw_get.return_value = {"zones": {}}
|
|
|
|
result = NetworkToAllSync.on_network_config_saved(
|
|
SyncEvent("network", "config_saved", {"_cascade": "firewall"})
|
|
)
|
|
|
|
assert result is not None
|