Files
DECNET/decnet/web/router/topology/api_create_blank_topology.py
anti f2b3393669 chore: relicense to AGPL-3.0-or-later and add SPDX headers
Replaces LICENSE (GPLv3 -> AGPLv3) and prepends
`SPDX-License-Identifier: AGPL-3.0-or-later` to every source file
across decnet/, decnet_web/, tests/, scripts/, and tools/.

Rationale: closes the GPLv3 ASP loophole so any party operating a
modified DECNET as a network service must offer their modified
source. Personal copyright (Samuel Paschuan) + inbound=outbound
contributions make a future unilateral relicense infeasible.

- LICENSE: full AGPL-3.0 text (gnu.org/licenses/agpl-3.0.txt)
- COPYRIGHT: project copyright notice
- tools/add_spdx_headers.py: idempotent header injector
  (shebang- and PEP 263-aware)

Touches 1565 source files (.py, .ts, .tsx, .js, .jsx, .css, .sh).
No behavior change; comments only.
2026-05-22 21:04:16 -04:00

125 lines
4.3 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""POST /topologies/blank — create an empty editable topology.
Produces a minimal ``pending`` topology seeded with exactly one DMZ LAN
and its mandatory host-gateway decky. Intended for the MazeNET editor
landing flow: unlike ``POST /topologies`` (which runs the generator),
this endpoint takes no generator parameters and skips the planner
entirely. The DMZ+gateway invariant is enforced server-side so the
editor never has to special-case a "no DMZ yet" state.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field as PydanticField
from decnet.telemetry import traced as _traced
from decnet.topology.allocator import SubnetAllocator, reserved_subnets
from decnet.web.db.models import TopologySummary
from decnet.web.dependencies import repo, require_admin
from decnet.web.router.topology._target_host import validate_target_host
router = APIRouter()
class BlankTopologyRequest(BaseModel):
"""Body for POST /topologies/blank — name plus optional agent pinning."""
name: str = PydanticField(..., min_length=1, max_length=64)
mode: str = PydanticField(default="unihost", pattern=r"^(unihost|agent)$")
target_host_uuid: str | None = PydanticField(default=None)
@router.post(
"/blank",
tags=["MazeNET Topologies"],
response_model=TopologySummary,
status_code=status.HTTP_201_CREATED,
responses={
400: {"description": "Malformed body or invalid topology name"},
401: {"description": "Missing or invalid credentials"},
403: {"description": "Insufficient permissions"},
409: {"description": "Name collision or subnet pool exhausted"},
},
)
@_traced("api.topology.create_blank")
async def api_create_blank_topology(
body: BlankTopologyRequest,
_admin: dict = Depends(require_admin),
) -> TopologySummary:
# 0. Validate mode/host pairing before any writes.
await validate_target_host(repo, body.mode, body.target_host_uuid)
# 1. Topology row
try:
topology_id = await repo.create_topology(
{
"name": body.name,
"mode": body.mode,
"target_host_uuid": body.target_host_uuid,
"status": "pending",
"config_snapshot": json.dumps({"blank": True}),
}
)
except Exception as exc: # noqa: BLE001 — surface duplicate-name as 409
raise HTTPException(status_code=409, detail=str(exc)) from exc
# 2. DMZ LAN with auto-allocated subnet
try:
allocator = SubnetAllocator(
"10.0", reserved=await reserved_subnets(repo)
)
subnet = allocator.next_free()
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
lan_id = await repo.add_lan(
{
"topology_id": topology_id,
"name": "dmz",
"subnet": subnet,
"is_dmz": True,
"x": 40,
"y": 40,
}
)
# 3. DMZ-gateway decky — a normal multi-homed bridge decky.
# `forwards_l3=True` turns on net.ipv4.ip_forward + NET_ADMIN at
# compose time (see decnet/topology/compose.py). No host-mode,
# no MACVLAN — the gateway reaches the outside world via Docker
# port publishing (see composer port emission).
decky_uuid = await repo.add_topology_decky(
{
"topology_id": topology_id,
"name": "dmz-gateway",
"services": ["ssh"],
"decky_config": {
"archetype": "deaddeck",
"forwards_l3": True,
},
"state": "pending",
"x": 20,
"y": 60,
}
)
# 4. Membership edge on the DMZ — is_bridge=True marks this decky
# as the topology's bridge gateway; forwards_l3 mirrors the decky
# config so the generator/compose paths stay consistent.
await repo.add_topology_edge(
{
"topology_id": topology_id,
"decky_uuid": decky_uuid,
"lan_id": lan_id,
"is_bridge": True,
"forwards_l3": True,
}
)
row = await repo.get_topology(topology_id)
if row is None: # pragma: no cover — create then vanish
raise HTTPException(status_code=500, detail="topology insert vanished")
return row