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:
@@ -964,6 +964,57 @@ def status_client():
|
||||
return app.test_client()
|
||||
|
||||
|
||||
class TestStatusPending:
|
||||
@_st("get")
|
||||
def test_advisory_fields_passthrough(self, mock_get, status_client):
|
||||
from daemon.iface import GET_STATUS_PENDING
|
||||
|
||||
mock_get.return_value = {
|
||||
"firewall": {
|
||||
"needs_apply": False,
|
||||
"change_count": 0,
|
||||
"changes": [],
|
||||
"uncovered_interfaces": ["eth1"],
|
||||
"coverage_warnings": [
|
||||
"Interfaces not in any firewall zone: eth1 — clients "
|
||||
"on those segments lose connectivity and DHCP"
|
||||
],
|
||||
},
|
||||
"dnsmasq": {
|
||||
"pending_changes": False,
|
||||
"summary": "Up to date",
|
||||
"changes": [],
|
||||
},
|
||||
"nginx": {"pending_changes": False, "summary": "Up to date", "changes": []},
|
||||
"wireguard": {
|
||||
"pending_changes": False,
|
||||
"summary": "Up to date",
|
||||
"changes": [],
|
||||
},
|
||||
"networkd": {
|
||||
"pending_changes": False,
|
||||
"summary": "Up to date",
|
||||
"changes": [],
|
||||
},
|
||||
"total_changes": 0,
|
||||
}
|
||||
resp = status_client.get("/api/status/pending")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
assert data["data"]["firewall"]["uncovered_interfaces"] == ["eth1"]
|
||||
assert data["data"]["firewall"]["coverage_warnings"]
|
||||
assert data["data"]["total_changes"] == 0
|
||||
mock_get.assert_called_once_with(GET_STATUS_PENDING)
|
||||
|
||||
@_st("get")
|
||||
def test_runtime_error(self, mock_get, status_client):
|
||||
mock_get.side_effect = RuntimeError("no daemon")
|
||||
resp = status_client.get("/api/status/pending")
|
||||
assert resp.status_code == 500
|
||||
assert resp.get_json()["ok"] is False
|
||||
|
||||
|
||||
class TestStatusRefresh:
|
||||
def test_filtered_subsystems_passed_through(self, status_client):
|
||||
"""The subsystem body is forwarded to the daemon POST endpoint."""
|
||||
|
||||
Reference in New Issue
Block a user