Files
AetherForge/server/web/src/pages/SettingsPage.tsx
AetherForge 6bfce5d5ab fix: dashboard funnel crash, comrade user, Cloudflare tunnel auto-start
Add runtime comrade account, null-safe spread funnel API/UI, server-started
cloudflared connector with Calibrate token field and builtin fallback, and
simplify LAUNCH to delegate tunnel startup to AetherForge.
2026-06-04 14:14:34 -07:00

1031 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react';
import { api } from '../api/client';
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
import type { ServerConfig } from '../types';
import {
DEFAULT_PRESET_IDS,
orderedPoolsFromSelection,
applyPoolsToForgeFields,
DEFAULT_RVN_PRESET_IDS,
orderedRVNPoolsFromSelection,
applyRVNPoolsToForgeFields,
} from '../help/poolPresets';
import { looksLikeXMRWallet } from '../help/forgeValidation';
import { HelpTip, FieldHint } from '../components/HelpTip';
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';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
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];
const baseVal = base[key];
if (
val !== null &&
val !== undefined &&
typeof val === 'object' &&
!Array.isArray(val) &&
typeof baseVal === 'object' &&
baseVal !== null &&
!Array.isArray(baseVal)
) {
result[key] = deepMerge(baseVal as object, val as object) as T[typeof key];
} else if (val !== undefined) {
result[key] = val as T[typeof key];
}
}
return result;
}
export default function SettingsPage() {
const { enabled: sfxEnabled, volume: sfxVolume, setEnabled: setSfxEnabled, setVolume: setSfxVolume, preview: previewSfx } = useSound();
const { glowParticles, setGlowParticles } = useVisualEffects();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [newUser, setNewUser] = useState('');
const [newPass, setNewPass] = useState('');
const [sessionUser, setSessionUser] = useState('');
const [sessionPass, setSessionPass] = useState('');
const [userMsg, setUserMsg] = useState('');
const [rotatingSecret, setRotatingSecret] = useState(false);
const [rotateMsg, setRotateMsg] = useState('');
const [backingUp, setBackingUp] = useState(false);
const [backupMsg, setBackupMsg] = useState('');
const [testingAlerts, setTestingAlerts] = useState(false);
const [alertTestMsg, setAlertTestMsg] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Promise.all([api.getConfig(), api.getServerInfo()])
.then(([cfg, info]) => {
setConfig({
...cfg,
rvn_wallet: cfg.rvn_wallet ?? { address: '', payment_id: '' },
rvn_pool: cfg.rvn_pool ?? {
host: 'rvn.2miners.com',
port: 6060,
use_tls: false,
password: 'x',
backup_pools: [],
},
tunnel_defaults: {
cloudflared_target_url: cfg.tunnel_defaults?.cloudflared_target_url ?? '',
cloudflare_tunnel_token: cfg.tunnel_defaults?.cloudflare_tunnel_token ?? '',
},
alerts: {
...cfg.alerts,
notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true,
notify_agent_reconnect: cfg.alerts?.notify_agent_reconnect ?? true,
notify_agent_offline: cfg.alerts?.notify_agent_offline ?? true,
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 ?? '',
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
build_retention_days: cfg.server?.build_retention_days ?? 30,
pool_reconnect_seconds: cfg.server?.pool_reconnect_seconds ?? 30,
websocket_ping_seconds: cfg.server?.websocket_ping_seconds ?? 30,
max_agents: cfg.server?.max_agents ?? 256,
max_build_size_mb: cfg.server?.max_build_size_mb ?? 150,
log_agent_connections: cfg.server?.log_agent_connections ?? true,
log_share_submissions: cfg.server?.log_share_submissions ?? false,
log_pool_traffic: cfg.server?.log_pool_traffic ?? false,
strict_wallet_validation: cfg.server?.strict_wallet_validation ?? false,
dashboard_subtitle: cfg.server?.dashboard_subtitle ?? 'security is just an emotion',
open_firewall_on_start: cfg.server?.open_firewall_on_start ?? true,
},
});
setServerInfo(info);
})
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const updateField = (path: string, value: unknown) => {
if (!config) return;
const newConfig = { ...config };
const keys = path.split('.');
let obj: Record<string, unknown> = newConfig as unknown as Record<string, unknown>;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!obj[key] || typeof obj[key] !== 'object') {
obj[key] = {};
}
obj = obj[key] as Record<string, unknown>;
}
obj[keys[keys.length - 1]] = value;
setConfig(newConfig);
};
const handleSave = async () => {
if (!config) return;
const wallet = config.wallet.address?.trim() ?? '';
if (wallet && !looksLikeXMRWallet(wallet)) {
const strict = config.server?.strict_wallet_validation ?? false;
if (strict) {
setSaveMessage('Save blocked: wallet does not look like a valid Monero mainnet address.');
return;
}
if (!window.confirm('Wallet does not look like a standard Monero address. Save anyway?')) {
return;
}
}
setSaving(true);
setSaveMessage('');
try {
const updated = await api.updateConfig(config);
setConfig(updated);
setSaveMessage('Calibration saved — control server updated.');
setTimeout(() => setSaveMessage(''), 4000);
} catch (err: unknown) {
setSaveMessage(`Save failed: ${err instanceof Error ? err.message : 'unknown error'}`);
} finally {
setSaving(false);
}
};
const handleExportConfig = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'aetherforge-server-config.json';
a.click();
URL.revokeObjectURL(url);
};
const handleFullBackup = async () => {
setBackingUp(true);
setBackupMsg('');
try {
await api.downloadBackup();
setBackupMsg('Backup downloaded.');
setTimeout(() => setBackupMsg(''), 4000);
} catch (e: unknown) {
setBackupMsg('Backup failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setBackingUp(false);
}
};
const handleImportConfig = () => fileInputRef.current?.click();
const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target?.result as string);
// Deep-merge so importing a partial config (e.g. only "pool" key) doesn't
// wipe unrelated nested sections like "default_agent" or "mining".
setConfig((prev) => (prev ? deepMerge(prev, data) : prev));
setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`);
} catch {
setSaveMessage('Invalid JSON file.');
}
};
reader.readAsText(file);
e.target.value = '';
};
const handleSessionLogin = () => {
if (!sessionUser || !sessionPass) return;
setStoredAuth(sessionUser, sessionPass);
setUserMsg('Session login saved — API calls from this browser will authenticate.');
setTimeout(() => setUserMsg(''), 4000);
};
const handleSessionLogout = () => {
clearStoredAuth();
setSessionUser('');
setSessionPass('');
setUserMsg('Session login cleared.');
setTimeout(() => setUserMsg(''), 3000);
};
const handleTestAlerts = async () => {
if (!config) return;
setTestingAlerts(true);
setAlertTestMsg('');
try {
if (!config.alerts.telegram_bot_token?.trim() && !config.alerts.email_enabled) {
setAlertTestMsg('Enter Telegram token + chat ID (or enable SMTP) first.');
return;
}
await api.updateConfig(config);
const result = await api.testAlerts();
const parts: string[] = [];
const tg = result.telegram;
if (tg) {
parts.push(tg.sent ? '✓ Telegram delivered' : `✕ Telegram: ${tg.error || 'failed'}`);
}
const smtp = result.smtp;
if (smtp) {
parts.push(smtp.sent ? '✓ Email delivered' : `✕ Email: ${smtp.error || 'failed'}`);
}
setAlertTestMsg(parts.join(' · ') || 'No channels configured.');
if (tg?.sent || smtp?.sent) previewSfx('success');
} catch (e: unknown) {
setAlertTestMsg('Test failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setTestingAlerts(false);
setTimeout(() => setAlertTestMsg(''), 12000);
}
};
const handleRotateSecret = async () => {
if (!window.confirm(
'Rotate fleet secret?\n\n' +
'ALL currently connected agents will be kicked and must be re-forged to reconnect.\n\n' +
'Click OK only if you are ready to re-forge your entire fleet.'
)) return;
setRotatingSecret(true);
setRotateMsg('');
try {
await api.rotateFleetSecret();
setRotateMsg('Secret rotated. Re-forge all agents to reconnect.');
} catch (e: unknown) {
setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e)));
} finally {
setRotatingSecret(false);
setTimeout(() => setRotateMsg(''), 6000);
}
};
const handleAddUser = async () => {
if (!newUser || !newPass) return;
try {
await api.createUser(newUser, newPass);
setUserMsg(`User "${newUser}" added successfully!`);
if (!getStoredAuth()) {
setStoredAuth(newUser, newPass);
}
setNewUser('');
setNewPass('');
setTimeout(() => setUserMsg(''), 3000);
} catch {
setUserMsg('Failed to save user — sign in under Browser session first.');
setTimeout(() => setUserMsg(''), 4000);
}
};
if (loading) {
return (
<div className="page fade-in command-deck">
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">SERVER ONLY</p>
<h1>Calibrate</h1>
</div>
</header>
<NeonCard accent="brass"><p>Loading server calibration...</p></NeonCard>
</div>
);
}
if (!config) {
return (
<div className="page fade-in command-deck">
<NeonCard accent="brass"><p>Failed to load server configuration.</p></NeonCard>
</div>
);
}
const s = config.server || {
public_url: '',
stats_retention_hours: 168,
build_retention_days: 30,
pool_reconnect_seconds: 30,
websocket_ping_seconds: 30,
max_agents: 256,
max_build_size_mb: 150,
log_agent_connections: true,
log_share_submissions: false,
log_pool_traffic: false,
strict_wallet_validation: false,
dashboard_subtitle: '',
open_firewall_on_start: true,
obfuscate_default: false,
sign_enabled: false,
sign_cert_thumbprint: '',
sign_tool_path: '',
sign_timestamp_url: 'http://timestamp.digicert.com',
};
return (
<div className="page fade-in command-deck">
<input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".json" onChange={handleFileSelected} />
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">CONTROL SERVER · LOCAL HOST</p>
<h1>Calibrate</h1>
<p className="page-subtitle">
Settings for this machine only the dashboard, pool relay, and LAN address. Miner installers are built in the Forge tab.
</p>
</div>
<div className="deck-hero-actions">
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save Calibration'}
</button>
<button className="btn btn-outline" onClick={handleExportConfig}>Export</button>
<button className="btn btn-outline" onClick={handleImportConfig}>Import</button>
<button
className="btn btn-outline"
onClick={handleFullBackup}
disabled={backingUp}
title="Downloads config, agent DB, and credentials."
>
{backingUp ? 'Backing up…' : 'Full Deck Backup'}
</button>
</div>
{backupMsg && (
<div className={`save-message ${backupMsg.includes('failed') ? 'error' : 'success'}`}>{backupMsg}</div>
)}
</header>
{saveMessage && (
<div className={`save-message ${saveMessage.includes('failed') ? 'error' : 'success'}`}>{saveMessage}</div>
)}
{serverInfo && (
<NeonCard accent="cyan" className="calibrate-banner" hud>
<p className="font-tech">DETECTED LAN ENDPOINTS</p>
<p><strong>Suggested:</strong> <code className="mono-sm">{serverInfo.suggested_url}</code></p>
{serverInfo.local_ips?.length > 0 && (
<p className="form-hint">IPs on this host: {serverInfo.local_ips.join(' · ')}</p>
)}
<p className="form-hint">Workers need this LAN address not localhost. Click below to apply best defaults, then Save Calibration.</p>
<button
type="button"
className="btn btn-primary btn-sm"
style={{ marginTop: '0.75rem' }}
onClick={() => {
if (!config) return;
updateField('server.public_url', serverInfo.suggested_url);
updateField('server.open_firewall_on_start', true);
updateField('server.obfuscate_default', false);
updateField('server.sign_enabled', false);
const pools = orderedPoolsFromSelection(
[...DEFAULT_PRESET_IDS],
config.pool.password || 'x'
);
const poolFields = applyPoolsToForgeFields(pools);
updateField('pool.host', poolFields.pool_host);
updateField('pool.port', poolFields.pool_port);
updateField('pool.use_tls', poolFields.pool_tls);
updateField(
'pool.backup_pools',
poolFields.backup_pools.map((bp) => ({
host: bp.host,
port: bp.port,
use_tls: bp.tls,
}))
);
if (!config.wallet.address?.trim()) {
setSaveMessage('TLS pool presets + LAN URL applied — set your Monero wallet, then Save Calibration.');
} else {
setSaveMessage('Best defaults applied (LAN URL + TLS pool presets) — click Save Calibration to keep them.');
}
}}
>
Use best defaults
</button>
<FieldHint field="calibrate_quick_setup" />
</NeonCard>
)}
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Deck Atmosphere</h2>
<p className="section-desc">
Background glow particles and sparkles sit behind the UI (pointer-events off). Turn off on
low-power devices if you want a calmer deck.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={glowParticles}
onChange={(e) => setGlowParticles(e.target.checked)}
/>
<span>Glow particles &amp; sparkles</span>
</label>
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Sound &amp; Haptics</h2>
<p className="section-desc">
Short UI bleeps and vibration on supported phones/tablets. Browsers require a click anywhere
on the deck first to unlock audio. Fleet events (agents, shares, alerts) use separate cues.
</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={sfxEnabled}
onChange={(e) => setSfxEnabled(e.target.checked)}
/>
<span>Enable sound effects &amp; haptic vibration</span>
</label>
</div>
<div className="form-group">
<label htmlFor="cfg-sfx-volume" className="label">
Volume ({Math.round(sfxVolume * 100)}%)
</label>
<input
id="cfg-sfx-volume"
type="range"
className="input"
min={0}
max={100}
step={5}
value={Math.round(sfxVolume * 100)}
disabled={!sfxEnabled}
onChange={(e) => setSfxVolume(parseInt(e.target.value, 10) / 100)}
/>
</div>
<div className="form-row" style={{ gap: '0.5rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('click')}
>
Preview click
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('alert')}
>
Preview alert
</button>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={!sfxEnabled}
onClick={() => previewSfx('share')}
>
Preview share
</button>
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Control Server</h2>
<p className="section-desc">How this dashboard and API are hosted on your network.</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-port" className="label">Listen Port</label>
<input id="cfg-port" type="number" className="input" min={1024} max={65535} value={config.port}
onChange={(e) => updateField('port', parseInt(e.target.value) || 8989)} />
<span className="form-hint">Restart server after changing port.</span>
</div>
<div className="form-group">
<label htmlFor="cfg-data-dir" className="label">Data Directory</label>
<input id="cfg-data-dir" type="text" className="input mono" value={config.data_dir}
onChange={(e) => updateField('data_dir', e.target.value)} />
</div>
</div>
<div className="form-group">
<label htmlFor="cfg-public-url" className="label">Public URL (LAN) <HelpTip field="public_url" /></label>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
<input id="cfg-public-url" type="text" className="input mono" style={{ flex: 1, minWidth: '200px' }}
placeholder={serverInfo?.suggested_url || 'http://192.168.1.x:8989'}
value={s.public_url}
onChange={(e) => updateField('server.public_url', e.target.value)} />
{serverInfo?.suggested_url && (
<button type="button" className="btn btn-outline btn-sm"
onClick={() => updateField('server.public_url', serverInfo.suggested_url)}>
Use detected LAN
</button>
)}
</div>
<FieldHint field="public_url" />
</div>
<div className="form-group">
<label htmlFor="cfg-cf-token" className="label">
Cloudflare Tunnel Token <HelpTip field="cloudflare_tunnel_token" />
</label>
<input
id="cfg-cf-token"
type="password"
className="input mono"
autoComplete="off"
placeholder="eyJhIjoi… connector token from Cloudflare"
value={config.tunnel_defaults?.cloudflare_tunnel_token ?? ''}
onChange={(e) => updateField('tunnel_defaults.cloudflare_tunnel_token', e.target.value)}
/>
<FieldHint field="cloudflare_tunnel_token" />
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
After saving, restart LAUNCH / the server. Connector also reads <code className="mono">data/cloudflared-token.txt</code> on USB.
</p>
</div>
<div className="form-group">
<label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
<input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}
onChange={(e) => updateField('server.dashboard_subtitle', e.target.value)} />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.open_firewall_on_start ?? true}
onChange={(e) => updateField('server.open_firewall_on_start', e.target.checked)} />
<span>Open dashboard port in Windows Firewall on startup <HelpTip field="open_firewall_on_start" /></span>
</label>
<FieldHint field="open_firewall_on_start" />
</div>
</NeonCard>
<NeonCard accent="cyan" className="settings-section">
<h2 className="font-display">Upstream Pool</h2>
<p className="section-desc">The control server connects here and relays work to your fleet. Pick presets or add your own unreachable pools are skipped automatically.</p>
<PoolPresetPicker
host={config.pool.host}
port={config.pool.port}
tls={config.pool.use_tls}
pass={config.pool.password || 'x'}
backups={(config.pool.backup_pools ?? []).map(
(bp): BackupPool => ({ host: bp.host, port: bp.port, tls: bp.use_tls, pass: config.pool.password || 'x' })
)}
onChange={(next) => {
updateField('pool.host', next.pool_host);
updateField('pool.port', next.pool_port);
updateField('pool.use_tls', next.pool_tls);
updateField(
'pool.backup_pools',
(next.backup_pools ?? []).map((bp) => ({
host: bp.host,
port: bp.port,
use_tls: bp.tls,
}))
);
}}
/>
<div className="form-group">
<label htmlFor="cfg-pool-pass" className="label">Pool Password</label>
<input id="cfg-pool-pass" type="text" className="input" value={config.pool.password}
onChange={(e) => updateField('pool.password', e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-pool-reconnect" className="label">Pool Reconnect Interval (sec)</label>
<input id="cfg-pool-reconnect" type="number" className="input" min={5} value={s.pool_reconnect_seconds}
onChange={(e) => updateField('server.pool_reconnect_seconds', parseInt(e.target.value) || 30)} />
</div>
</NeonCard>
<NeonCard accent="purple" className="settings-section">
<h2 className="font-display">Fleet Payout Wallet</h2>
<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 htmlFor="cfg-wallet-addr" className="label">XMR Address <HelpTip field="calibrate_wallet" /></label>
<input id="cfg-wallet-addr" 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" />
</div>
<div className="form-group">
<label htmlFor="cfg-payment-id" className="label">Payment ID (optional)</label>
<input id="cfg-payment-id" type="text" className="input mono" value={config.wallet.payment_id}
onChange={(e) => updateField('wallet.payment_id', e.target.value)} />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.strict_wallet_validation}
onChange={(e) => updateField('server.strict_wallet_validation', e.target.checked)} />
<span>Strict wallet validation on build API</span>
</label>
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Ravencoin (GPU) Pool</h2>
<p className="section-desc">
Default RVN pool and wallet used when forging GPU-enabled agents. These pre-populate the Forge GPU mining fields.
</p>
<div className="form-group">
<label htmlFor="cfg-rvn-wallet" className="label">RVN Wallet Address</label>
<input
id="cfg-rvn-wallet"
type="text"
className="input mono"
placeholder="R… (Ravencoin address)"
value={config.rvn_wallet?.address ?? ''}
onChange={(e) => updateField('rvn_wallet.address', e.target.value)}
/>
</div>
<RVNPoolPresetPicker
host={config.rvn_pool?.host ?? ''}
port={config.rvn_pool?.port ?? 0}
tls={config.rvn_pool?.use_tls ?? false}
pass={config.rvn_pool?.password ?? 'x'}
backups={(config.rvn_pool?.backup_pools ?? []).map((bp) => ({
host: bp.host, port: bp.port, tls: bp.use_tls,
}))}
onChange={(fields) => {
updateField('rvn_pool.host', fields.rvn_pool_host);
updateField('rvn_pool.port', fields.rvn_pool_port);
updateField('rvn_pool.use_tls', fields.rvn_pool_tls);
updateField('rvn_pool.password', config.rvn_pool?.password ?? 'x');
updateField('rvn_pool.backup_pools',
(fields.rvn_backup_pools ?? []).map((bp: BackupPool) => ({
host: bp.host, port: bp.port, use_tls: bp.tls,
}))
);
}}
/>
<div className="form-group" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
onClick={() => {
const pools = orderedRVNPoolsFromSelection(
[...DEFAULT_RVN_PRESET_IDS],
config.rvn_pool?.password || 'x'
);
const f = applyRVNPoolsToForgeFields(pools);
updateField('rvn_pool.host', f.rvn_pool_host);
updateField('rvn_pool.port', f.rvn_pool_port);
updateField('rvn_pool.use_tls', f.rvn_pool_tls);
updateField('rvn_pool.backup_pools',
(f.rvn_backup_pools ?? []).map((bp: BackupPool) => ({
host: bp.host, port: bp.port, use_tls: bp.tls,
}))
);
setSaveMessage('RVN pool presets applied — click Save Calibration.');
}}
>
Apply default RVN pools
</button>
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Fleet Alerts</h2>
<p className="section-desc">Dashboard thresholds for agent health.</p>
<div className="form-group">
<label htmlFor="cfg-alert-offline" className="label">Offline After (minutes)</label>
<input id="cfg-alert-offline" type="number" className="input" min={1} value={config.alerts.offline_threshold_minutes}
onChange={(e) => updateField('alerts.offline_threshold_minutes', parseInt(e.target.value) || 5)} />
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-alert-hashrate" className="label">Hashrate Drop (%)</label>
<input id="cfg-alert-hashrate" type="number" className="input" value={config.alerts.hashrate_drop_threshold_pct}
onChange={(e) => updateField('alerts.hashrate_drop_threshold_pct', parseInt(e.target.value) || 50)} />
</div>
<div className="form-group">
<label htmlFor="cfg-alert-reject" className="label">Rejection Rate (%)</label>
<input id="cfg-alert-reject" type="number" className="input" value={config.alerts.rejection_rate_threshold_pct}
onChange={(e) => updateField('alerts.rejection_rate_threshold_pct', parseInt(e.target.value) || 5)} />
</div>
</div>
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Alert Notifications</h2>
<p className="section-desc">
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">
<label htmlFor="cfg-tg-token" className="label">Telegram Bot Token</label>
<input id="cfg-tg-token" type="password" className="input mono" value={config.alerts.telegram_bot_token || ''}
onChange={(e) => updateField('alerts.telegram_bot_token', e.target.value)} placeholder="123456:ABC…" />
</div>
<div className="form-group">
<label htmlFor="cfg-tg-chat" className="label">Telegram Chat ID</label>
<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{' '}
<a href="https://t.me/userinfobot" target="_blank" rel="noreferrer">@userinfobot</a> to copy your numeric ID,
or read it from <code>getUpdates</code> on the Bot API. Save Calibrate, then test.
</p>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap', marginBottom: '0.75rem' }}>
<button
type="button"
className="btn btn-outline btn-sm"
disabled={testingAlerts}
onClick={handleTestAlerts}
>
{testingAlerts ? 'Sending…' : 'Send test notification'}
</button>
{alertTestMsg && <span className="mono" style={{ fontSize: '0.85rem', color: '#9ee0ff' }}>{alertTestMsg}</span>}
</div>
<h3 className="font-display" style={{ fontSize: '1rem', margin: '1rem 0 0.5rem' }}>Notify me when</h3>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_connect !== false}
onChange={(e) => updateField('alerts.notify_agent_connect', e.target.checked)} />
<span>New agent connects to C2 (first time seen)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_reconnect !== false}
onChange={(e) => updateField('alerts.notify_agent_reconnect', e.target.checked)} />
<span>Agent reconnects (back online or session takeover)</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_agent_offline !== false}
onChange={(e) => updateField('alerts.notify_agent_offline', e.target.checked)} />
<span>Agent offline past threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_hashrate_drop !== false}
onChange={(e) => updateField('alerts.notify_hashrate_drop', e.target.checked)} />
<span>Hashrate drops below threshold</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_rejection_rate !== false}
onChange={(e) => updateField('alerts.notify_rejection_rate', e.target.checked)} />
<span>Share rejection rate spikes</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.alerts.notify_build_complete !== false}
onChange={(e) => updateField('alerts.notify_build_complete', e.target.checked)} />
<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}
onChange={(e) => updateField('alerts.email_enabled', e.target.checked)} />
<span>Email alerts via SMTP</span>
</label>
</div>
{config.alerts.email_enabled && (
<>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-smtp-host" className="label">SMTP Host</label>
<input id="cfg-smtp-host" type="text" className="input" value={config.alerts.smtp_host || ''}
onChange={(e) => updateField('alerts.smtp_host', e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-smtp-port" className="label">SMTP Port</label>
<input id="cfg-smtp-port" type="number" className="input" value={config.alerts.smtp_port || 587}
onChange={(e) => updateField('alerts.smtp_port', parseInt(e.target.value) || 587)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-smtp-user" className="label">SMTP User</label>
<input id="cfg-smtp-user" type="text" className="input" value={config.alerts.smtp_user || ''}
onChange={(e) => updateField('alerts.smtp_user', e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-smtp-pass" className="label">SMTP Password</label>
<input id="cfg-smtp-pass" type="password" className="input" value={config.alerts.smtp_password || ''}
onChange={(e) => updateField('alerts.smtp_password', e.target.value)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-email-to" className="label">Email To</label>
<input id="cfg-email-to" type="email" className="input" value={config.alerts.email_to || ''}
onChange={(e) => updateField('alerts.email_to', e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-email-from" className="label">Email From</label>
<input id="cfg-email-from" type="email" className="input" value={config.alerts.email_from || ''}
onChange={(e) => updateField('alerts.email_from', e.target.value)} />
</div>
</div>
</>
)}
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Forge Pipeline</h2>
<p className="section-desc">Defaults for obfuscation and code signing applied when forging on this control PC.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.obfuscate_default ?? false}
onChange={(e) => updateField('server.obfuscate_default', e.target.checked)} />
<span>Default: obfuscate new forges with Garble <HelpTip field="obfuscate_default" /></span>
</label>
<FieldHint field="obfuscate_default" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.sign_enabled ?? false}
onChange={(e) => updateField('server.sign_enabled', e.target.checked)} />
<span>Default: sign forged executables <HelpTip field="sign_enabled" /></span>
</label>
<FieldHint field="sign_enabled" />
</div>
<div className="form-group">
<label htmlFor="cfg-sign-cert" className="label">Code signing cert thumbprint (SHA-1) <HelpTip field="sign_cert_thumbprint" /></label>
<input id="cfg-sign-cert" type="text" className="input mono" placeholder="AB CD EF ..."
value={s.sign_cert_thumbprint || ''}
onChange={(e) => updateField('server.sign_cert_thumbprint', e.target.value)} />
<FieldHint field="sign_cert_thumbprint" />
</div>
<div className="form-group">
<label htmlFor="cfg-sign-tool" className="label">signtool.exe path (optional) <HelpTip field="sign_tool_path" /></label>
<input id="cfg-sign-tool" type="text" className="input mono" placeholder="Auto-detect from Windows SDK"
value={s.sign_tool_path || ''}
onChange={(e) => updateField('server.sign_tool_path', e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-sign-ts" className="label">Timestamp server URL <HelpTip field="sign_timestamp_url" /></label>
<input id="cfg-sign-ts" type="text" className="input mono"
value={s.sign_timestamp_url || 'http://timestamp.digicert.com'}
onChange={(e) => updateField('server.sign_timestamp_url', e.target.value)} />
<FieldHint field="sign_timestamp_url" />
</div>
</NeonCard>
<NeonCard accent="green" className="settings-section">
<h2 className="font-display">Data & Limits</h2>
<p className="section-desc">Retention and capacity for this host.</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-stats-ret" className="label">Stats Retention (hours)</label>
<input id="cfg-stats-ret" type="number" className="input" min={24} value={s.stats_retention_hours}
onChange={(e) => updateField('server.stats_retention_hours', parseInt(e.target.value) || 168)} />
</div>
<div className="form-group">
<label htmlFor="cfg-build-ret" className="label">Keep Builds (days)</label>
<input id="cfg-build-ret" type="number" className="input" min={1} value={s.build_retention_days}
onChange={(e) => updateField('server.build_retention_days', parseInt(e.target.value) || 30)} />
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-max-agents" className="label">Max Agents</label>
<input id="cfg-max-agents" type="number" className="input" min={1} value={s.max_agents}
onChange={(e) => updateField('server.max_agents', parseInt(e.target.value) || 256)} />
</div>
<div className="form-group">
<label htmlFor="cfg-max-build-mb" className="label">Max Build Size (MB)</label>
<input id="cfg-max-build-mb" type="number" className="input" min={10} value={s.max_build_size_mb}
onChange={(e) => updateField('server.max_build_size_mb', parseInt(e.target.value) || 150)} />
</div>
</div>
<div className="form-group">
<label htmlFor="cfg-ws-ping" className="label">WebSocket Ping (sec)</label>
<input id="cfg-ws-ping" type="number" className="input" min={10} value={s.websocket_ping_seconds}
onChange={(e) => updateField('server.websocket_ping_seconds', parseInt(e.target.value) || 30)} />
</div>
</NeonCard>
<NeonCard accent="brass" className="settings-section">
<h2 className="font-display">Server Logging</h2>
<p className="section-desc">What this control server writes to its log.</p>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.log_agent_connections}
onChange={(e) => updateField('server.log_agent_connections', e.target.checked)} />
<span>Log agent connect / disconnect</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.log_share_submissions}
onChange={(e) => updateField('server.log_share_submissions', e.target.checked)} />
<span>Log every share submission</span>
</label>
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={s.log_pool_traffic}
onChange={(e) => updateField('server.log_pool_traffic', e.target.checked)} />
<span>Verbose pool traffic (debug)</span>
</label>
</div>
</NeonCard>
<NeonCard accent="magenta" className="settings-section">
<h2 className="font-display">Access Control</h2>
<p className="section-desc">
API routes require login. On first server start, credentials are printed once in the server console (<code>admin</code> + random password). Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
</p>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-session-user" className="label">Browser session username</label>
<input id="cfg-session-user" type="text" className="input" placeholder="admin" value={sessionUser}
onChange={(e) => setSessionUser(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-session-pass" className="label">Browser session password</label>
<input id="cfg-session-pass" type="password" className="input" value={sessionPass}
onChange={(e) => setSessionPass(e.target.value)} />
</div>
</div>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
<button type="button" className="btn btn-primary" onClick={handleSessionLogin} disabled={!sessionUser || !sessionPass}>
Save session login
</button>
<button type="button" className="btn btn-outline" onClick={handleSessionLogout}>
Clear session
</button>
{getStoredAuth() && <span className="form-hint" style={{ alignSelf: 'center' }}>Session active</span>}
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-new-user" className="label">New Username</label>
<input id="cfg-new-user" type="text" className="input" placeholder="admin" value={newUser}
onChange={(e) => setNewUser(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="cfg-new-pass" className="label">New Password</label>
<input id="cfg-new-pass" type="password" className="input" placeholder="••••••••" value={newPass}
onChange={(e) => setNewPass(e.target.value)} />
</div>
</div>
<button className="btn btn-outline" onClick={handleAddUser} disabled={!newUser || !newPass}>
Add User
</button>
{userMsg && (
<div style={{ marginTop: '0.5rem', color: userMsg.includes('Failed') ? '#ff4444' : '#00ff00', fontSize: '0.9rem' }}>{userMsg}</div>
)}
</NeonCard>
<NeonCard accent="amber" className="settings-section">
<h2 className="font-display">Fleet Security</h2>
<p className="section-desc">
A <strong>Fleet Secret</strong> is auto-generated on first server start and baked into every forged agent.
Agents without the correct secret are rejected. Use rotation if the secret is compromised
it immediately kicks all connected agents; re-forge to reconnect.
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-outline"
style={{ borderColor: 'var(--accent-red)', color: 'var(--accent-red)' }}
onClick={handleRotateSecret}
disabled={rotatingSecret}
>
{rotatingSecret ? 'Rotating…' : 'Rotate Fleet Secret'}
</button>
<span className="form-hint" style={{ color: 'var(--accent-amber)' }}>
Kicks all agents. You must re-forge after rotating.
</span>
</div>
{rotateMsg && (
<p style={{ marginTop: '0.5rem', color: rotateMsg.startsWith('Rotation failed') ? '#ff4444' : '#00ff44', fontSize: '0.9rem' }}>
{rotateMsg}
</p>
)}
</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>
</div>
);
}