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
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user