Pulse/internal/api/discovery_handlers.go
2026-03-18 16:06:30 +00:00

680 lines
20 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
// AIConfigProvider provides access to the current AI configuration.
// This allows discovery handlers to show AI provider info without tight coupling.
type AIConfigProvider interface {
GetAIConfig() *config.AIConfig
}
// Note: adminBypassEnabled() is defined in auth.go
// DiscoveryHandlers handles AI-powered infrastructure discovery endpoints.
type DiscoveryHandlers struct {
service *servicediscovery.Service
config *config.Config // For admin status checks
aiConfigProvider AIConfigProvider
}
// NewDiscoveryHandlers creates new discovery handlers.
func NewDiscoveryHandlers(service *servicediscovery.Service, cfg *config.Config) *DiscoveryHandlers {
return &DiscoveryHandlers{
service: service,
config: cfg,
}
}
// SetService sets the discovery service (used for late initialization after routes are registered).
func (h *DiscoveryHandlers) SetService(service *servicediscovery.Service) {
h.service = service
}
// SetAIConfigProvider sets the AI config provider for showing AI provider info.
func (h *DiscoveryHandlers) SetAIConfigProvider(provider AIConfigProvider) {
h.aiConfigProvider = provider
}
// getAIProviderInfo returns info about the current AI provider for discovery.
func (h *DiscoveryHandlers) getAIProviderInfo() *servicediscovery.AIProviderInfo {
if h.aiConfigProvider == nil {
return nil
}
aiCfg := h.aiConfigProvider.GetAIConfig()
if aiCfg == nil || !aiCfg.Enabled {
return nil
}
// Get the discovery model
model := aiCfg.GetDiscoveryModel()
if model == "" {
return nil
}
// Parse the model to get provider
provider, modelName := config.ParseModelString(model)
// Determine if local
isLocal := provider == config.AIProviderOllama
// Build human-readable label
var label string
switch provider {
case config.AIProviderOllama:
label = "Local (Ollama)"
case config.AIProviderAnthropic:
label = "Cloud (Anthropic)"
case config.AIProviderOpenAI:
label = "Cloud (OpenAI)"
case config.AIProviderOpenRouter:
label = "Cloud (OpenRouter)"
case config.AIProviderDeepSeek:
label = "Cloud (DeepSeek)"
case config.AIProviderGemini:
label = "Cloud (Google Gemini)"
default:
label = "Cloud (" + provider + ")"
}
return &servicediscovery.AIProviderInfo{
Provider: provider,
Model: modelName,
IsLocal: isLocal,
Label: label,
}
}
// writeDiscoveryJSON writes a JSON response.
func writeDiscoveryJSON(w http.ResponseWriter, data any) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// writeDiscoveryError writes a JSON error response.
func writeDiscoveryError(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]any{
"error": true,
"message": message,
})
}
func parseDiscoveryResourceType(raw string) (servicediscovery.ResourceType, error) {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
return "", fmt.Errorf("resource type is required")
}
normalized := servicediscovery.NormalizeResourceType(servicediscovery.ResourceType(trimmed))
if !isSupportedDiscoveryResourceType(normalized) {
return "", fmt.Errorf("unsupported resource type %q", trimmed)
}
return normalized, nil
}
func isSupportedDiscoveryResourceType(resourceType servicediscovery.ResourceType) bool {
switch resourceType {
case servicediscovery.ResourceTypeVM,
servicediscovery.ResourceTypeSystemContainer,
servicediscovery.ResourceTypeDocker,
servicediscovery.ResourceTypeK8s,
servicediscovery.ResourceTypeAgent,
servicediscovery.ResourceTypeDockerVM,
servicediscovery.ResourceTypeDockerSystemContainer:
return true
default:
return false
}
}
// isAdminRequest checks whether the request has privileged admin access for
// discovery secret operations. This is intentionally stricter than general
// authentication: session users must match configured admin identity and
// API tokens must include settings:write.
func (h *DiscoveryHandlers) isAdminRequest(r *http.Request) bool {
// Dev mode bypass - treat all requests as admin when enabled
if adminBypassEnabled() {
return true
}
if h.config == nil {
return false // Default to non-admin if no config
}
// 1. If using proxy auth, check the admin role
if h.config.ProxyAuthSecret != "" {
if valid, _, isAdmin := CheckProxyAuth(h.config, r); valid {
return isAdmin
}
return false
}
// 2. Check for basic auth (Pulse single-user admin credential)
if username, password, ok := r.BasicAuth(); ok {
configuredUser := strings.TrimSpace(h.config.AuthUser)
configuredHash := strings.TrimSpace(h.config.AuthPass)
if configuredUser != "" && configuredHash != "" &&
username == configuredUser &&
internalauth.CheckPasswordHash(password, configuredHash) {
return true
}
}
// 3. Check for configured admin session (OIDC/SAML/local session)
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
if ValidateSession(cookie.Value) {
configuredAdmin := strings.TrimSpace(h.config.AuthUser)
if configuredAdmin != "" {
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
if strings.EqualFold(sessionUser, configuredAdmin) {
return true
}
}
}
}
// 4. Check API tokens; only settings:write tokens are admin-capable here.
if tokenRecord := getAPITokenRecordFromRequest(r); tokenRecord != nil {
return tokenRecord.HasScope(config.ScopeSettingsWrite)
}
validateTokenAsAdmin := func(raw string) bool {
if strings.TrimSpace(raw) == "" {
return false
}
config.Mu.Lock()
record, ok := h.config.ValidateAPIToken(raw)
config.Mu.Unlock()
return ok && record != nil && record.HasScope(config.ScopeSettingsWrite)
}
if token := strings.TrimSpace(r.Header.Get("X-API-Token")); token != "" {
if validateTokenAsAdmin(token) {
return true
}
}
if bearer := extractBearerToken(r.Header.Get("Authorization")); bearer != "" {
if validateTokenAsAdmin(bearer) {
return true
}
}
return false
}
// redactSensitiveFields removes sensitive data from a discovery for non-admin users.
// This creates a copy to avoid modifying the original.
func redactSensitiveFields(d *servicediscovery.ResourceDiscovery) *servicediscovery.ResourceDiscovery {
if d == nil {
return nil
}
// Create a shallow copy
redacted := *d
// Redact sensitive fields
redacted.UserSecrets = nil // Never expose to non-admins
redacted.RawCommandOutput = nil // May contain sensitive output
return &redacted
}
func discoverySummaryResponse(d *servicediscovery.ResourceDiscovery) servicediscovery.DiscoverySummary {
if d == nil {
return servicediscovery.DiscoverySummary{}
}
return d.ToSummary()
}
func discoveryDetailResponse(d *servicediscovery.ResourceDiscovery) *servicediscovery.ResourceDiscovery {
return d
}
// HandleListDiscoveries handles GET /api/discovery
func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
discoveries, err := h.service.ListDiscoveries()
if err != nil {
log.Error().Err(err).Msg("Failed to list discoveries")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
// Convert to summaries for list view
summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries))
for _, d := range discoveries {
if d == nil {
continue
}
summaries = append(summaries, discoverySummaryResponse(d))
}
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
})
}
// HandleGetDiscovery handles GET /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleGetDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path: /api/discovery/{type}/{target}/{id}
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path: expected /api/discovery/{type}/{target}/{id}")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
discovery, err := h.service.GetDiscoveryByResource(resourceType, targetID, resourceID)
if err != nil {
log.Error().Err(err).Str("type", string(resourceType)).Str("target", targetID).Str("id", resourceID).Msg("Failed to get discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to get discovery")
return
}
if discovery == nil {
writeDiscoveryError(w, http.StatusNotFound, "Discovery not found")
return
}
// Redact sensitive fields for non-admin users
if !h.isAdminRequest(r) {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleTriggerDiscovery handles POST /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleTriggerDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path: expected /api/discovery/{type}/{target}/{id}")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
// Parse optional request body for force flag and hostname
var reqBody struct {
Force bool `json:"force"`
Hostname string `json:"hostname"`
}
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&reqBody)
}
// Build discovery request
req := servicediscovery.DiscoveryRequest{
ResourceType: resourceType,
ResourceID: resourceID,
TargetID: targetID,
Hostname: reqBody.Hostname,
Force: reqBody.Force,
}
// If hostname not provided, use target ID as a fallback.
if req.Hostname == "" {
req.Hostname = targetID
}
discovery, err := h.service.DiscoverResource(r.Context(), req)
if err != nil {
log.Error().Err(err).
Str("type", string(resourceType)).
Str("target", targetID).
Str("id", resourceID).
Msg("Failed to trigger discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Discovery failed: "+err.Error())
return
}
// Redact sensitive fields for non-admin users
if !h.isAdminRequest(r) {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleUpdateNotes handles PUT /api/discovery/{type}/{target}/{id}/notes
func (h *DiscoveryHandlers) HandleUpdateNotes(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
path = strings.TrimSuffix(path, "/notes")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
// Build the full ID
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
// Parse request body
var req servicediscovery.UpdateNotesRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Only admins can set user_secrets (contains sensitive data like API tokens)
isAdmin := h.isAdminRequest(r)
if !isAdmin && len(req.UserSecrets) > 0 {
writeDiscoveryError(w, http.StatusForbidden, "Only admins can set user_secrets")
return
}
if err := h.service.UpdateNotes(discoveryID, req.UserNotes, req.UserSecrets); err != nil {
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to update notes")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to update notes: "+err.Error())
return
}
// Return updated discovery
discovery, err := h.service.GetDiscovery(discoveryID)
if err != nil {
writeDiscoveryError(w, http.StatusInternalServerError, "Notes updated but failed to fetch result")
return
}
// Redact sensitive fields for non-admin users
if !isAdmin {
discovery = redactSensitiveFields(discovery)
}
writeDiscoveryJSON(w, discoveryDetailResponse(discovery))
}
// HandleDeleteDiscovery handles DELETE /api/discovery/{type}/{target}/{id}
func (h *DiscoveryHandlers) HandleDeleteDiscovery(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
if err := h.service.DeleteDiscovery(discoveryID); err != nil {
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to delete discovery")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to delete discovery")
return
}
writeDiscoveryJSON(w, map[string]any{"success": true, "id": discoveryID})
}
// HandleGetProgress handles GET /api/discovery/{type}/{target}/{id}/progress
func (h *DiscoveryHandlers) HandleGetProgress(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
path = strings.TrimSuffix(path, "/progress")
parts := strings.SplitN(path, "/", 3)
if len(parts) < 3 {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid path")
return
}
resourceType, err := parseDiscoveryResourceType(parts[0])
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
targetID := parts[1]
resourceID := parts[2]
discoveryID := servicediscovery.MakeResourceID(resourceType, targetID, resourceID)
progress := h.service.GetProgress(discoveryID)
if progress == nil {
// Not currently scanning - check if we have a discovery
discovery, err := h.service.GetDiscovery(discoveryID)
if err == nil && discovery != nil {
// Return completed status with all fields for frontend compatibility
writeDiscoveryJSON(w, map[string]any{
"resource_id": discoveryID,
"status": "completed",
"current_step": "",
"total_steps": 0,
"completed_steps": 0,
"started_at": discovery.DiscoveredAt,
"updated_at": discovery.UpdatedAt,
})
return
}
// Return not_started status with all fields for frontend compatibility
writeDiscoveryJSON(w, map[string]any{
"resource_id": discoveryID,
"status": "not_started",
"current_step": "",
"total_steps": 0,
"completed_steps": 0,
"started_at": "",
})
return
}
writeDiscoveryJSON(w, progress)
}
// HandleGetStatus handles GET /api/discovery/status
func (h *DiscoveryHandlers) HandleGetStatus(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
status := h.service.GetStatus()
// Add fingerprint change/stale stats
changedCount, _ := h.service.GetChangedResourceCount()
staleCount, _ := h.service.GetStaleResourceCount()
status["changed_count"] = changedCount // Containers with changed fingerprints
status["stale_count"] = staleCount // Discoveries > 30 days old
writeDiscoveryJSON(w, status)
}
// HandleUpdateSettings handles PUT /api/discovery/settings
// Allows updating discovery settings like the staleness threshold.
func (h *DiscoveryHandlers) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Require admin privileges
if !h.isAdminRequest(r) {
writeDiscoveryError(w, http.StatusForbidden, "Admin privileges required")
return
}
var req struct {
MaxDiscoveryAgeDays int `json:"max_discovery_age_days"` // Days before rediscovery
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Update settings
if req.MaxDiscoveryAgeDays > 0 {
h.service.SetMaxDiscoveryAge(time.Duration(req.MaxDiscoveryAgeDays) * 24 * time.Hour)
log.Info().Int("days", req.MaxDiscoveryAgeDays).Msg("Max discovery age updated via API")
}
// Return updated status
status := h.service.GetStatus()
changedCount, _ := h.service.GetChangedResourceCount()
staleCount, _ := h.service.GetStaleResourceCount()
status["changed_count"] = changedCount
status["stale_count"] = staleCount
writeDiscoveryJSON(w, status)
}
// HandleListByType handles GET /api/discovery/type/{type}
func (h *DiscoveryHandlers) HandleListByType(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/type/")
resourceType, err := parseDiscoveryResourceType(path)
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
discoveries, err := h.service.ListDiscoveriesByType(resourceType)
if err != nil {
log.Error().Err(err).Str("type", string(resourceType)).Msg("Failed to list discoveries by type")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries))
for _, d := range discoveries {
if d == nil {
continue
}
summaries = append(summaries, discoverySummaryResponse(d))
}
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
"type": resourceType,
})
}
// HandleListByAgent handles GET /api/discovery/agent/{agentId}
func (h *DiscoveryHandlers) HandleListByAgent(w http.ResponseWriter, r *http.Request) {
if h.service == nil {
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
return
}
// Parse path
agentID := strings.TrimPrefix(r.URL.Path, "/api/discovery/agent/")
discoveries, err := h.service.ListDiscoveriesByTarget(agentID)
if err != nil {
log.Error().Err(err).Str("agentId", agentID).Msg("Failed to list discoveries by agent")
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to list discoveries")
return
}
summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries))
for _, d := range discoveries {
if d == nil {
continue
}
summaries = append(summaries, discoverySummaryResponse(d))
}
writeDiscoveryJSON(w, map[string]any{
"discoveries": summaries,
"total": len(summaries),
"agentId": agentID,
})
}
// HandleGetInfo handles GET /api/discovery/info/{type}
// Returns metadata about the discovery process: AI provider info and commands that will run.
func (h *DiscoveryHandlers) HandleGetInfo(w http.ResponseWriter, r *http.Request) {
// Parse resource type from path
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/info/")
resourceType, err := parseDiscoveryResourceType(path)
if err != nil {
writeDiscoveryError(w, http.StatusBadRequest, err.Error())
return
}
// Get commands for this resource type
commands := servicediscovery.GetCommandsForResource(resourceType)
categories := servicediscovery.GetCommandCategories(resourceType)
// Get AI provider info
aiProvider := h.getAIProviderInfo()
info := servicediscovery.DiscoveryInfo{
AIProvider: aiProvider,
Commands: commands,
CommandCategories: categories,
}
writeDiscoveryJSON(w, info)
}