feat(mutator): refuse forwards_l3 promotion on non-DMZ deckies
apply_update_decky's flip path now refuses to promote a decky to
gateway unless its home LAN is a DMZ. The compose generator publishes
host ports for forwards_l3=True; a non-DMZ gateway would shadow the
host's port space without anything legitimately able to reach the
service. Same posture as the existing 'forwards_l3 flip on live
requires force=true' guard — refused before any DB write so a bad
mutation leaves zero side-effects.
The check is intentionally NOT a standing _RULES invariant — the
codebase uses forwards_l3 for two semantics:
1. Generic L3 forwarding (internal bridge deckies routing between
their multi-home LANs). The generator writes this on internal
bridges via bridge_forward_probability; legitimately non-DMZ.
2. DMZ gateway (host-port publisher). Only meaningful on DMZ.
Standing validation can't enforce DMZ-homing without breaking case 1.
The guard fires only on the explicit user-driven flip path where the
operator's intent is unambiguously case 2. Generator output and
internal-bridge attachments bypass the check.
check_gateway_homed_in_dmz lives in validate.py for callers that want
the explicit form (and for the test surface), but is not a standing
rule — comment in _RULES explains the asymmetry.
This commit is contained in:
@@ -12,6 +12,7 @@ from decnet.topology.persistence import hydrate, persist
|
||||
from decnet.topology.status import TopologyStatus
|
||||
from decnet.topology.validate import (
|
||||
ValidationError,
|
||||
check_gateway_homed_in_dmz,
|
||||
errors,
|
||||
validate,
|
||||
)
|
||||
@@ -176,3 +177,85 @@ async def test_deploy_aborts_on_validation_error(repo, tmp_path, monkeypatch):
|
||||
|
||||
topo = await repo.get_topology(tid)
|
||||
assert topo["status"] == TopologyStatus.PENDING
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- gateway-in-dmz
|
||||
|
||||
|
||||
def _make_hydrated(*, dmz_id="dmz-id", internal_id="int-id") -> dict:
|
||||
"""Tiny hand-rolled hydrated dict for hermetic check_* unit tests."""
|
||||
return {
|
||||
"topology": {"id": "t", "status": "pending"},
|
||||
"lans": [
|
||||
{"id": dmz_id, "name": "dmz", "subnet": "10.0.0.0/24", "is_dmz": True},
|
||||
{"id": internal_id, "name": "internal", "subnet": "10.0.1.0/24", "is_dmz": False},
|
||||
],
|
||||
"deckies": [],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
|
||||
def test_check_gateway_homed_in_dmz_passes_when_gateway_is_in_dmz() -> None:
|
||||
h = _make_hydrated()
|
||||
h["deckies"].append({
|
||||
"uuid": "d1", "name": "gw",
|
||||
"decky_config": {"name": "gw", "forwards_l3": True},
|
||||
"services": ["ssh"],
|
||||
})
|
||||
h["edges"].append({
|
||||
"decky_uuid": "d1", "lan_id": "dmz-id",
|
||||
"is_bridge": False, "forwards_l3": True,
|
||||
})
|
||||
assert check_gateway_homed_in_dmz(h) == []
|
||||
|
||||
|
||||
def test_check_gateway_homed_in_dmz_fails_when_gateway_is_internal() -> None:
|
||||
h = _make_hydrated()
|
||||
h["deckies"].append({
|
||||
"uuid": "d1", "name": "gw",
|
||||
"decky_config": {"name": "gw", "forwards_l3": True},
|
||||
"services": ["ssh"],
|
||||
})
|
||||
# Home edge points at the internal LAN, not the DMZ.
|
||||
h["edges"].append({
|
||||
"decky_uuid": "d1", "lan_id": "int-id",
|
||||
"is_bridge": False, "forwards_l3": True,
|
||||
})
|
||||
issues = check_gateway_homed_in_dmz(h)
|
||||
assert len(issues) == 1
|
||||
assert issues[0].code == "GATEWAY_NOT_IN_DMZ"
|
||||
|
||||
|
||||
def test_check_gateway_homed_in_dmz_ignores_non_gateway_deckies() -> None:
|
||||
h = _make_hydrated()
|
||||
h["deckies"].append({
|
||||
"uuid": "d1", "name": "web",
|
||||
"decky_config": {"name": "web"}, # forwards_l3 absent
|
||||
"services": ["ssh"],
|
||||
})
|
||||
h["edges"].append({
|
||||
"decky_uuid": "d1", "lan_id": "int-id",
|
||||
"is_bridge": False,
|
||||
})
|
||||
assert check_gateway_homed_in_dmz(h) == []
|
||||
|
||||
|
||||
def test_check_gateway_homed_in_dmz_uses_non_bridge_edge_as_home() -> None:
|
||||
"""Multi-homed gateway: home is the non-bridge edge, not the bridge edge."""
|
||||
h = _make_hydrated()
|
||||
h["deckies"].append({
|
||||
"uuid": "d1", "name": "gw",
|
||||
"decky_config": {"name": "gw", "forwards_l3": True},
|
||||
"services": ["ssh"],
|
||||
})
|
||||
# Bridge edge first (would be picked by a naive 'first edge' rule).
|
||||
h["edges"].append({
|
||||
"decky_uuid": "d1", "lan_id": "int-id",
|
||||
"is_bridge": True, "forwards_l3": False,
|
||||
})
|
||||
h["edges"].append({
|
||||
"decky_uuid": "d1", "lan_id": "dmz-id",
|
||||
"is_bridge": False, "forwards_l3": True,
|
||||
})
|
||||
# Home is the DMZ via the non-bridge edge → no issue.
|
||||
assert check_gateway_homed_in_dmz(h) == []
|
||||
|
||||
Reference in New Issue
Block a user