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."""
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -19,7 +19,21 @@ def _missing(required_keys: frozenset, data: dict) -> set[str]:
|
||||
|
||||
class TestCollectorShapesMatchSchema:
|
||||
def test_firewall_state(self):
|
||||
with patch.object(lib.state, "run") as mock_run:
|
||||
with (
|
||||
patch.object(lib.state, "run") as mock_run,
|
||||
patch.object(
|
||||
lib.state,
|
||||
"_network_get_config",
|
||||
return_value={
|
||||
"interfaces": {
|
||||
"eth0": {},
|
||||
"eth1": {},
|
||||
"lo": {},
|
||||
"wg0": {},
|
||||
}
|
||||
},
|
||||
),
|
||||
):
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
@@ -55,6 +69,9 @@ class TestCollectorShapesMatchSchema:
|
||||
for iface in result["interfaces"]:
|
||||
for k in schema.FirewallInterface.__required_keys__:
|
||||
assert k in iface, f"FirewallInterface missing {k}"
|
||||
# eth1 is network-config-managed but in no live zone; lo and wg*
|
||||
# are filtered out even though they are present in the config.
|
||||
assert result["uncovered_interfaces"] == ["eth1"]
|
||||
|
||||
def test_dnsmasq_state(self):
|
||||
with patch.object(lib.state, "run_proc") as mock_proc:
|
||||
|
||||
@@ -143,6 +143,30 @@ class TestCollectAll:
|
||||
assert vlan_iface["ips"], "VLAN interface should have collected IPs"
|
||||
assert "10.0.0.1/24" in vlan_iface["ips"]
|
||||
|
||||
@patch("lib.state.get_service_descriptions")
|
||||
@patch("lib.state.run")
|
||||
def test_collect_firewall_includes_service_descriptions(self, mock_run, mock_desc):
|
||||
from lib.state import _collect_firewall
|
||||
|
||||
def run_side(args, **kwargs):
|
||||
if "--get-active-zones" in args:
|
||||
return ""
|
||||
if "--get-default-zone" in args:
|
||||
return "public\n"
|
||||
if "--get-services" in args:
|
||||
return "ssh http"
|
||||
if "ip" in args[0]:
|
||||
return ""
|
||||
if "--list-all-zones" in args:
|
||||
return ""
|
||||
|
||||
mock_run.side_effect = run_side
|
||||
descs = {"ssh": "OpenSSH", "http": "WWW"}
|
||||
mock_desc.return_value = descs
|
||||
result = _collect_firewall()
|
||||
mock_desc.assert_called_once_with()
|
||||
assert result["service_descriptions"] == descs
|
||||
|
||||
@patch("lib.state.run_proc")
|
||||
def test_collect_dnsmasq_returns_dict(self, mock_proc):
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -183,6 +183,62 @@ class TestStatusPending:
|
||||
assert result["total_changes"] == 0
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
|
||||
def test_firewall_uncovered_advisory_not_counted(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {"needs_apply": False, "pending": []},
|
||||
"uncovered_interfaces": ["eth1"],
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["firewall"]["uncovered_interfaces"] == ["eth1"]
|
||||
assert result["firewall"]["coverage_warnings"]
|
||||
assert "eth1" in result["firewall"]["coverage_warnings"][0]
|
||||
# Advisory: must not flip needs_apply or count as a change.
|
||||
assert not result["firewall"]["needs_apply"]
|
||||
assert result["firewall"]["change_count"] == 0
|
||||
assert result["total_changes"] == 0
|
||||
|
||||
def test_firewall_no_uncovered_no_warnings(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {"needs_apply": False, "pending": []},
|
||||
"uncovered_interfaces": [],
|
||||
},
|
||||
"dnsmasq": {"status": {"pending_changes": False}},
|
||||
"nginx": {"status": {"pending_changes": False}},
|
||||
"wireguard": {"status": {"pending_changes": False}},
|
||||
"networkd": {"status": {"pending_changes": False}},
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["firewall"]["uncovered_interfaces"] == []
|
||||
assert result["firewall"]["coverage_warnings"] == []
|
||||
assert result["total_changes"] == 0
|
||||
|
||||
def test_firewall_missing_uncovered_key_defaults_empty(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
"firewall": {
|
||||
"pending": {"needs_apply": False, "pending": []},
|
||||
},
|
||||
"dnsmasq": None,
|
||||
"nginx": None,
|
||||
"wireguard": None,
|
||||
"networkd": None,
|
||||
}
|
||||
):
|
||||
result = status.status_pending(None, None)
|
||||
assert result["firewall"]["uncovered_interfaces"] == []
|
||||
assert result["firewall"]["coverage_warnings"] == []
|
||||
|
||||
def test_firewall_no_pending_key(self):
|
||||
with self._patch_store(
|
||||
{
|
||||
|
||||
+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")
|
||||
|
||||
@@ -552,7 +552,9 @@ class TestImportFirewall:
|
||||
assert "zones" in cfg
|
||||
assert "public" in cfg["zones"]
|
||||
assert "internal" in cfg["zones"]
|
||||
assert cfg["zones"]["public"]["target"] == "DEFAULT"
|
||||
# Live target normalizes to "default" -> the target key is omitted
|
||||
# (key-absence is the canonical "unmanaged" notation, WI-2).
|
||||
assert "target" not in cfg["zones"]["public"]
|
||||
assert cfg["zones"]["public"]["interfaces"] == ["eth0", "eth1"]
|
||||
assert cfg["zones"]["public"]["services"] == [
|
||||
"dhcpv6-cidr",
|
||||
@@ -560,9 +562,31 @@ class TestImportFirewall:
|
||||
"mdns",
|
||||
"ssh",
|
||||
]
|
||||
assert cfg["zones"]["internal"]["target"] == "DEFAULT"
|
||||
assert "target" not in cfg["zones"]["internal"]
|
||||
assert cfg["zones"]["internal"]["interfaces"] == ["eth2"]
|
||||
|
||||
def test_import_keeps_nondefault_target(self, temp_project, tmp_path):
|
||||
# A zone with interfaces and a non-default live target keeps its
|
||||
# explicit target key (ACCEPT/DROP/REJECT remain fully managed).
|
||||
output = (
|
||||
"trusted (active)\n"
|
||||
" target: ACCEPT\n"
|
||||
" interfaces: eth3\n"
|
||||
" sources: \n"
|
||||
" services: \n"
|
||||
" ports: \n"
|
||||
" protocols: \n"
|
||||
" forward-ports: \n"
|
||||
" source-ports: \n"
|
||||
" icmp-blocks: \n"
|
||||
" rich rules: \n"
|
||||
)
|
||||
with patch("lib.system_import.run", return_value=output):
|
||||
assert system_import.import_firewall()
|
||||
cfg = self._read_json(tmp_path)
|
||||
assert cfg["zones"]["trusted"]["target"] == "ACCEPT"
|
||||
assert cfg["zones"]["trusted"]["interfaces"] == ["eth3"]
|
||||
|
||||
def test_empty_interface_zones_skipped(self, temp_project, tmp_path):
|
||||
with patch("lib.system_import.run", return_value=FIREWALL_ZONES_OUTPUT):
|
||||
assert system_import.import_firewall()
|
||||
|
||||
Reference in New Issue
Block a user