From 917f7e8e549cb59effe39fe1249128f72114ba04 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 29 Apr 2026 21:10:02 -0400 Subject: [PATCH] =?UTF-8?q?feat(tarpit):=20MazeNET=20topology-scoped=20tar?= =?UTF-8?q?pit=20=E2=80=94=20Inspector=20controls=20+=20topology=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- decnet/web/router/topology/__init__.py | 2 + decnet/web/router/topology/api_tarpit.py | 201 ++++++++++++++++++ .../src/components/MazeNET/Inspector.tsx | 114 +++++++++- decnet_web/src/components/MazeNET/MazeNET.css | 40 ++++ decnet_web/src/components/MazeNET/MazeNET.tsx | 13 ++ 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 decnet/web/router/topology/api_tarpit.py diff --git a/decnet/web/router/topology/__init__.py b/decnet/web/router/topology/__init__.py index a251a064..10b06ddf 100644 --- a/decnet/web/router/topology/__init__.py +++ b/decnet/web/router/topology/__init__.py @@ -23,6 +23,7 @@ from .api_list_topologies import router as _list_router from .api_mutations import router as _mutations_router from .api_personas import router as _personas_router from .api_reap_orphans import router as _reap_router +from .api_tarpit import router as _tarpit_router from .api_teardown_topology import router as _teardown_router topology_router = APIRouter(prefix="/topologies", tags=["topologies"]) @@ -45,6 +46,7 @@ topology_router.include_router(_decky_router) topology_router.include_router(_edge_router) topology_router.include_router(_mutations_router) topology_router.include_router(_events_router) +topology_router.include_router(_tarpit_router) # Personas use a literal-suffix path (`/{id}/personas`) — register # before the bare `/{id}` getter so FastAPI's trie sees the literal # segment first. diff --git a/decnet/web/router/topology/api_tarpit.py b/decnet/web/router/topology/api_tarpit.py new file mode 100644 index 00000000..5eef8a96 --- /dev/null +++ b/decnet/web/router/topology/api_tarpit.py @@ -0,0 +1,201 @@ +"""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 = ..., + 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") diff --git a/decnet_web/src/components/MazeNET/Inspector.tsx b/decnet_web/src/components/MazeNET/Inspector.tsx index 71c450be..81f9c1f7 100644 --- a/decnet_web/src/components/MazeNET/Inspector.tsx +++ b/decnet_web/src/components/MazeNET/Inspector.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useEffect, useState } from 'react'; import { ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus, Server, Trash2, X, Shield, @@ -46,6 +46,9 @@ interface Props { * destructive-recreate confirm dialog and the ``force: true`` submit * — this prop just relays the user's intent. */ onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise; + /** Tarpit controls — only shown when topology is active/degraded and node is a deployed decky. */ + onLiveTarpitEnable?: (nodeName: string, ports: number[], delayMs: number) => Promise; + onLiveTarpitDisable?: (nodeName: string) => Promise; onAddDecky?: (netId: string) => void; setSelection?: (sel: Selection) => void; pendingChanges?: number; @@ -57,6 +60,7 @@ const Inspector: React.FC = ({ onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService, onLiveAddService, onLiveRemoveService, availableServices = [], onToggleGateway, + onLiveTarpitEnable, onLiveTarpitDisable, onAddDecky, setSelection, pendingChanges = 0, className = '', @@ -69,6 +73,22 @@ const Inspector: React.FC = ({ const [addSlug, setAddSlug] = useState(''); const [busy, setBusy] = useState(null); // slug currently mutating const [opError, setOpError] = useState(null); + + // Tarpit state — local form, fires parent callbacks + const [tarpitOpen, setTarpitOpen] = useState(false); + const [tarpitPorts, setTarpitPorts] = useState('22'); + const [tarpitDelay, setTarpitDelay] = useState(30000); + const tarpitEnabled = liveOpsEnabled && !!onLiveTarpitEnable && !!onLiveTarpitDisable; + + // Close tarpit form when selection changes + const prevNodeId = useRef(undefined); + useEffect(() => { + const nodeId = selection?.type === 'node' ? selection.id : undefined; + if (nodeId !== prevNodeId.current) { + prevNodeId.current = nodeId; + setTarpitOpen(false); + } + }, [selection]); const net = selection?.type === 'net' ? nets.find((n) => n.id === selection.id) : undefined; const node = selection?.type === 'node' ? nodes.find((n) => n.id === selection.id) : undefined; const edge = selection?.type === 'edge' ? edges.find((e) => e.id === selection.id) : undefined; @@ -308,6 +328,98 @@ const Inspector: React.FC = ({ : (isGateway ? 'DEMOTE GATEWAY' : 'PROMOTE TO GATEWAY')} )} + {tarpitEnabled && !isObserved && ( +
+
+ + +
+ {tarpitOpen && ( +
+
+ + setTarpitPorts(e.target.value)} + /> +
+
+ + setTarpitDelay(parseInt(e.target.value, 10))} + style={{ width: '100%' }} + /> +
+ +
+ )} +
+ )} {onDeleteNode && (