firewall: interface-coverage apply guard, target drift, non-destructive DHCP sync
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
This commit is contained in:
+28
-21
@@ -764,7 +764,10 @@ class TestFirewallToDhcpSync:
|
||||
@patch("lib.dnsmasq.save_config")
|
||||
@patch("lib.dnsmasq.get_config")
|
||||
@patch("lib.firewall.get_config")
|
||||
def test_removes_stale_ranges(self, mock_fw_get, mock_dm_get, mock_dm_save):
|
||||
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"]}}
|
||||
}
|
||||
@@ -790,16 +793,16 @@ class TestFirewallToDhcpSync:
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "dnsmasq" in result.affected_subsystems
|
||||
assert any("Removed stale DHCP range" in c for c in result.changes)
|
||||
assert any("eth2" in c for c in result.changes)
|
||||
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
|
||||
|
||||
# Verify saved config only has eth1 range
|
||||
mock_dm_save.assert_called_once()
|
||||
saved = mock_dm_save.call_args[0][0]
|
||||
saved_ranges = saved["dhcp"]["ranges"]
|
||||
assert len(saved_ranges) == 1
|
||||
assert saved_ranges[0]["interface"] == "eth1"
|
||||
# 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")
|
||||
@@ -869,8 +872,9 @@ class TestFirewallToDhcpSync:
|
||||
@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):
|
||||
"""Ranges without an interface (global) are never removed."""
|
||||
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"]}}
|
||||
}
|
||||
@@ -892,16 +896,19 @@ class TestFirewallToDhcpSync:
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert "dnsmasq" in result.affected_subsystems
|
||||
|
||||
saved = mock_dm_save.call_args[0][0]
|
||||
saved_ranges = saved["dhcp"]["ranges"]
|
||||
assert len(saved_ranges) == 1
|
||||
assert saved_ranges[0]["start"] == "192.168.1.100"
|
||||
assert (
|
||||
saved_ranges[0].get("interface") is None
|
||||
or saved_ranges[0]["interface"] == ""
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user