Files
DECNET/decnet/web/router/topology/api_tarpit.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

203 lines
6.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-or-later
"""POST/GET/DELETE /api/v1/topologies/{topology_id}/deckies/{decky_name}/tarpit
Same tc netem logic as the fleet tarpit, but scoped to a MazeNET topology.
Container name is resolved via resolve_decky_container so the SSH-suffix /
decnet_t_ convention is handled transparently.
Auth: require_admin for write operations, require_viewer for GET.
"""
from __future__ import annotations
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Path
from decnet.decky_io.resolve import resolve_decky_container
from decnet.logging import get_logger
from decnet.network import get_container_pid, get_container_veth
from decnet.web.db.models import (
MessageResponse,
TarpitEnableRequest,
TarpitRuleResponse,
TarpitStatusResponse,
)
from decnet.web.dependencies import repo, require_admin, require_viewer
from decnet.web.router.deckies.api_tarpit import (
_apply_tarpit,
_get_active_connections,
_remove_tarpit,
)
log = get_logger("api.topology.tarpit")
_TOPO_RE = r"^[a-zA-Z0-9\-]{1,64}$"
_DECKY_RE = r"^[a-z0-9\-]{1,64}$"
router = APIRouter(
prefix="/{topology_id}/deckies/{decky_name}/tarpit",
tags=["Topologies"],
)
def _db_key(topology_id: str, decky_name: str) -> str:
"""Namespace topology tarpit rules away from fleet rules."""
return f"t:{topology_id}:{decky_name}"
@router.post(
"",
response_model=MessageResponse,
status_code=201,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Decky not found in topology"},
409: {"description": "tc command failed (qdisc already exists or container unreachable)"},
},
)
async def api_enable_tarpit(
topology_id: str = Path(..., pattern=_TOPO_RE),
decky_name: str = Path(..., pattern=_DECKY_RE),
req: TarpitEnableRequest = ..., # type: ignore[assignment]
admin: dict = Depends(require_admin),
) -> MessageResponse:
try:
container = await resolve_decky_container(repo, decky_name, topology_id=topology_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
veth = await asyncio.to_thread(get_container_veth, container)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
await asyncio.to_thread(_apply_tarpit, veth, req.ports, req.delay_ms)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
db_key = _db_key(topology_id, decky_name)
ports_json = json.dumps(req.ports)
await repo.set_tarpit_rule({
"decky_name": db_key,
"ports": ports_json,
"delay_ms": req.delay_ms,
"created_by": admin.get("uuid", "unknown"),
})
await repo.add_log({
"decky": decky_name,
"service": "tarpit",
"event_type": "tarpit_enabled",
"attacker_ip": "0.0.0.0", # nosec B104
"raw_line": (
f"tarpit enabled topology={topology_id} decky={decky_name}"
f" ports={req.ports} delay={req.delay_ms}ms"
f" by={admin.get('uuid', 'unknown')}"
),
"fields": json.dumps({
"topology_id": topology_id,
"ports": req.ports,
"delay_ms": req.delay_ms,
"veth": veth,
"container": container,
"operator": admin.get("uuid"),
}),
})
log.info(
"tarpit enabled topology=%s decky=%s ports=%s delay_ms=%d veth=%s by=%s",
topology_id, decky_name, req.ports, req.delay_ms, veth, admin.get("uuid"),
)
return MessageResponse(message="tarpit active")
@router.get(
"",
response_model=TarpitStatusResponse,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "No active tarpit rule for this decky"},
},
)
async def api_get_tarpit(
topology_id: str = Path(..., pattern=_TOPO_RE),
decky_name: str = Path(..., pattern=_DECKY_RE),
_viewer: dict = Depends(require_viewer),
) -> TarpitStatusResponse:
db_key = _db_key(topology_id, decky_name)
rule = await repo.get_tarpit_rule(db_key)
if rule is None:
raise HTTPException(status_code=404, detail="No active tarpit rule for this decky")
conns: list[dict] = []
try:
container = await resolve_decky_container(repo, decky_name, topology_id=topology_id)
pid = await asyncio.to_thread(get_container_pid, container)
raw_conns = await asyncio.to_thread(_get_active_connections, pid, rule["ports"])
for c in raw_conns:
conns.append({"ip": c["ip"], "port": c["port"]})
except LookupError:
pass
return TarpitStatusResponse(
rule=TarpitRuleResponse(**{**rule, "decky_name": decky_name}),
active_connections=conns,
)
@router.delete(
"",
response_model=MessageResponse,
responses={
401: {"description": "Could not validate credentials"},
403: {"description": "Insufficient permissions"},
404: {"description": "Decky container not found"},
409: {"description": "tc teardown failed"},
},
)
async def api_disable_tarpit(
topology_id: str = Path(..., pattern=_TOPO_RE),
decky_name: str = Path(..., pattern=_DECKY_RE),
admin: dict = Depends(require_admin),
) -> MessageResponse:
try:
container = await resolve_decky_container(repo, decky_name, topology_id=topology_id)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
veth = await asyncio.to_thread(get_container_veth, container)
except LookupError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
try:
await asyncio.to_thread(_remove_tarpit, veth)
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
db_key = _db_key(topology_id, decky_name)
await repo.delete_tarpit_rule(db_key)
await repo.add_log({
"decky": decky_name,
"service": "tarpit",
"event_type": "tarpit_disabled",
"attacker_ip": "0.0.0.0", # nosec B104
"raw_line": (
f"tarpit disabled topology={topology_id} decky={decky_name}"
f" by={admin.get('uuid', 'unknown')}"
),
"fields": json.dumps({
"topology_id": topology_id,
"veth": veth,
"container": container,
"operator": admin.get("uuid"),
}),
})
log.info(
"tarpit disabled topology=%s decky=%s veth=%s by=%s",
topology_id, decky_name, veth, admin.get("uuid"),
)
return MessageResponse(message="tarpit removed")