diff --git a/decnet_web/src/components/MazeNET/data.ts b/decnet_web/src/components/MazeNET/data.ts new file mode 100644 index 00000000..fe670a9b --- /dev/null +++ b/decnet_web/src/components/MazeNET/data.ts @@ -0,0 +1,71 @@ +import type { Net, MazeNode, Edge } from './types'; + +export interface Archetype { + slug: string; + name: string; + services: string[]; + icon: string; +} + +export interface ServiceDef { + slug: string; + name: string; + port: number; + proto: 'tcp' | 'udp'; + icon: string; + risk: 'low' | 'med' | 'high'; +} + +export const ARCHETYPES: Archetype[] = [ + { slug: 'linux-server', name: 'Linux Server', services: ['ssh', 'http'], icon: 'server' }, + { slug: 'windows-workstation', name: 'Windows Workstation', services: ['smb', 'rdp'], icon: 'monitor' }, + { slug: 'domain-controller', name: 'Domain Controller', services: ['smb', 'rdp', 'ldap', 'llmnr', 'kerberos'], icon: 'shield' }, + { slug: 'database-server', name: 'Database Server', services: ['mysql', 'postgres', 'redis'], icon: 'database' }, + { slug: 'iot-device', name: 'IoT / OT Device', services: ['modbus', 'mqtt', 'coap'], icon: 'cpu' }, + { slug: 'web-application', name: 'Web Application', services: ['http', 'https'], icon: 'globe' }, +]; + +export const DEFAULT_SERVICES: ServiceDef[] = [ + { slug: 'ssh', name: 'SSH', port: 22, proto: 'tcp', icon: 'terminal', risk: 'high' }, + { slug: 'http', name: 'HTTP', port: 80, proto: 'tcp', icon: 'globe', risk: 'med' }, + { slug: 'https', name: 'HTTPS', port: 443, proto: 'tcp', icon: 'lock', risk: 'med' }, + { slug: 'ftp', name: 'FTP', port: 21, proto: 'tcp', icon: 'folder', risk: 'high' }, + { slug: 'smb', name: 'SMB', port: 445, proto: 'tcp', icon: 'hard-drive', risk: 'high' }, + { slug: 'rdp', name: 'RDP', port: 3389, proto: 'tcp', icon: 'monitor', risk: 'high' }, + { slug: 'ldap', name: 'LDAP', port: 389, proto: 'tcp', icon: 'users', risk: 'med' }, + { slug: 'kerberos', name: 'Kerberos', port: 88, proto: 'tcp', icon: 'key-round', risk: 'med' }, + { slug: 'llmnr', name: 'LLMNR', port: 5355, proto: 'udp', icon: 'radio', risk: 'low' }, + { slug: 'mysql', name: 'MySQL', port: 3306, proto: 'tcp', icon: 'database', risk: 'high' }, + { slug: 'postgres', name: 'Postgres', port: 5432, proto: 'tcp', icon: 'database', risk: 'high' }, + { slug: 'redis', name: 'Redis', port: 6379, proto: 'tcp', icon: 'zap', risk: 'med' }, + { slug: 'mqtt', name: 'MQTT', port: 1883, proto: 'tcp', icon: 'wifi', risk: 'low' }, + { slug: 'modbus', name: 'Modbus', port: 502, proto: 'tcp', icon: 'cpu', risk: 'med' }, + { slug: 'coap', name: 'CoAP', port: 5683, proto: 'udp', icon: 'wifi', risk: 'low' }, +]; + +/* Demo seed mirroring design-handoff/.../MazeNET.jsx INITIAL_* */ +export const DEMO_NETS: Net[] = [ + { id: 'net-internet', label: 'INTERNET', cidr: '0.0.0.0/0', kind: 'internet', x: 40, y: 40, w: 240, h: 220 }, + { id: 'net-dmz', label: 'DMZ', cidr: '10.4.2.0/24', kind: 'subnet', x: 340, y: 40, w: 340, h: 260 }, + { id: 'net-corp', label: 'CORP-LAN', cidr: '10.20.0.0/16', kind: 'subnet', x: 340, y: 340, w: 340, h: 240 }, + { id: 'net-vault', label: 'DB-VAULT', cidr: '10.88.1.0/24', kind: 'subnet', x: 740, y: 200, w: 260, h: 220 }, +]; + +export const DEMO_NODES: MazeNode[] = [ + { id: 'n-scan', kind: 'observed', netId: 'net-internet', name: 'SCANNERS', archetype: 'attacker-pool', services: ['*'], status: 'hot', x: 60, y: 80 }, + { id: 'n-edge', kind: 'decky', netId: 'net-dmz', name: 'decky-01', archetype: 'linux-server', services: ['ssh', 'http'], status: 'active', x: 20, y: 60 }, + { id: 'n-jump', kind: 'decky', netId: 'net-dmz', name: 'decky-03', archetype: 'linux-server', services: ['ssh'], status: 'hot', x: 180, y: 60 }, + { id: 'n-web', kind: 'decky', netId: 'net-dmz', name: 'decky-07', archetype: 'web-application', services: ['http'], status: 'active', x: 20, y: 160 }, + { id: 'n-ws', kind: 'decky', netId: 'net-corp', name: 'decky-02', archetype: 'windows-workstation', services: ['smb', 'rdp'], status: 'active', x: 20, y: 60 }, + { id: 'n-dc', kind: 'decky', netId: 'net-corp', name: 'decky-05', archetype: 'domain-controller', services: ['ldap', 'smb'], status: 'active', x: 180, y: 60 }, + { id: 'n-db', kind: 'decky', netId: 'net-vault', name: 'decky-12', archetype: 'database-server', services: ['mysql', 'postgres'], status: 'active', x: 50, y: 80 }, +]; + +export const DEMO_EDGES: Edge[] = [ + { id: 'e1', from: 'n-scan', to: 'n-edge', traffic: 'hot', label: 'TCP 443' }, + { id: 'e2', from: 'n-scan', to: 'n-jump', traffic: 'hot', label: 'TCP 22' }, + { id: 'e3', from: 'n-edge', to: 'n-ws', traffic: 'active', label: '' }, + { id: 'e4', from: 'n-jump', to: 'n-dc', traffic: 'hot', label: 'LAT-MOV' }, + { id: 'e5', from: 'n-dc', to: 'n-db', traffic: 'active', label: '' }, + { id: 'e6', from: 'n-web', to: 'n-db', traffic: 'active', label: 'SQL' }, +]; diff --git a/decnet_web/src/components/MazeNET/types.ts b/decnet_web/src/components/MazeNET/types.ts new file mode 100644 index 00000000..8aa33674 --- /dev/null +++ b/decnet_web/src/components/MazeNET/types.ts @@ -0,0 +1,60 @@ +export type NetKind = 'internet' | 'subnet' | 'dmz'; + +export interface Net { + id: string; + label: string; + cidr: string; + kind: NetKind; + x: number; + y: number; + w: number; + h: number; +} + +export type NodeKind = 'decky' | 'observed'; + +interface NodeBase { + id: string; + netId: string; + name: string; + archetype: string; + services: string[]; + status: 'active' | 'idle' | 'hot' | 'mutating'; + x: number; + y: number; +} + +export interface DeckyNode extends NodeBase { + kind: 'decky'; + ip?: string; + decky_config?: Record; + mutate_interval?: number | null; +} + +export interface ObservedNode extends NodeBase { + kind: 'observed'; + archetype: 'attacker-pool'; + services: ['*']; +} + +export type MazeNode = DeckyNode | ObservedNode; + +export interface Edge { + id: string; + from: string; + to: string; + traffic: 'hot' | 'active' | 'idle'; + label?: string; +} + +/* ── Pending changes — mirrors Phase-3 MutationEnqueueRequest.op ── */ +export type PendingChange = + | { op: 'add_lan'; payload: { id: string; label: string; cidr: string; x: number; y: number; w: number; h: number } } + | { op: 'remove_lan'; payload: { id: string } } + | { op: 'update_lan'; payload: { id: string; patch: Partial } } + | { op: 'attach_decky'; payload: { nodeId: string; netId: string; archetype: string; name: string; x: number; y: number; services: string[] } } + | { op: 'detach_decky'; payload: { nodeId: string; netId: string } } + | { op: 'remove_decky'; payload: { nodeId: string } } + | { op: 'update_decky'; payload: { nodeId: string; patch: Partial } } + | { op: 'add_edge'; payload: { id: string; from: string; to: string } } + | { op: 'remove_edge'; payload: { id: string } }; diff --git a/decnet_web/src/components/MazeNET/useMazeApi.ts b/decnet_web/src/components/MazeNET/useMazeApi.ts new file mode 100644 index 00000000..10d884a5 --- /dev/null +++ b/decnet_web/src/components/MazeNET/useMazeApi.ts @@ -0,0 +1,186 @@ +import { useCallback } from 'react'; +import api from '../../utils/api'; +import { DEFAULT_SERVICES } from './data'; +import type { ServiceDef } from './data'; +import type { Net, MazeNode, Edge, DeckyNode, PendingChange } from './types'; + +interface LANRow { + id: string; + name: string; + subnet: string; + is_dmz: boolean; + x?: number | null; + y?: number | null; +} + +interface DeckyRow { + uuid: string; + name: string; + services: string[]; + decky_config?: Record | null; + ip?: string | null; + state: string; + x?: number | null; + y?: number | null; +} + +interface EdgeRow { + id: string; + decky_uuid: string; + lan_id: string; + is_bridge: boolean; + forwards_l3: boolean; +} + +interface TopologySummary { + id: string; + name: string; + mode: string; + status: string; + version: number; +} + +interface TopologyDetail { + topology: TopologySummary; + lans: LANRow[]; + deckies: DeckyRow[]; + edges: EdgeRow[]; +} + +interface HydratedTopology { + topology: TopologySummary; + nets: Net[]; + nodes: MazeNode[]; + edges: Edge[]; +} + +/** Adapt the Phase-3 TopologyDetail wire shape to canvas entities. + * Backend edges are decky↔LAN membership (bipartite); we surface them + * as node-in-net placement. Decky-to-decky traffic edges are derived + * from shared-LAN co-membership for now (Step 4 may refine this). */ +export function adaptTopology(detail: TopologyDetail): HydratedTopology { + const nets: Net[] = detail.lans.map((lan, i) => ({ + id: lan.id, + label: lan.name.toUpperCase(), + cidr: lan.subnet, + kind: lan.is_dmz ? 'dmz' : 'subnet', + x: lan.x ?? 40 + (i % 3) * 320, + y: lan.y ?? 40 + Math.floor(i / 3) * 280, + w: 300, + h: 240, + })); + + /* A decky sits in the first LAN it attaches to. */ + const firstLanFor = new Map(); + for (const e of detail.edges) { + if (!firstLanFor.has(e.decky_uuid)) firstLanFor.set(e.decky_uuid, e.lan_id); + } + + const nodes: MazeNode[] = detail.deckies.map((d, i): DeckyNode => ({ + kind: 'decky', + id: d.uuid, + netId: firstLanFor.get(d.uuid) ?? (nets[0]?.id ?? ''), + name: d.name, + archetype: 'linux-server', + services: d.services, + status: d.state === 'running' ? 'active' : d.state === 'failed' ? 'hot' : 'idle', + x: d.x ?? 20 + (i % 2) * 160, + y: d.y ?? 60 + Math.floor(i / 2) * 90, + ip: d.ip ?? undefined, + decky_config: d.decky_config ?? undefined, + })); + + /* Derive decky-to-decky edges from shared-LAN membership. */ + const byLan = new Map(); + for (const e of detail.edges) { + const arr = byLan.get(e.lan_id) ?? []; + arr.push(e.decky_uuid); + byLan.set(e.lan_id, arr); + } + const seen = new Set(); + const edges: Edge[] = []; + for (const [lanId, members] of byLan) { + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + const key = `${members[i]}::${members[j]}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + id: `${lanId}-${members[i]}-${members[j]}`, + from: members[i], + to: members[j], + traffic: 'idle', + }); + } + } + } + + return { topology: detail.topology, nets, nodes, edges }; +} + +export interface MazeApi { + listTopologies: () => Promise; + getTopology: (id: string) => Promise; + getServices: () => Promise; + getNextIp: (topologyId: string, lanId: string) => Promise; + getNextSubnet: (base: string) => Promise; + commit: (topologyId: string, changes: PendingChange[]) => Promise; +} + +export function useMazeApi(toast?: (msg: string) => void): MazeApi { + const listTopologies = useCallback(async () => { + const { data } = await api.get('/topologies/'); + return (data?.data ?? []) as TopologySummary[]; + }, []); + + const getTopology = useCallback(async (id: string) => { + const { data } = await api.get(`/topologies/${id}`); + return adaptTopology(data); + }, []); + + const getServices = useCallback(async () => { + try { + const { data } = await api.get<{ services: string[] }>('/topologies/services'); + const known = new Map(DEFAULT_SERVICES.map((s) => [s.slug, s])); + return data.services.map( + (slug) => + known.get(slug) ?? { + slug, + name: slug.toUpperCase(), + port: 0, + proto: 'tcp' as const, + icon: 'circle', + risk: 'low' as const, + }, + ); + } catch { + return DEFAULT_SERVICES; + } + }, []); + + const getNextIp = useCallback(async (topologyId: string, lanId: string) => { + const { data } = await api.get<{ subnet: string; ip: string }>( + `/topologies/${topologyId}/lans/${lanId}/next-ip`, + ); + return data.ip; + }, []); + + const getNextSubnet = useCallback(async (base: string) => { + const { data } = await api.get<{ subnet: string }>( + `/topologies/next-subnet`, + { params: { base } }, + ); + return data.subnet; + }, []); + + const commit = useCallback( + async (_topologyId: string, changes: PendingChange[]) => { + /* Phase-3 Steps 3–5 land the real endpoints. For now, just surface. */ + console.log('[MazeNET] commit stub — pending changes:', changes); + toast?.(`commit stubbed (${changes.length} change${changes.length === 1 ? '' : 's'})`); + }, + [toast], + ); + + return { listTopologies, getTopology, getServices, getNextIp, getNextSubnet, commit }; +}