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:
2026-08-28 23:38:21 +00:00
parent 55309cfd86
commit ac52918df5
25 changed files with 1677 additions and 168 deletions
+468
View File
@@ -76,6 +76,38 @@ class TestParseZoneOutput:
assert result["services"] == ["ssh", "dhcp"]
assert result["masquerade"] is True
def test_lib_rich_rule_continuation_lines(self):
"""firewalld emits each rich rule on its own tab-indented line."""
rule = 'rule family="ipv4" port port="51820" protocol="udp" accept'
result = firewall._parse_zone_output(
"vpn-full",
(
"target: default\n"
"interfaces: \n"
"rich rules: \n"
"\t" + rule + "\n"
"masquerade: yes\n"
),
)
assert result["rich-rules"] == [rule]
assert result["masquerade"] is True
def test_lib_multiple_rich_rule_continuation_lines(self):
rule_a = 'rule family="ipv4" port port="51820" protocol="udp" accept'
rule_b = 'rule family="ipv4" source address="10.0.0.0/8" drop'
result = firewall._parse_zone_output(
"vpn-full",
("target: default\nrich rules: \n" + rule_a + "\n" + rule_b + "\n"),
)
assert result["rich-rules"] == [rule_a, rule_b]
def test_lib_no_rich_rules_when_no_continuation(self):
result = firewall._parse_zone_output(
"public",
"target: default\nrich rules: \nmasquerade: no\n",
)
assert result["rich-rules"] == []
class TestParseInterfaces:
def test_lib_parses_interfaces(self):
@@ -334,6 +366,92 @@ class TestConfigPending:
)
_PENDING_LIVE_PUBLIC = {
"interfaces": ["eth0"],
"services": ["http"],
"masquerade": False,
"target": "default",
"rich-rules": [],
"forward-ports": [],
}
class TestComputePendingChangesAbsentInterfaces:
"""Zones whose config lacks the 'interfaces' key are hands-off on apply,
so their interfaces diff must not be reported; other field drift is."""
def test_services_drift_reported_without_interfaces_key(self):
cfg = {"zones": {"public": {"services": ["http", "ssh"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
types = {c["type"] for c in result["pending"]}
assert "services" in types
assert "interfaces" not in types
def test_no_spurious_interfaces_entry_for_absent_key_zone(self):
# Config in sync on everything except a missing interfaces key: the
# zone's live interfaces are intentionally left alone by apply.
cfg = {"zones": {"public": {"services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
assert result["pending"] == []
assert result["needs_apply"] is False
def test_explicit_empty_interfaces_key_still_diffs(self):
cfg = {"zones": {"public": {"interfaces": [], "services": ["http"]}}}
result = firewall._compute_pending_changes(
cfg, {"public": _PENDING_LIVE_PUBLIC}
)
entries = [c for c in result["pending"] if c["type"] == "interfaces"]
assert len(entries) == 1
assert entries[0]["config"] == []
assert entries[0]["live"] == ["eth0"]
class TestTargetDriftSemantics:
"""Target is unmanaged when the config key is absent or normalizes to
'default' (WI-2, Option A); explicit ACCEPT/DROP/REJECT is fully managed."""
def test_absent_target_key_not_diffed(self):
cfg = {"zones": {"public": {"interfaces": ["eth0"], "services": ["http"]}}}
live = {"public": {**_PENDING_LIVE_PUBLIC, "target": "ACCEPT"}}
result = firewall._compute_pending_changes(cfg, live)
assert not any(c["type"] == "target" for c in result["pending"])
def test_explicit_default_target_not_diffed(self):
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"target": "DEFAULT",
}
}
}
live = {"public": {**_PENDING_LIVE_PUBLIC, "target": "ACCEPT"}}
result = firewall._compute_pending_changes(cfg, live)
assert not any(c["type"] == "target" for c in result["pending"])
def test_explicit_accept_target_diffed(self):
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["http"],
"target": "ACCEPT",
}
}
}
live = {"public": _PENDING_LIVE_PUBLIC} # live target is 'default'
result = firewall._compute_pending_changes(cfg, live)
target_entries = [c for c in result["pending"] if c["type"] == "target"]
assert len(target_entries) == 1
assert target_entries[0]["config"] == "ACCEPT"
assert target_entries[0]["live"] == "default"
# ---------------------------------------------------------------------------
# lib/firewall.py — parse zone output (used by both lib and daemon)
# ---------------------------------------------------------------------------
@@ -530,6 +648,7 @@ class TestDaemonConfigApply:
)
def test_applies_existing_zone(self, mock_run, mock_cfg):
with (
patch("lib.network.get_config", return_value={"interfaces": {}}),
patch(
"daemon.handlers.firewall._save_backup", return_value="/tmp/rules.json"
),
@@ -683,6 +802,245 @@ class TestDaemonMgmtLockoutGuard:
assert result["applied_zones"] == ["public"]
# ---------------------------------------------------------------------------
# Interface-coverage guard: apply must not leave a network-managed interface
# in no zone (clients lose connectivity/DHCP) unless forced.
# ---------------------------------------------------------------------------
def _make_run(
active_out: str,
zones_out: str = "public\ninternal",
zone_out: str = (
"target: default\n"
"interfaces: eth0\n"
"services: http\n"
"masquerade: no\n"
"rich-rules: \n"
"forward-ports: \n"
),
):
def _side_effect(cmd, **kwargs):
if cmd == ["firewall-cmd", "--get-active-zones"]:
return active_out
if cmd == ["firewall-cmd", "--get-zones"]:
return zones_out
if cmd and cmd[-1] == "--list-all":
return zone_out
return ""
return _side_effect
def _apply_with(
cfg: dict,
network_ifaces: dict,
active_out: str,
force: bool = False,
backup: str = "/tmp/rules.json",
):
"""Run _config_apply with the standard mock set; return (result, mock_run)."""
with (
patch("lib.network.get_config", return_value={"interfaces": network_ifaces}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run(active_out),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup", return_value=backup),
patch("daemon.handlers.firewall.refresh_state"),
patch(
"daemon.handlers.firewall._get_config",
return_value=deepcopy(cfg),
),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply(force=force)
return result, mock_run
class TestDaemonInterfaceCoverageGuard:
def test_absent_key_zone_keeps_live_interfaces_on_apply(self):
cfg = {"zones": {"public": {"services": ["http"], "masquerade": False}}}
result, mock_run = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
# The guard reads live zones once, up front.
assert mock_run.call_args_list[0].args[0] == [
"firewall-cmd",
"--get-active-zones",
]
# Hands off: no interface mutation commands for the absent-key zone.
for c in mock_run.call_args_list:
for arg in c.args[0]:
assert not arg.startswith("--remove-interface=")
assert not arg.startswith("--add-interface=")
def test_explicit_empty_list_unassigns(self):
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, mock_run = _apply_with(cfg, {}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
cmds = [c.args[0] for c in mock_run.call_args_list]
assert [
"firewall-cmd",
"--zone=public",
"--remove-interface=eth0",
"--permanent",
] in cmds
assert not any(
any(a.startswith("--add-interface=") for a in cmd) for cmd in cmds
)
def test_conflict_when_network_iface_goes_uncovered(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
pytest.raises(ConflictError) as exc,
):
daemonfirewall._config_apply()
assert "eth0" in str(exc.value)
assert "force" in str(exc.value)
def test_force_bypasses_coverage_guard(self):
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
result, _ = _apply_with(cfg, {"eth0": {}}, "public\n eth0\n", force=True)
assert result["applied_zones"] == ["public"]
def test_guard_ignores_lo_and_wg(self):
# lo/wg* are never guarded even though the network config carries them.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"lo": {}, "wg0": {}}, "public\n eth0\n")
assert result["applied_zones"] == ["public"]
def test_live_only_zone_interfaces_count_as_covered(self):
# eth1 is held by 'guest', which is live but absent from the config —
# apply never touches it, so eth1 counts as covered.
cfg = {"zones": {"public": {"interfaces": [], "services": []}}}
result, _ = _apply_with(cfg, {"eth1": {}}, "public\n eth0\nguest\n eth1\n")
assert result["applied_zones"] == ["public"]
def test_coverage_guard_conflict_writes_no_backup(self):
# Guard conflict must be side-effect free, like the lockout conflict.
cfg = {"zones": {"public": {"interfaces": ["eth1"], "services": []}}}
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
) as mock_run,
patch("daemon.handlers.firewall._default_zone", return_value="internal"),
patch("daemon.handlers.firewall._save_backup") as mock_backup,
pytest.raises(ConflictError),
):
daemonfirewall._config_apply()
mock_backup.assert_not_called()
# Only the guard's live-zone read ran — no mutation commands at all.
assert [c.args[0] for c in mock_run.call_args_list] == [
["firewall-cmd", "--get-active-zones"]
]
# ---------------------------------------------------------------------------
# create_zone — must run --new-zone first, then set only non-default targets
# ---------------------------------------------------------------------------
class TestDaemonCreateZone:
def _run(self, body, run_return="public internal"):
with (
patch("daemon.handlers.firewall.run", return_value=run_return) as mock_run,
patch.object(daemonfirewall, "_reload"),
patch.object(daemonfirewall, "bus") as mock_bus,
patch("daemon.handlers.firewall.refresh_state"),
):
mock_bus.emit.return_value = MagicMock(affected_subsystems=[])
result = daemonfirewall.create_zone(None, body)
return result, mock_run
def test_new_zone_always_created(self):
result, mock_run = self._run({"name": "guest"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert ["firewall-cmd", "--new-zone=guest", "--permanent"] in cmds
def test_default_target_not_set(self):
result, mock_run = self._run({"name": "guest", "target": "default"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert not any("--set-target=" in " ".join(c) for c in cmds)
def test_accept_target_is_set(self):
result, mock_run = self._run({"name": "guest", "target": "ACCEPT"})
assert result == {"zone": "guest"}
cmds = [c.args[0] for c in mock_run.call_args_list]
assert [
"firewall-cmd",
"--zone=guest",
"--set-target=ACCEPT",
"--permanent",
] in cmds
def test_existing_zone_rejected(self):
with (
patch("daemon.handlers.firewall.run", return_value="public guest"),
pytest.raises(ValueError),
):
daemonfirewall.create_zone(None, {"name": "guest"})
# ---------------------------------------------------------------------------
# Pre-apply recovery snapshot (single backup write with a real payload)
# ---------------------------------------------------------------------------
class TestDaemonConfigApplyBackup:
def test_pre_apply_snapshot_shape(self):
# public carries https+ssh so the default-zone lockout guard does not fire.
cfg = {
"zones": {
"public": {
"interfaces": ["eth0"],
"services": ["https", "ssh"],
}
}
}
with (
patch("lib.network.get_config", return_value={"interfaces": {}}),
patch("lib.firewall.get_config", return_value=cfg, create=True),
patch(
"daemon.handlers.firewall.run",
side_effect=_make_run("public\n eth0\n"),
),
patch("daemon.handlers.firewall._default_zone", return_value="public"),
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
) as mock_backup,
patch("daemon.handlers.firewall.refresh_state"),
patch("daemon.handlers.firewall._get_config", return_value=deepcopy(cfg)),
patch("daemon.handlers.firewall._save_config"),
):
result = daemonfirewall._config_apply()
assert result["backup"] == "/tmp/rules.json"
# A single pre-apply snapshot (no post-apply skeleton write).
assert mock_backup.call_count == 1
snapshot = mock_backup.call_args[0][0]
assert {"timestamp", "default_zone", "zones", "config"} <= set(snapshot)
assert snapshot["default_zone"] == "public"
assert snapshot["config"] == cfg
# ---------------------------------------------------------------------------
# Applied-baseline stamping
# ---------------------------------------------------------------------------
_STAMP_TEST_CFG = {
"zones": {
"public": {
@@ -715,6 +1073,7 @@ class TestDaemonConfigApplyStamp:
)
def test_stamps_applied_baseline(self, mock_run, mock_cfg):
with (
patch("lib.network.get_config", return_value={"interfaces": {"eth0": {}}}),
patch(
"daemon.handlers.firewall._save_backup",
return_value="/tmp/rules.json",
@@ -1060,6 +1419,23 @@ class TestParseAllZonesOutput:
assert result["internal"]["target"] == "ACCEPT"
assert result["trusted"]["services"] == []
def test_parses_rich_rule_continuation_lines(self):
rule = 'rule family="ipv4" port port="51820" protocol="udp" accept'
result = firewall._parse_all_zones_output(
"vpn-full\n"
" target: default\n"
" interfaces: \n"
" rich rules: \n"
"\t" + rule + "\n"
" masquerade: yes\n"
"dmz\n"
" target: default\n"
" rich rules: \n"
)
assert result["vpn-full"]["rich-rules"] == [rule]
assert result["vpn-full"]["masquerade"] is True
assert result["dmz"]["rich-rules"] == []
def test_empty_output(self):
assert firewall._parse_all_zones_output("") == {}
assert firewall._parse_all_zones_output("\n \n") == {}
@@ -1101,3 +1477,95 @@ class TestParseAllZonesOutput:
"rich-rules",
):
assert field in zone, f"Missing field: {field}"
# ---------------------------------------------------------------------------
# lib/firewall.py — service catalog descriptions
# ---------------------------------------------------------------------------
def _write_service(dir, name, body):
(dir / f"{name}.xml").write_text(body)
class TestGetServiceDescriptions:
def test_parses_short_preferred(self, tmp_path):
d1 = tmp_path / "builtin"
d2 = tmp_path / "etc"
d1.mkdir()
d2.mkdir()
_write_service(
d1,
"ssh",
"<service><short>OpenSSH</short><description>Remote login</description></service>",
)
_write_service(
d1,
"http",
"<service><short>WWW</short><description>Web server</description></service>",
)
_write_service(
d2,
"custom",
"<service><description>Only a long description</description></service>",
)
result = firewall.get_service_descriptions([d1, d2])
assert result == {
"ssh": "OpenSSH",
"http": "WWW",
"custom": "Only a long description",
}
def test_description_fallback_when_no_short(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(
d,
"ntp",
"<service><description>Time synchronization</description></service>",
)
assert firewall.get_service_descriptions([d]) == {"ntp": "Time synchronization"}
def test_etc_overrides_builtin(self, tmp_path):
builtin = tmp_path / "builtin"
etc = tmp_path / "etc"
builtin.mkdir()
etc.mkdir()
_write_service(builtin, "ssh", "<service><short>Built-in SSH</short></service>")
_write_service(etc, "ssh", "<service><short>Custom SSH</short></service>")
# builtin listed first, etc second (same order as the default dirs)
assert firewall.get_service_descriptions([builtin, etc]) == {
"ssh": "Custom SSH"
}
def test_missing_dirs_return_empty(self, tmp_path):
assert (
firewall.get_service_descriptions([tmp_path / "nope1", tmp_path / "nope2"])
== {}
)
def test_malformed_xml_skipped(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(d, "broken", "<service><short>Unclosed")
_write_service(d, "good", "<service><short>Works</short></service>")
result = firewall.get_service_descriptions([d])
assert result == {"good": "Works"}
def test_empty_text_not_recorded(self, tmp_path):
d = tmp_path / "svc"
d.mkdir()
_write_service(d, "blank", "<service></service>")
assert firewall.get_service_descriptions([d]) == {}
def test_explicit_dirs_not_cached(self, tmp_path):
d1 = tmp_path / "first"
d2 = tmp_path / "second"
d1.mkdir()
d2.mkdir()
_write_service(d1, "a", "<service><short>A1</short></service>")
result1 = firewall.get_service_descriptions([d1])
_write_service(d2, "a", "<service><short>A2</short></service>")
result2 = firewall.get_service_descriptions([d1, d2])
assert result1 == {"a": "A1"}
assert result2 == {"a": "A2"}