feat(mazenet): auto-bridge new LANs to the DMZ gateway

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.
This commit is contained in:
2026-04-20 23:07:19 -04:00
parent 3618c59d08
commit be4e1b1891
2 changed files with 55 additions and 2 deletions

View File

@@ -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)"

View File

@@ -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"],