feat(tarpit): MazeNET topology-scoped tarpit — Inspector controls + topology API
This commit is contained in:
@@ -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.
|
||||
|
||||
201
decnet/web/router/topology/api_tarpit.py
Normal file
201
decnet/web/router/topology/api_tarpit.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user