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_mutations import router as _mutations_router
|
||||||
from .api_personas import router as _personas_router
|
from .api_personas import router as _personas_router
|
||||||
from .api_reap_orphans import router as _reap_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
|
from .api_teardown_topology import router as _teardown_router
|
||||||
|
|
||||||
topology_router = APIRouter(prefix="/topologies", tags=["topologies"])
|
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(_edge_router)
|
||||||
topology_router.include_router(_mutations_router)
|
topology_router.include_router(_mutations_router)
|
||||||
topology_router.include_router(_events_router)
|
topology_router.include_router(_events_router)
|
||||||
|
topology_router.include_router(_tarpit_router)
|
||||||
# Personas use a literal-suffix path (`/{id}/personas`) — register
|
# Personas use a literal-suffix path (`/{id}/personas`) — register
|
||||||
# before the bare `/{id}` getter so FastAPI's trie sees the literal
|
# before the bare `/{id}` getter so FastAPI's trie sees the literal
|
||||||
# segment first.
|
# 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 {
|
import {
|
||||||
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
|
ArrowLeft, ArrowRight, Crosshair, Globe, GitMerge, MousePointer2, Plus,
|
||||||
Server, Trash2, X, Shield,
|
Server, Trash2, X, Shield,
|
||||||
@@ -46,6 +46,9 @@ interface Props {
|
|||||||
* destructive-recreate confirm dialog and the ``force: true`` submit
|
* destructive-recreate confirm dialog and the ``force: true`` submit
|
||||||
* — this prop just relays the user's intent. */
|
* — this prop just relays the user's intent. */
|
||||||
onToggleGateway?: (nodeId: string, nextValue: boolean) => Promise<void>;
|
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;
|
onAddDecky?: (netId: string) => void;
|
||||||
setSelection?: (sel: Selection) => void;
|
setSelection?: (sel: Selection) => void;
|
||||||
pendingChanges?: number;
|
pendingChanges?: number;
|
||||||
@@ -57,6 +60,7 @@ const Inspector: React.FC<Props> = ({
|
|||||||
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService,
|
onDeleteNet, onDeleteNode, onDeleteEdge, onRemoveService,
|
||||||
onLiveAddService, onLiveRemoveService, availableServices = [],
|
onLiveAddService, onLiveRemoveService, availableServices = [],
|
||||||
onToggleGateway,
|
onToggleGateway,
|
||||||
|
onLiveTarpitEnable, onLiveTarpitDisable,
|
||||||
onAddDecky, setSelection,
|
onAddDecky, setSelection,
|
||||||
pendingChanges = 0,
|
pendingChanges = 0,
|
||||||
className = '',
|
className = '',
|
||||||
@@ -69,6 +73,22 @@ const Inspector: React.FC<Props> = ({
|
|||||||
const [addSlug, setAddSlug] = useState('');
|
const [addSlug, setAddSlug] = useState('');
|
||||||
const [busy, setBusy] = useState<string | null>(null); // slug currently mutating
|
const [busy, setBusy] = useState<string | null>(null); // slug currently mutating
|
||||||
const [opError, setOpError] = useState<string | null>(null);
|
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 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 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;
|
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')}
|
: (isGateway ? 'DEMOTE GATEWAY' : 'PROMOTE TO GATEWAY')}
|
||||||
</button>
|
</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 && (
|
{onDeleteNode && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -588,3 +588,43 @@ body.maze-fullscreen .maze-shell {
|
|||||||
padding: 6px 10px; font-size: 0.7rem; color: var(--violet);
|
padding: 6px 10px; font-size: 0.7rem; color: var(--violet);
|
||||||
box-shadow: var(--violet-glow);
|
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}
|
onLiveAddService={requestAddService}
|
||||||
onLiveRemoveService={liveRemoveService}
|
onLiveRemoveService={liveRemoveService}
|
||||||
onToggleGateway={toggleGateway}
|
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) => {
|
onAddDecky={(netId) => {
|
||||||
const net = nets.find((n) => n.id === netId);
|
const net = nets.find((n) => n.id === netId);
|
||||||
if (!net) return;
|
if (!net) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user