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
537 lines
19 KiB
Python
537 lines
19 KiB
Python
"""Tests for lib/state.py — state store and collect functions."""
|
|
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
from lib.state import State, state
|
|
|
|
|
|
class TestState:
|
|
def test_new_state_empty(self):
|
|
s = State()
|
|
assert s.get("firewall") is None
|
|
assert s.is_populated() is False
|
|
|
|
def test_set_and_get(self):
|
|
s = State()
|
|
s.set("firewall", {"zones": {"public": {}}})
|
|
assert s.get("firewall") == {"zones": {"public": {}}}
|
|
|
|
def test_populate_all(self):
|
|
s = State()
|
|
with patch.object(s, "_data", {}):
|
|
pass
|
|
# Just verify populate doesn't crash on empty collectors
|
|
# (our collect functions need subprocess, so test mocks only)
|
|
pass
|
|
|
|
def test_singleton_exists(self):
|
|
assert state is not None
|
|
assert isinstance(state, State)
|
|
|
|
def test_get_snapshot_empty(self):
|
|
"""Fresh store: snapshot lists every subsystem, all None."""
|
|
s = State()
|
|
snap = s.get_snapshot()
|
|
assert set(snap) == set(s.SUBSYSTEMS)
|
|
assert all(v is None for v in snap.values())
|
|
|
|
def test_get_snapshot_reflects_set_and_none(self):
|
|
"""Snapshot carries set data; failed collections stay None."""
|
|
s = State()
|
|
s.set("firewall", {"zones": {}})
|
|
s.set("system", {"load": {"load1": 0.0}})
|
|
s.set("dnsmasq", None)
|
|
snap = s.get_snapshot()
|
|
assert snap["firewall"] == {"zones": {}}
|
|
assert snap["system"] == {"load": {"load1": 0.0}}
|
|
assert snap["dnsmasq"] is None
|
|
assert snap["acme"] is None
|
|
|
|
|
|
class TestCollectAll:
|
|
@patch("lib.state.run")
|
|
def test_collect_firewall_returns_dict(self, mock_run):
|
|
from lib.state import _collect_firewall
|
|
|
|
def run_side(args, **kwargs):
|
|
if "--get-active-zones" in args:
|
|
return "public\n eth0"
|
|
if "--get-default-zone" in args:
|
|
return "public\n"
|
|
if "--get-services" in args:
|
|
return "ssh http"
|
|
if "ip" in args[0]:
|
|
if "link" in args:
|
|
return "1: lo: <LOOPBACK> mtu 65536\n2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
return ""
|
|
if "--list-all-zones" in args:
|
|
return (
|
|
"public\n"
|
|
" target: default\n"
|
|
" interfaces: eth0\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
)
|
|
|
|
mock_run.side_effect = run_side
|
|
result = _collect_firewall()
|
|
assert isinstance(result, dict)
|
|
assert "active_zones" in result
|
|
assert "default_zone" in result
|
|
assert result["default_zone"] == "public"
|
|
assert "interfaces" in result
|
|
assert "timestamp" in result
|
|
|
|
@patch("lib.state.run")
|
|
def test_collect_firewall_vlan_ips_populated(self, mock_run):
|
|
"""VLAN interfaces with @suffix in ip addr output get their IPs collected."""
|
|
from lib.state import _collect_firewall
|
|
|
|
def run_side(args, **kwargs):
|
|
if "--get-active-zones" in args:
|
|
return "public\n eth0\ninternal\n eth0.100"
|
|
if "--get-default-zone" in args:
|
|
return "public\n"
|
|
if "--get-services" in args:
|
|
return "ssh http"
|
|
if "ip" in args[0]:
|
|
if "link" in args:
|
|
return (
|
|
"1: lo: <LOOPBACK> mtu 65536\n"
|
|
"2: eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
"3: eth0.100@eth0: <UP> mtu 1500 link/ether aa:bb\n"
|
|
)
|
|
if "addr" in args:
|
|
return (
|
|
"2: eth0 inet 192.168.1.1/24\n"
|
|
"3: eth0.100@if100 inet 10.0.0.1/24\n"
|
|
)
|
|
return ""
|
|
if "--list-all-zones" in args:
|
|
return (
|
|
"public\n"
|
|
" target: default\n"
|
|
" interfaces: eth0\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
"internal\n"
|
|
" target: ACCEPT\n"
|
|
" interfaces: eth0.100\n"
|
|
" services: \n"
|
|
" ports: \n"
|
|
" protocols: \n"
|
|
" forward-ports: \n"
|
|
" masquerade: no\n"
|
|
" rich rules: \n"
|
|
)
|
|
|
|
mock_run.side_effect = run_side
|
|
result = _collect_firewall()
|
|
vlan_iface = next(
|
|
(i for i in result["interfaces"] if i["name"] == "eth0.100"), None
|
|
)
|
|
assert vlan_iface is not None, "VLAN interface should be present"
|
|
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
|
|
|
|
from lib.state import _collect_dnsmasq
|
|
|
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
|
result = _collect_dnsmasq()
|
|
assert isinstance(result, dict)
|
|
assert "status" in result
|
|
assert "config" in result
|
|
assert "leases" in result
|
|
|
|
@patch("lib.state.run_proc")
|
|
def test_collect_dnsmasq_pending_diff(self, mock_proc, tmp_path, monkeypatch):
|
|
from unittest.mock import Mock
|
|
|
|
from lib.common import _APPLY_HASH_KEY, _LAST_APPLIED_CONFIG_KEY
|
|
from lib.state import _collect_dnsmasq
|
|
|
|
(tmp_path / "config" / "dnsmasq").mkdir(parents=True)
|
|
applied = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "10.4.20.101",
|
|
"end": "10.4.20.200",
|
|
"lease_time": "1h",
|
|
"gateway": "10.4.20.1",
|
|
}
|
|
],
|
|
"static_leases": [],
|
|
},
|
|
"dns": {"upstreams": ["8.8.8.8"], "domain": None, "custom_records": []},
|
|
}
|
|
cfg = {
|
|
"dhcp": {
|
|
"ranges": [
|
|
{
|
|
"interface": "eth1",
|
|
"start": "10.4.20.100",
|
|
"end": "10.4.20.200",
|
|
"lease_time": "12h",
|
|
"gateway": "10.4.20.1",
|
|
}
|
|
],
|
|
"static_leases": [],
|
|
},
|
|
"dns": {
|
|
"upstreams": ["8.8.8.8", "1.1.1.1"],
|
|
"domain": None,
|
|
"custom_records": [],
|
|
},
|
|
_LAST_APPLIED_CONFIG_KEY: applied,
|
|
_APPLY_HASH_KEY: "stale-hash",
|
|
}
|
|
(tmp_path / "config" / "dnsmasq" / "config.json").write_text(json.dumps(cfg))
|
|
monkeypatch.setattr("lib.state.PROJECT_DIR", tmp_path)
|
|
# service check -> active; lease file read -> no lines
|
|
mock_proc.return_value = Mock(stdout="active\n", returncode=0)
|
|
|
|
result = _collect_dnsmasq()
|
|
assert result["status"]["pending_changes"] is True
|
|
paths = {d["path"] for d in result["status"]["pending_diff"]}
|
|
assert "dhcp.ranges[0].start" in paths
|
|
assert "dhcp.ranges[0].lease_time" in paths
|
|
# Apply metadata must not leak into the returned config.
|
|
assert _LAST_APPLIED_CONFIG_KEY not in result["config"]
|
|
assert _APPLY_HASH_KEY not in result["config"]
|
|
|
|
|
|
class TestCollectFailure:
|
|
def test_state_clears_on_failure(self):
|
|
"""State collection failure sets the subsystem to None."""
|
|
s = State()
|
|
s.set("firewall", {"zones": {"public": {}}})
|
|
s.set("firewall", None) # simulates failure
|
|
assert s.get("firewall") is None
|
|
assert s.is_populated() is False
|
|
|
|
|
|
class TestStateVersions:
|
|
def test_version_starts_at_zero(self):
|
|
s = State()
|
|
versions = s.get_versions()
|
|
assert versions["firewall"] == 0
|
|
assert versions["dnsmasq"] == 0
|
|
|
|
def test_bump_increments_version(self):
|
|
s = State()
|
|
assert s.get_versions()["firewall"] == 0
|
|
s.bump("firewall")
|
|
assert s.get_versions()["firewall"] == 1
|
|
|
|
def test_bump_unknown_subsystem_noop(self):
|
|
s = State()
|
|
versions = s.get_versions()
|
|
s.bump("nonexistent")
|
|
assert versions == s.get_versions()
|
|
|
|
def test_get_updated_versions_first_call_empty(self):
|
|
s = State()
|
|
s.bump("firewall")
|
|
updated = s.get_updated_versions()
|
|
assert updated == {}
|
|
assert s.get_updated_versions() == {}
|
|
|
|
def test_get_updated_versions_detects_change(self):
|
|
s = State()
|
|
_ = s.get_updated_versions() # snapshot
|
|
s.bump("firewall")
|
|
updated = s.get_updated_versions()
|
|
assert updated["firewall"] == 1
|
|
|
|
def test_broadcast_maintains_snapshot(self):
|
|
s = State()
|
|
s.bump("firewall")
|
|
s.bump("dnsmasq")
|
|
_ = s.get_updated_versions() # snapshot at fw=1, dm=1
|
|
s.bump("wireguard")
|
|
updated = s.get_updated_versions()
|
|
assert updated["wireguard"] == 1
|
|
assert s.get_updated_versions() == {}
|
|
|
|
def test_multiple_bumps_aggregate(self):
|
|
s = State()
|
|
_ = s.get_updated_versions()
|
|
s.bump("firewall")
|
|
s.bump("firewall")
|
|
s.bump("dnsmasq")
|
|
updated = s.get_updated_versions()
|
|
assert updated["firewall"] == 2
|
|
assert updated["dnsmasq"] == 1
|
|
|
|
|
|
class TestStripVolatile:
|
|
def test_list_of_dicts_strips_nested_keys(self):
|
|
"""Wireguard-style: status.peers[].transfer_received gets stripped."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"status": {
|
|
"peers": [
|
|
{
|
|
"transfer_received": "100B",
|
|
"transfer_sent": "200B",
|
|
"latest_handshake": "ago",
|
|
"public_key": "abc",
|
|
},
|
|
{
|
|
"transfer_received": "300B",
|
|
"transfer_sent": "400B",
|
|
"latest_handshake": "now",
|
|
"public_key": "def",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
vol = frozenset(
|
|
{"status.peers[].transfer_received", "status.peers[].latest_handshake"}
|
|
)
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["status"]["peers"][0]["transfer_received"] is None
|
|
assert stripped["status"]["peers"][0]["latest_handshake"] is None
|
|
assert stripped["status"]["peers"][0]["transfer_sent"] == "200B"
|
|
assert stripped["status"]["peers"][0]["public_key"] == "abc"
|
|
assert stripped["status"]["peers"][1]["transfer_received"] is None
|
|
|
|
def test_scalar_path_strips(self):
|
|
"""Scalar paths get zeroed to None."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {"a": {"b": 1, "c": 2}}
|
|
vol = frozenset({"a.b"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["a"]["b"] is None
|
|
assert stripped["a"]["c"] == 2
|
|
|
|
def test_empty_volatile_returns_copy(self):
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {"x": 1}
|
|
stripped = _strip_volatile(data, frozenset())
|
|
assert stripped == data
|
|
assert stripped is not data
|
|
|
|
def test_firewall_volatile_strips_ips(self):
|
|
"""Firewall-style: interfaces[].ips gets stripped."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"interfaces": [
|
|
{
|
|
"name": "eth0",
|
|
"ips": ["10.0.0.1/24"],
|
|
"ipv6": ["fe80::1"],
|
|
"zone": "internal",
|
|
},
|
|
]
|
|
}
|
|
vol = frozenset({"interfaces[].ips", "interfaces[].ipv6"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["interfaces"][0]["ips"] is None
|
|
assert stripped["interfaces"][0]["ipv6"] is None
|
|
assert stripped["interfaces"][0]["name"] == "eth0"
|
|
assert stripped["interfaces"][0]["zone"] == "internal"
|
|
|
|
def test_networkd_volatile_strips_addresses(self):
|
|
"""Networkd-style: interfaces dict keyed by name, addresses stripped via fallback."""
|
|
from lib.state import _strip_volatile
|
|
|
|
data = {
|
|
"interfaces": {
|
|
"eth0": {
|
|
"addresses": ["10.0.0.1/24", "fe80::1"],
|
|
"state": "routable",
|
|
"type": "ether",
|
|
},
|
|
"lo": {
|
|
"addresses": ["127.0.0.1/8"],
|
|
"state": "degraded",
|
|
"type": "loopback",
|
|
},
|
|
}
|
|
}
|
|
vol = frozenset({"interfaces[].addresses"})
|
|
stripped = _strip_volatile(data, vol)
|
|
assert stripped["interfaces"]["eth0"]["addresses"] is None
|
|
assert stripped["interfaces"]["eth0"]["state"] == "routable"
|
|
assert stripped["interfaces"]["eth0"]["type"] == "ether"
|
|
assert stripped["interfaces"]["lo"]["addresses"] is None
|
|
assert stripped["interfaces"]["lo"]["state"] == "degraded"
|
|
|
|
|
|
class TestDiffLayers:
|
|
def test_no_change(self):
|
|
"""Identical data (minus timestamp) returns (False, False)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {"zones": {"public": {}}, "timestamp": "t1"}
|
|
new = {"zones": {"public": {}}, "timestamp": "t2"}
|
|
structural, volatile = _diff_layers(old, new, frozenset())
|
|
assert structural is False
|
|
assert volatile is False
|
|
|
|
def test_structural_only(self):
|
|
"""Non-volatile change detected as structural."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"zones": {"public": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"zones": {"internal": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"interfaces[].ips"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is True
|
|
assert volatile is False
|
|
|
|
def test_volatile_only(self):
|
|
"""Only volatile fields changed returns (False, True)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"status": {"peers": [{"transfer_received": "200B", "public_key": "abc"}]},
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"status.peers[].transfer_received"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is False
|
|
assert volatile is True
|
|
|
|
def test_neither(self):
|
|
"""No change at all returns (False, False)."""
|
|
from lib.state import _diff_layers
|
|
|
|
old = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"status": {"peers": [{"transfer_received": "100B", "public_key": "abc"}]},
|
|
"timestamp": "t2",
|
|
}
|
|
vol = frozenset({"status.peers[].transfer_received"})
|
|
structural, volatile = _diff_layers(old, new, vol)
|
|
assert structural is False
|
|
assert volatile is False
|
|
|
|
def test_old_none_returns_both_true(self):
|
|
"""When old is None (first poll), both layers report True."""
|
|
from lib.state import _diff_layers
|
|
|
|
new = {"zones": {}}
|
|
structural, volatile = _diff_layers(None, new, frozenset())
|
|
assert structural is True
|
|
assert volatile is True
|
|
|
|
|
|
class TestPoll:
|
|
def test_poll_no_change(self):
|
|
"""poll() returns (False, False) when collector returns same data."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
data = {"value": 1, "timestamp": "t1"}
|
|
_COLLECTORS[name] = lambda: data
|
|
s.set(name, data)
|
|
structural, volatile = s.poll(name)
|
|
assert structural is False
|
|
assert volatile is False
|
|
del _COLLECTORS[name]
|
|
|
|
def test_poll_detects_structural(self):
|
|
"""poll() detects structural changes via collector."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, _VOLATILE, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
old = {
|
|
"zones": {"public": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t1",
|
|
}
|
|
new = {
|
|
"zones": {"internal": {}},
|
|
"interfaces": [{"ips": None}],
|
|
"timestamp": "t2",
|
|
}
|
|
_COLLECTORS[name] = lambda: new
|
|
_VOLATILE[name] = frozenset({"interfaces[].ips"})
|
|
s.set(name, old)
|
|
structural, _volatile = s.poll(name)
|
|
assert structural is True
|
|
del _COLLECTORS[name]
|
|
del _VOLATILE[name]
|
|
|
|
def test_poll_failure_returns_no_broadcast(self):
|
|
"""poll() returns (False, False) and preserves existing data on collector failure."""
|
|
import uuid
|
|
|
|
from lib.state import _COLLECTORS, State
|
|
|
|
name = f"test_{uuid.uuid4().hex}"
|
|
s = State()
|
|
s.set(name, {"value": 1})
|
|
_COLLECTORS[name] = lambda: (_ for _ in ()).throw(RuntimeError("fail"))
|
|
structural, volatile = s.poll(name)
|
|
assert structural is False
|
|
assert volatile is False
|
|
assert s.get(name) == {"value": 1}
|
|
del _COLLECTORS[name]
|