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