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

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