feat: T1007 System Service Discovery - fixed allowlist probe in posture heartbeat

This commit is contained in:
AetherForge
2026-05-30 23:16:50 -07:00
parent 4207f6c21b
commit d010292333
26 changed files with 2499 additions and 134 deletions

View File

@@ -0,0 +1,157 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import BuilderPage, { formatBytes } from './BuilderPage';
import { ForgeProvider } from '../context/ForgeContext';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
function renderBuilder(initialEntries = ['/forge']) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<ForgeProvider>
<BuilderPage />
</ForgeProvider>
</MemoryRouter>
);
}
describe('formatBytes', () => {
it('formats sub-kilobyte values as bytes', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('formats kilobytes and megabytes with two decimals', () => {
expect(formatBytes(2048)).toBe('2.00 KB');
expect(formatBytes(5 * 1024 * 1024)).toBe('5.00 MB');
});
it('formats gigabytes and terabytes', () => {
expect(formatBytes(3 * 1024 * 1024 * 1024)).toBe('3.00 GB');
expect(formatBytes(2 * 1024 * 1024 * 1024 * 1024)).toBe('2.00 TB');
});
});
describe('BuilderPage', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'listBuilds').mockResolvedValue([]);
vi.spyOn(api, 'buildAgent').mockResolvedValue({
success: true,
file_name: 'worker-1.exe',
file_size: 4 * 1024 * 1024,
download_url: '/api/v1/builds/test/download',
fusion_enabled: false,
obfuscated: false,
signed: false,
});
vi.spyOn(api, 'listBlueprints').mockResolvedValue([]);
vi.spyOn(api, 'saveBlueprint').mockResolvedValue({ name: 'my-blueprint', size: 1024, created_at: '' });
vi.spyOn(api, 'cancelBuild').mockResolvedValue({ ok: true });
});
afterEach(() => {
cleanup();
});
it('shows loading hero before defaults arrive', () => {
renderBuilder();
expect(screen.getByRole('heading', { level: 1, name: 'The Forge' })).toBeInTheDocument();
expect(screen.getByText('Loading forge defaults from server...')).toBeInTheDocument();
});
it('renders forge form with identity field labels after load', async () => {
renderBuilder();
expect(await screen.findByRole('heading', { level: 2, name: 'Quick Forge' })).toBeInTheDocument();
expect(screen.getByText('INSTALLER FORGE')).toBeInTheDocument();
expect(screen.getByText('Worker Name')).toBeInTheDocument();
expect(screen.getByText('Control Endpoint')).toBeInTheDocument();
expect(screen.getByText('XMR Wallet Address')).toBeInTheDocument();
expect(api.getConfig).toHaveBeenCalled();
expect(api.getServerInfo).toHaveBeenCalled();
expect(api.listBuilds).toHaveBeenCalled();
});
it('surfaces load failure when server info cannot be fetched', async () => {
vi.spyOn(api, 'getConfig').mockRejectedValue(new Error('offline'));
renderBuilder();
expect(
await screen.findByText('Failed to load server info — is the control server running?')
).toBeInTheDocument();
});
it('shows LAN endpoint chips from server info', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
expect(screen.getByRole('button', { name: mockServerInfo.suggested_url! })).toBeInTheDocument();
});
it('switches to advanced mode and reveals pool configuration', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
expect(await screen.findByRole('heading', { level: 2, name: 'Build Miner Installer' })).toBeInTheDocument();
expect(screen.getByText('FORGE RULES — READ THIS ONCE')).toBeInTheDocument();
expect(screen.getByText('Pool Host')).toBeInTheDocument();
});
it('submits forge when preflight passes', async () => {
const buildSpy = vi.spyOn(api, 'buildAgent');
renderBuilder();
await screen.findByRole('button', { name: /FORGE INSTALLER/i });
await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i }));
await waitFor(() => {
expect(buildSpy).toHaveBeenCalled();
});
expect(await screen.findByText('worker-1.exe')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument();
});
it('blocks forge when preflight has errors (empty wallet)', async () => {
renderBuilder();
const wallet = await screen.findByDisplayValue(/^4A+/);
fireEvent.change(wallet, { target: { value: '' } });
const forgeBtn = screen.getByRole('button', { name: /FIX .* ERROR/i });
expect(forgeBtn).toBeDisabled();
expect(screen.getByText('PREFLIGHT CROSS-CHECK')).toBeInTheDocument();
});
it('persists simple vs advanced mode in localStorage', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Advanced' }));
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('advanced');
await userEvent.setup().click(screen.getByRole('button', { name: 'Simple' }));
expect(localStorage.getItem('aetherforge-forge-mode')).toBe('simple');
});
it('loads blueprint list when Load Blueprint is clicked', async () => {
vi.spyOn(api, 'listBlueprints').mockResolvedValue([
{ name: 'office-preset', size: 2048, created_at: '2026-05-30T12:00:00Z' },
]);
renderBuilder();
await screen.findByRole('button', { name: /Load Blueprint/i });
await userEvent.setup().click(screen.getByRole('button', { name: /Load Blueprint/i }));
expect(await screen.findByText('Saved Blueprints')).toBeInTheDocument();
expect(screen.getByText('office-preset')).toBeInTheDocument();
expect(api.listBlueprints).toHaveBeenCalled();
});
it('shows fusion deliverable hint when Fusion card is selected', async () => {
renderBuilder();
await screen.findByRole('heading', { level: 2, name: 'Quick Forge' });
await userEvent.setup().click(
screen.getByRole('button', { name: /Hide miner in any file/i })
);
expect(
screen.getByText(/Fusion selected — drop your files below and forge/i)
).toBeInTheDocument();
});
});

View File

@@ -33,7 +33,7 @@ import {
} from '../help/fusionMedia';
import './Pages.css';
function formatBytes(n: number): string {
export function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB'];
let v = n / 1024;
@@ -635,7 +635,7 @@ export default function BuilderPage() {
fusionPrepFile,
]);
if (loadingDefaults || !form) {
if (loadingDefaults) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
@@ -649,6 +649,22 @@ export default function BuilderPage() {
);
}
if (!form) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">INSTALLER FORGE</p>
<h1>The Forge</h1>
</div>
</header>
<NeonCard accent="brass">
<p>{error || 'Failed to load forge defaults from server.'}</p>
</NeonCard>
</div>
);
}
const installPreview = previewInstallPath({
install_base: form.install_base,
install_custom_base: form.install_custom_base,
@@ -946,7 +962,7 @@ export default function BuilderPage() {
<input
type="text"
className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`}
placeholder="4... or 8... (95106 characters)"
placeholder="4... or 8... (90106 characters)"
value={form.wallet}
onChange={(e) => updateField('wallet', e.target.value)}
required
@@ -954,7 +970,7 @@ export default function BuilderPage() {
/>
{form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && (
<p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}>
Wallet address looks short Monero addresses are 95106 characters starting with 4 or 8.
Wallet address looks short Monero addresses are 90106 characters starting with 4 or 8.
</p>
)}
<FieldHint field="wallet" />

View File

@@ -226,6 +226,34 @@
50% { opacity: 0.45; }
}
/* ── T1007 service row ─────────────────────────────────────────────────────── */
.cn-services {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
border-top: 1px solid rgba(255,255,255,0.05);
padding-top: 4px;
}
.cn-svc {
display: flex;
align-items: center;
gap: 3px;
font-size: 0.6rem;
font-family: var(--font-tech);
padding: 1px 5px;
border-radius: 3px;
letter-spacing: 0.05em;
cursor: default;
}
.cn-svc .svc-dot { font-size: 0.55rem; }
.cn-svc.svc-run { color: #00ff88; background: rgba(0,255,136,0.07); }
.cn-svc.svc-stop { color: #888; background: rgba(128,128,128,0.07); text-decoration: line-through; opacity: 0.7; }
.cn-svc.svc-unk { color: #555; background: rgba(80,80,80,0.05); }
/* ── Row: Groups + Actions ───────────────────────────────────────────── */
.crucible-row {

View File

@@ -1,7 +1,7 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import type { Agent } from '../types';
import type { Agent, AgentService } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import { formatHashrate } from '../help/fleetFilters';
import './CruciblePage.css';
@@ -78,6 +78,15 @@ function postureTooltip(agent: Agent): string {
if (agent.reboot_pending !== undefined) {
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
}
if (agent.services?.length) {
lines.push('──────────────────────');
lines.push('Services (T1007):');
for (const svc of agent.services) {
const icon = svc.status === 'running' ? '●' : svc.status === 'stopped' ? '○' : '—';
const st = svc.start_type !== 'unknown' ? ` [${svc.start_type}]` : '';
lines.push(` ${icon} ${svc.display_name ?? svc.name}${st}`);
}
}
return lines.join('\n');
}
@@ -96,6 +105,42 @@ function rebootBadge(agent: Agent): { label: string; cls: string } | null {
return null; // no badge when not pending — cleaner UI
}
// ── Service helpers (T1007) ────────────────────────────────────────────────
// Human-readable label for well-known service names
const SVC_LABELS: Record<string, string> = {
sshd: 'SSH',
ssh: 'SSH',
'openssh ssh server': 'SSH',
cloudflared: 'CF Tunnel',
wuauserv: 'WU',
windefend: 'Defender',
ufw: 'UFW',
fail2ban: 'Fail2Ban',
};
function svcLabel(svc: AgentService): string {
return SVC_LABELS[svc.name.toLowerCase()] ?? svc.display_name ?? svc.name;
}
function svcDot(status: string): string {
if (status === 'running') return '●';
if (status === 'stopped') return '○';
return '—';
}
function svcDotClass(status: string): string {
if (status === 'running') return 'svc-run';
if (status === 'stopped') return 'svc-stop';
return 'svc-unk';
}
// Only surface services that are interesting to show (skip self-service clutter)
const IMPORTANT_SVCS = new Set(['sshd', 'ssh', 'openssh ssh server', 'cloudflared', 'wuauserv', 'windefend']);
function importantServices(svcs: AgentService[]): AgentService[] {
return svcs.filter(s => IMPORTANT_SVCS.has(s.name.toLowerCase()) || s.status === 'running');
}
function platformIcon(platform?: string): string {
if (!platform) return '⬡';
const p = platform.toLowerCase();
@@ -472,6 +517,20 @@ export default function CruciblePage() {
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
)}
</div>
{a.services && a.services.length > 0 && (
<div className="cn-services">
{importantServices(a.services).map(svc => (
<span
key={svc.name}
className={`cn-svc ${svcDotClass(svc.status)}`}
title={`${svc.display_name ?? svc.name} status: ${svc.status} start: ${svc.start_type}`}
>
<span className="svc-dot">{svcDot(svc.status)}</span>
{svcLabel(svc)}
</span>
))}
</div>
)}
</div>
</div>
);

View File

@@ -0,0 +1,148 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SettingsPage, { deepMerge } from './SettingsPage';
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
import { api } from '../api/client';
import { clearStoredAuth, getStoredAuth, setStoredAuth } from '../api/auth';
function renderSettings() {
return render(<SettingsPage />);
}
describe('deepMerge', () => {
it('preserves base keys not present in override', () => {
const base = { a: 1, nested: { keep: true, old: 'x' } };
const result = deepMerge(base, { nested: { old: 'y' } });
expect(result.a).toBe(1);
expect(result.nested.keep).toBe(true);
expect(result.nested.old).toBe('y');
});
it('replaces scalar values from override', () => {
const base = { port: 8989, pool: { host: 'a', port: 3333 } };
const result = deepMerge(base, { port: 8080, pool: { host: 'b' } });
expect(result.port).toBe(8080);
expect(result.pool.host).toBe('b');
expect(result.pool.port).toBe(3333);
});
});
describe('SettingsPage (Calibrate)', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig());
vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo);
vi.spyOn(api, 'updateConfig').mockImplementation(async (cfg) => cfg as ReturnType<typeof mockServerConfig>);
vi.spyOn(api, 'createUser').mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
});
it('shows loading state then Calibrate heading', async () => {
renderSettings();
expect(screen.getByRole('heading', { level: 1, name: 'Calibrate' })).toBeInTheDocument();
expect(screen.getByText('Loading server calibration...')).toBeInTheDocument();
expect(await screen.findByText('CONTROL SERVER · LOCAL HOST')).toBeInTheDocument();
expect(screen.getAllByRole('heading', { level: 1, name: 'Calibrate' })).toHaveLength(1);
});
it('renders key section headings and labels', async () => {
renderSettings();
expect(await screen.findByText('Control Server')).toBeInTheDocument();
expect(screen.getByText('Upstream Pool')).toBeInTheDocument();
expect(screen.getByText('Fleet Payout Wallet')).toBeInTheDocument();
expect(screen.getByText('Access Control')).toBeInTheDocument();
expect(screen.getByText('Fleet Security')).toBeInTheDocument();
expect(screen.getByText('Listen Port')).toBeInTheDocument();
expect(screen.getByText(/Public URL \(LAN\)/i)).toBeInTheDocument();
expect(screen.getByText(/XMR Address/i)).toBeInTheDocument();
expect(screen.getByDisplayValue('8080')).toBeInTheDocument();
});
it('shows detected LAN endpoints banner', async () => {
renderSettings();
expect(await screen.findByText('DETECTED LAN ENDPOINTS')).toBeInTheDocument();
expect(screen.getByText(mockServerInfo.suggested_url!)).toBeInTheDocument();
expect(screen.getByText(/IPs on this host:/)).toBeInTheDocument();
});
it('saves calibration via updateConfig API', async () => {
const updateSpy = vi.spyOn(api, 'updateConfig');
renderSettings();
await screen.findByRole('button', { name: 'Save Calibration' });
await userEvent.setup().click(screen.getByRole('button', { name: 'Save Calibration' }));
await waitFor(() => {
expect(updateSpy).toHaveBeenCalled();
});
expect(await screen.findByText('Calibration saved — control server updated.')).toBeInTheDocument();
});
it('applies best defaults to public URL from server info', async () => {
vi.spyOn(api, 'getConfig').mockResolvedValue(
mockServerConfig({ server: { public_url: '' } })
);
renderSettings();
await screen.findByRole('button', { name: 'Use best defaults' });
const publicUrlInput = screen.getByPlaceholderText(mockServerInfo.suggested_url!) as HTMLInputElement;
expect(publicUrlInput.value).toBe('');
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.')
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
});
it('stores browser session credentials', async () => {
renderSettings();
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
const user = userEvent.setup();
const sessionUser = within(accessSection).getAllByPlaceholderText('admin')[0];
const sessionPass = accessSection.querySelectorAll('input[type="password"]')[0] as HTMLInputElement;
await user.type(sessionUser, 'admin');
await user.type(sessionPass, 'secret-pass');
await user.click(within(accessSection).getByRole('button', { name: 'Save session login' }));
expect(getStoredAuth()).toBeTruthy();
expect(await screen.findByText(/Session login saved/i)).toBeInTheDocument();
});
it('clears browser session on logout', async () => {
setStoredAuth('admin', 'secret-pass');
renderSettings();
await screen.findByText('Session active');
await userEvent.setup().click(screen.getByRole('button', { name: 'Clear session' }));
expect(getStoredAuth()).toBeNull();
expect(await screen.findByText('Session login cleared.')).toBeInTheDocument();
clearStoredAuth();
});
it('adds dashboard user via createUser API', async () => {
const createSpy = vi.spyOn(api, 'createUser');
renderSettings();
const accessSection = (await screen.findByText('Access Control')).closest('.settings-section') as HTMLElement;
const user = userEvent.setup();
const newUserInput = within(accessSection).getAllByPlaceholderText('admin')[1];
const newPassInput = within(accessSection).getByPlaceholderText('••••••••');
await user.type(newUserInput, 'operator');
await user.type(newPassInput, 'op-pass-123');
await user.click(within(accessSection).getByRole('button', { name: 'Add User' }));
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith('operator', 'op-pass-123');
});
expect(await screen.findByText('User "operator" added successfully!')).toBeInTheDocument();
});
it('describes first-run admin credentials in Access Control help', async () => {
renderSettings();
expect(
await screen.findByText(/first server start, credentials are printed once in the server console/i)
).toBeInTheDocument();
expect(screen.getByText(/admin/i)).toBeInTheDocument();
});
});

View File

@@ -7,7 +7,7 @@ import NeonCard from '../components/NeonCard/NeonCard';
import './Pages.css';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
const result = { ...base } as T;
for (const key in override) {
const val = override[key];
@@ -378,7 +378,7 @@ export default function SettingsPage() {
<p className="section-desc">Default wallet the server uses when connecting to the pool. The Forge pre-fills this when building miners.</p>
<div className="form-group">
<label className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input type="text" className="input mono" placeholder="4… (95 chars)"
<input type="text" className="input mono" placeholder="4… or 8… (90106 chars)"
value={config.wallet.address}
onChange={(e) => updateField('wallet.address', e.target.value)} />
<FieldHint field="calibrate_wallet" />