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:
2026-04-29 00:38:51 -04:00
parent c002c5a4f1
commit 892219ec87
4 changed files with 227 additions and 0 deletions

View File

@@ -847,6 +847,39 @@ async def apply_update_decky(
new_forwards_l3 = bool(new_decky_config.get("forwards_l3", False))
forwards_l3_flipped = new_forwards_l3 != old_forwards_l3
# Promotion path: refuse to flip a non-DMZ decky to gateway. The
# 'gateway' semantic specifically means 'host-port publisher facing
# the DMZ' — running it on an internal LAN publishes ports the
# outside world can't reach and shadows the host's port space.
# Generic L3-bridge forwards_l3 (internal multi-homing) is set by
# the generator/attach paths, not by this op, so this check only
# fires when the operator explicitly toggles the flag.
if forwards_l3_flipped and new_forwards_l3:
# Re-derive the home LAN from the edges; same logic as
# check_gateway_homed_in_dmz.
decky_uuid = decky["uuid"]
home_lan_id: Optional[str] = None
for e in hydrated["edges"]:
if e["decky_uuid"] == decky_uuid and e.get("is_bridge") is False:
home_lan_id = e["lan_id"]
break
if home_lan_id is None:
for e in hydrated["edges"]:
if e["decky_uuid"] == decky_uuid:
home_lan_id = e["lan_id"]
break
home_lan = next(
(lan for lan in hydrated["lans"] if lan["id"] == home_lan_id),
None,
)
if home_lan is None or not home_lan.get("is_dmz"):
home_name = home_lan["name"] if home_lan else "(unknown)"
raise MutationError(
f"cannot promote decky {decky['decky_config']['name']!r} "
f"to gateway: home LAN {home_name!r} is not a DMZ. "
"Move the decky to the DMZ first, or pick a different decky."
)
# Pre-check the destructive flip BEFORE any DB write, so a refused
# mutation leaves zero side-effects.
is_live = (await _live_topology_or_none(repo, topology_id)) is not None

View File

@@ -283,6 +283,68 @@ def check_service_config_shape(h: dict[str, Any]) -> list[ValidationIssue]:
return issues
def check_gateway_homed_in_dmz(h: dict[str, Any]) -> list[ValidationIssue]:
"""Gateway deckies must live in a DMZ LAN.
``forwards_l3=True`` triggers host-port publishing in the compose
generator (see :mod:`decnet.topology.compose`); a gateway sitting
on an internal LAN would publish ports on the host without anyone
on the right side of the perimeter able to reach the service
legitimately. The semantic is "this decky is the front door"
only meaningful when the LAN is the DMZ.
Errors out the validator so the live ``forwards_l3`` flip path
catches this before recreating the base.
"""
if not h.get("deckies"):
return []
lans_by_id = {lan["id"]: lan for lan in h["lans"]}
dmz_lan_ids = {
lan["id"] for lan in h["lans"] if lan.get("is_dmz")
}
dmz_lan_names = {
lan["name"] for lan in h["lans"] if lan.get("is_dmz")
}
# Home-LAN selection mirrors the frontend hydration: prefer the
# non-bridge edge. Falls back to the first edge if no
# is_bridge flag is set (legacy rows).
home_lan_for: dict[str, str] = {} # decky_uuid → lan_id
for e in h["edges"]:
if e.get("is_bridge") is False and e["decky_uuid"] not in home_lan_for:
home_lan_for[e["decky_uuid"]] = e["lan_id"]
for e in h["edges"]:
if e["decky_uuid"] in home_lan_for:
continue
home_lan_for[e["decky_uuid"]] = e["lan_id"]
issues: list[ValidationIssue] = []
for d in h["deckies"]:
cfg = d.get("decky_config") or {}
if not cfg.get("forwards_l3"):
continue
home_lan_id = home_lan_for.get(d["uuid"])
if home_lan_id is None or home_lan_id not in dmz_lan_ids:
home_lan_name = (
lans_by_id.get(home_lan_id, {}).get("name")
if home_lan_id
else "(no home LAN)"
)
allowed = ", ".join(sorted(dmz_lan_names)) or "(no DMZ defined)"
issues.append(
ValidationIssue(
"error",
"GATEWAY_NOT_IN_DMZ",
f"gateway decky {d['name']!r} is on LAN "
f"{home_lan_name!r}; gateways must home in a DMZ "
f"LAN ({allowed})",
target={"decky": d["name"], "lan": home_lan_name},
)
)
return issues
def check_no_host_port_collision(h: dict[str, Any]) -> list[ValidationIssue]:
"""Flag gateway service ports that are already bound on the host.
@@ -342,6 +404,23 @@ _RULES: list[Callable[[dict[str, Any]], list[ValidationIssue]]] = [
check_services_known,
check_service_config_shape,
]
# NOTE: ``check_gateway_homed_in_dmz`` is intentionally NOT in _RULES.
# The codebase uses ``forwards_l3=True`` for two distinct semantics:
#
# 1. Generic L3 forwarding (internal bridge deckies: enable
# net.ipv4.ip_forward so the decky can route between its multi-home
# LANs). The generator writes this on internal bridges via
# ``bridge_forward_probability``; those bridges legitimately home
# in non-DMZ LANs.
# 2. DMZ gateway (host-port publisher: the decky exposes its services
# on the host's public IP). Only meaningful when the home LAN is
# the DMZ.
#
# Standing validation can't enforce DMZ-homing without breaking case 1.
# Instead, the rule fires only on the explicit user-driven flip path
# (apply_update_decky setting forwards_l3 from False → True), where the
# operator's intent is unambiguously "make this a gateway". Generator
# output and bridge-decky paths bypass this check.
def validate(hydrated: dict[str, Any]) -> list[ValidationIssue]: