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:
@@ -277,6 +277,9 @@ async def test_update_decky_forwards_l3_flip_with_force_recreates_base(
|
||||
):
|
||||
tid = await _make_active(repo)
|
||||
deckies = await repo.list_topology_deckies(tid)
|
||||
# _make_active produces a single-LAN topology where that LAN is the
|
||||
# DMZ; both deckies home there, so promoting deckies[0] to gateway
|
||||
# is valid (passes the DMZ-homing guard).
|
||||
target = deckies[0]
|
||||
target_name = target["decky_config"]["name"]
|
||||
|
||||
@@ -295,6 +298,35 @@ async def test_update_decky_forwards_l3_flip_with_force_recreates_base(
|
||||
assert found, "expected force-recreate up against the base"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_update_decky_refuses_gateway_promotion_on_non_dmz_lan(
|
||||
repo, stubs,
|
||||
):
|
||||
"""Promoting a decky homed on an internal LAN to gateway must fail."""
|
||||
tid = await _make_active(repo)
|
||||
# Add an internal LAN + a decky homed there.
|
||||
from decnet.mutator.ops import apply_add_lan, apply_add_decky
|
||||
await apply_add_lan(repo, tid, {
|
||||
"name": "internal", "subnet": "10.99.0.0/24", "is_dmz": False,
|
||||
})
|
||||
await apply_add_decky(repo, tid, {
|
||||
"name": "internalbox", "lan": "internal", "services": ["ssh"],
|
||||
})
|
||||
stubs["compose_with_retry"].reset_mock()
|
||||
|
||||
with pytest.raises(MutationError, match="not a DMZ"):
|
||||
await apply_update_decky(repo, tid, {
|
||||
"decky": "internalbox",
|
||||
"patch": {"forwards_l3": True},
|
||||
"force": True,
|
||||
})
|
||||
|
||||
# No recreate should have fired — refused mutations leave zero
|
||||
# side-effects.
|
||||
for call in stubs["compose_with_retry"].call_args_list:
|
||||
assert "--force-recreate" not in call.args
|
||||
|
||||
|
||||
# ---------------- apply_update_lan -------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user