feat(tarpit): MazeNET topology-scoped tarpit — Inspector controls + topology API

This commit is contained in:
2026-04-29 21:10:02 -04:00
parent f84c66cf9b
commit 917f7e8e54
5 changed files with 369 additions and 1 deletions

View File

@@ -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.

View File

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

View File

@@ -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<void>;
/** Tarpit controls — only shown when topology is active/degraded and node is a deployed decky. */
onLiveTarpitEnable?: (nodeName: string, ports: number[], delayMs: number) => Promise<void>;
onLiveTarpitDisable?: (nodeName: string) => Promise<void>;
onAddDecky?: (netId: string) => void;
setSelection?: (sel: Selection) => void;
pendingChanges?: number;
@@ -57,6 +60,7 @@ const Inspector: React.FC<Props> = ({
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService,
onLiveAddService, onLiveRemoveService, availableServices = [],
onToggleGateway,
onLiveTarpitEnable, onLiveTarpitDisable,
onAddDecky, setSelection,
pendingChanges = 0,
className = '',
@@ -69,6 +73,22 @@ const Inspector: React.FC<Props> = ({
const [addSlug, setAddSlug] = useState('');
const [busy, setBusy] = useState<string | null>(null); // slug currently mutating
const [opError, setOpError] = useState<string | null>(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<string | undefined>(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<Props> = ({
: (isGateway ? 'DEMOTE GATEWAY' : 'PROMOTE TO GATEWAY')}
</button>
)}
{tarpitEnabled && !isObserved && (
<div className="inspector-tarpit-wrap">
<div className="inspector-tarpit-row">
<button
type="button"
className={`maze-btn small ${tarpitOpen ? 'active' : ''}`}
disabled={busy === '__tarpit__'}
onClick={() => setTarpitOpen((o) => !o)}
title="Configure tc netem tarpit on this decky"
>
<Shield size={12} />
{tarpitOpen ? 'CANCEL' : 'TARPIT'}
</button>
<button
type="button"
className="maze-btn alert small"
disabled={busy === '__tarpit__'}
title="Remove active tarpit rule"
onClick={async () => {
setOpError(null);
setBusy('__tarpit__');
try {
await onLiveTarpitDisable!(node.name);
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail ?? 'Tarpit disable failed.';
setOpError(msg);
} finally {
setBusy(null);
}
}}
>
{busy === '__tarpit__' ? '…' : 'DISABLE'}
</button>
</div>
{tarpitOpen && (
<div className="inspector-tarpit-form">
<div className="inspector-tarpit-field">
<label className="type-label">PORTS</label>
<input
className="maze-input"
value={tarpitPorts}
placeholder="22,80,443"
onChange={(e) => setTarpitPorts(e.target.value)}
/>
</div>
<div className="inspector-tarpit-field">
<label className="type-label">
DELAY · {tarpitDelay >= 1000
? `${(tarpitDelay / 1000).toFixed(0)}s`
: `${tarpitDelay}ms`}
</label>
<input
type="range"
min={100}
max={60000}
step={100}
value={tarpitDelay}
onChange={(e) => setTarpitDelay(parseInt(e.target.value, 10))}
style={{ width: '100%' }}
/>
</div>
<button
type="button"
className="maze-btn alert small"
disabled={busy === '__tarpit__' || !tarpitPorts.trim()}
onClick={async () => {
const ports = tarpitPorts
.split(',')
.map((p) => parseInt(p.trim(), 10))
.filter((p) => !isNaN(p) && p > 0 && p <= 65535);
if (!ports.length) return;
setOpError(null);
setBusy('__tarpit__');
try {
await onLiveTarpitEnable!(node.name, ports, tarpitDelay);
setTarpitOpen(false);
} catch (err) {
const msg = (err as { response?: { data?: { detail?: string } } })
?.response?.data?.detail ?? 'Tarpit enable failed.';
setOpError(msg);
} finally {
setBusy(null);
}
}}
>
{busy === '__tarpit__' ? 'APPLYING…' : 'APPLY TARPIT'}
</button>
</div>
)}
</div>
)}
{onDeleteNode && (
<button
type="button"

View File

@@ -588,3 +588,43 @@ body.maze-fullscreen .maze-shell {
padding: 6px 10px; font-size: 0.7rem; color: var(--violet);
box-shadow: var(--violet-glow);
}
/* Tarpit controls in the Inspector node panel */
.inspector-tarpit-wrap {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.inspector-tarpit-row {
display: flex;
gap: 6px;
}
.inspector-tarpit-form {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: rgba(255, 65, 65, 0.04);
border: 1px solid rgba(255, 65, 65, 0.3);
}
.inspector-tarpit-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.maze-input {
font-size: 0.72rem;
padding: 4px 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
color: var(--text-color, #e0e0e0);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.maze-btn.active {
background: rgba(57, 255, 20, 0.1);
border-color: var(--matrix);
}

View File

@@ -926,6 +926,19 @@ const MazeNET: React.FC = () => {
onLiveAddService={requestAddService}
onLiveRemoveService={liveRemoveService}
onToggleGateway={toggleGateway}
onLiveTarpitEnable={async (nodeName, ports, delayMs) => {
await axios.post(
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/tarpit`,
{ ports, delay_ms: delayMs },
);
pushToast({ text: `TARPIT ON · ${nodeName.toUpperCase()} · ${ports.join(',')} / ${delayMs >= 1000 ? `${delayMs / 1000}s` : `${delayMs}ms`}`, tone: 'matrix', icon: 'shield' });
}}
onLiveTarpitDisable={async (nodeName) => {
await axios.delete(
`/topologies/${encodeURIComponent(topologyId)}/deckies/${encodeURIComponent(nodeName)}/tarpit`,
);
pushToast({ text: `TARPIT OFF · ${nodeName.toUpperCase()}`, tone: 'matrix', icon: 'shield' });
}}
onAddDecky={(netId) => {
const net = nets.find((n) => n.id === netId);
if (!net) return;