From be4e1b1891539dd1492cfbbbb74dad21a42b69d8 Mon Sep 17 00:00:00 2001 From: anti Date: Mon, 20 Apr 2026 23:07:19 -0400 Subject: [PATCH] feat(mazenet): auto-bridge new LANs to the DMZ gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a non-DMZ LAN is created via POST /lans, look up the topology's gateway (decky with forwards_l3=True attached to the DMZ) and insert an edge binding it to the new LAN. The gateway becomes multi-homed to every internal LAN automatically, so DMZ_ORPHAN cannot arise from ordinary editor use. Also fixes delete_lan: the home-decky guard used scalar_one_or_none, which blew up when the gateway already had >1 'other' LAN edge. Switch to scalars().first() — we only need to know *some* other edge exists, not a unique one. --- decnet/web/db/sqlmodel_repo.py | 2 +- decnet/web/router/topology/api_lan_crud.py | 55 +++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/decnet/web/db/sqlmodel_repo.py b/decnet/web/db/sqlmodel_repo.py index 08b8e19e..b9c0ed76 100644 --- a/decnet/web/db/sqlmodel_repo.py +++ b/decnet/web/db/sqlmodel_repo.py @@ -1172,7 +1172,7 @@ class SQLModelRepository(BaseRepository): TopologyEdge.lan_id != lan_id, ) ) - if other.scalar_one_or_none() is None: + if other.scalars().first() is None: raise ValueError( f"cannot delete LAN {lan.name!r}: decky " f"{decky_uuid} has no other LAN (would be orphaned)" diff --git a/decnet/web/router/topology/api_lan_crud.py b/decnet/web/router/topology/api_lan_crud.py index 6a427e95..147fc01e 100644 --- a/decnet/web/router/topology/api_lan_crud.py +++ b/decnet/web/router/topology/api_lan_crud.py @@ -8,6 +8,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Response, status +from decnet.logging import get_logger from decnet.telemetry import traced as _traced from decnet.topology.allocator import reserved_subnets from decnet.topology.status import ( @@ -19,6 +20,7 @@ from decnet.web.dependencies import repo, require_admin from ._guards import assert_pending_or_409, map_repo_exception +log = get_logger("api.topology.lan") router = APIRouter() @@ -51,7 +53,7 @@ async def api_create_lan( from decnet.topology.allocator import SubnetAllocator allocator = SubnetAllocator( - "10.0.0.0/16", reserved=await reserved_subnets(repo) + "10.0", reserved=await reserved_subnets(repo) ) subnet = allocator.next_free() @@ -74,9 +76,60 @@ async def api_create_lan( row = next((r for r in rows if r["id"] == lan_id), None) if row is None: # pragma: no cover — would mean insert vanished raise HTTPException(status_code=500, detail="LAN insert vanished") + + # Auto-bridge: if this is a non-DMZ LAN, attach the topology's + # DMZ gateway (the decky with forwards_l3=True that lives on the + # DMZ LAN) to it. Satisfies the DMZ_ORPHAN invariant by + # construction — every internal LAN always has a bridge path. + if not body.is_dmz: + try: + await _auto_attach_gateway(topology_id, lan_id) + except Exception as exc: + # Best-effort: if the gateway is missing or the edge can't + # be written, the deploy-time validator will surface it. + log.warning( + "auto-bridge skipped for LAN %s in topology %s: %s", + lan_id, topology_id, exc, + ) + return LANRow(**row) +async def _auto_attach_gateway(topology_id: str, new_lan_id: str) -> None: + """Attach the topology's DMZ gateway to a newly created non-DMZ LAN.""" + lans = await repo.list_lans_for_topology(topology_id) + dmz = next((lan for lan in lans if lan.get("is_dmz")), None) + if dmz is None: + return + + edges = await repo.list_topology_edges(topology_id) + gateway_uuid: str | None = None + for e in edges: + if e["lan_id"] != dmz["id"]: + continue + if e.get("forwards_l3"): + gateway_uuid = e["decky_uuid"] + break + if gateway_uuid is None: + return + + if any( + e["decky_uuid"] == gateway_uuid and e["lan_id"] == new_lan_id + for e in edges + ): + return + + await repo.add_topology_edge( + { + "topology_id": topology_id, + "decky_uuid": gateway_uuid, + "lan_id": new_lan_id, + "is_bridge": True, + "forwards_l3": True, + } + ) + + @router.patch( "/{topology_id}/lans/{lan_id}", tags=["MazeNET Topologies"],