mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
1007 lines
33 KiB
Go
1007 lines
33 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
mockfixtures "github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
|
|
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
|
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
|
|
}
|
|
|
|
// DiscoveryReadinessForResource implements the unified resource readiness
|
|
// provider used by resource and agent-context endpoints.
|
|
func (h *DiscoveryHandlers) DiscoveryReadinessForResource(resource unified.Resource, now time.Time) unified.ResourceDiscoveryReadiness {
|
|
target := resource.DiscoveryTarget
|
|
if h == nil {
|
|
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery handlers are not configured.")
|
|
}
|
|
if h.service != nil {
|
|
return h.service.DiscoveryReadinessForResource(resource, now)
|
|
}
|
|
if target == nil {
|
|
return servicediscovery.DiscoveryReadinessForTarget(nil, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
|
}
|
|
if _, ok := servicediscovery.DiscoveryResourceTypeForTarget(target); !ok {
|
|
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
|
}
|
|
if mockfixtures.IsMockEnabled() {
|
|
resourceType, ok := servicediscovery.DiscoveryResourceTypeForTarget(target)
|
|
if !ok {
|
|
return servicediscovery.DiscoveryReadinessForTarget(target, nil, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
|
}
|
|
discovery := mockDiscoveryFixtureByResource(resourceType, target.AgentID, target.ResourceID)
|
|
return servicediscovery.DiscoveryReadinessForTarget(target, discovery, nil, servicediscovery.DefaultConfig().MaxDiscoveryAge, now)
|
|
}
|
|
return servicediscovery.DiscoveryReadinessUnavailableForTarget(target, now, "Discovery service is not configured.")
|
|
}
|
|
|
|
// 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 an admin session (OIDC/SAML/local session). The admin test is
|
|
// sessionUserCarriesAdminPrivileges, the same one the settings routes apply,
|
|
// so both the configured local administrator and an explicit RBAC admin
|
|
// grant count. Comparing against h.config.AuthUser alone cannot recognize an
|
|
// explicitly authorized SSO administrator.
|
|
if cookie, err := readSessionCookie(r); err == nil && cookie.Value != "" {
|
|
if ValidateSession(cookie.Value) {
|
|
sessionUser := strings.TrimSpace(GetSessionUsername(cookie.Value))
|
|
if sessionUserCarriesAdminPrivileges(h.config, sessionUser) {
|
|
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
|
|
}
|
|
|
|
func (h *DiscoveryHandlers) requireCommandScanningAccess(w http.ResponseWriter, r *http.Request) bool {
|
|
if !h.isAdminRequest(r) {
|
|
writeDiscoveryError(w, http.StatusForbidden, "Admin privileges required")
|
|
return false
|
|
}
|
|
if h.service == nil || !h.service.IsCommandScanningEnabled() {
|
|
writeDiscoveryError(w, http.StatusForbidden, "Discovery command scanning is disabled")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
func mockDiscoveryFixtureToService(fixture *mockfixtures.DiscoveryFixture) *servicediscovery.ResourceDiscovery {
|
|
if fixture == nil {
|
|
return nil
|
|
}
|
|
facts := make([]servicediscovery.DiscoveryFact, 0, len(fixture.Facts))
|
|
for _, fact := range fixture.Facts {
|
|
facts = append(facts, servicediscovery.DiscoveryFact{
|
|
Category: servicediscovery.FactCategory(fact.Category),
|
|
Key: fact.Key,
|
|
Value: fact.Value,
|
|
Source: fact.Source,
|
|
Confidence: fact.Confidence,
|
|
DiscoveredAt: fact.DiscoveredAt,
|
|
})
|
|
}
|
|
ports := make([]servicediscovery.PortInfo, 0, len(fixture.Ports))
|
|
for _, port := range fixture.Ports {
|
|
ports = append(ports, servicediscovery.PortInfo{
|
|
Port: port.Port,
|
|
Protocol: port.Protocol,
|
|
Process: port.Process,
|
|
Address: port.Address,
|
|
})
|
|
}
|
|
mounts := make([]servicediscovery.DockerBindMount, 0, len(fixture.DockerMounts))
|
|
for _, mount := range fixture.DockerMounts {
|
|
mounts = append(mounts, servicediscovery.DockerBindMount{
|
|
ContainerName: mount.ContainerName,
|
|
Source: mount.Source,
|
|
Destination: mount.Destination,
|
|
Type: mount.Type,
|
|
ReadOnly: mount.ReadOnly,
|
|
})
|
|
}
|
|
userSecrets := map[string]string{}
|
|
for key, value := range fixture.UserSecrets {
|
|
userSecrets[key] = value
|
|
}
|
|
|
|
discovery := &servicediscovery.ResourceDiscovery{
|
|
ID: fixture.ID,
|
|
ResourceType: servicediscovery.ResourceType(fixture.ResourceType),
|
|
ResourceID: fixture.ResourceID,
|
|
TargetID: fixture.TargetID,
|
|
AgentID: fixture.AgentID,
|
|
Hostname: fixture.Hostname,
|
|
ServiceType: fixture.ServiceType,
|
|
ServiceName: fixture.ServiceName,
|
|
ServiceVersion: fixture.ServiceVersion,
|
|
Category: servicediscovery.ServiceCategory(fixture.Category),
|
|
CLIAccess: fixture.CLIAccess,
|
|
Facts: facts,
|
|
ConfigPaths: append([]string(nil), fixture.ConfigPaths...),
|
|
DataPaths: append([]string(nil), fixture.DataPaths...),
|
|
LogPaths: append([]string(nil), fixture.LogPaths...),
|
|
Ports: ports,
|
|
DockerMounts: mounts,
|
|
UserNotes: fixture.UserNotes,
|
|
UserSecrets: userSecrets,
|
|
Confidence: fixture.Confidence,
|
|
AIReasoning: fixture.AIReasoning,
|
|
DiscoveredAt: fixture.DiscoveredAt,
|
|
UpdatedAt: fixture.UpdatedAt,
|
|
ScanDuration: fixture.ScanDuration,
|
|
Fingerprint: fixture.Fingerprint,
|
|
FingerprintedAt: fixture.FingerprintedAt,
|
|
FingerprintSchemaVersion: fixture.FingerprintSchemaVersion,
|
|
CLIAccessVersion: fixture.CLIAccessVersion,
|
|
SuggestedURL: fixture.SuggestedURL,
|
|
SuggestedURLSourceCode: fixture.SuggestedURLSourceCode,
|
|
SuggestedURLSourceDetail: fixture.SuggestedURLSourceDetail,
|
|
SuggestedURLDiagnostic: fixture.SuggestedURLDiagnostic,
|
|
}
|
|
discovery.SuggestedAvailabilityProbe = servicediscovery.SuggestAvailabilityProbe(discovery, fixture.Hostname)
|
|
return discovery
|
|
}
|
|
|
|
func mockDiscoveryFixturesToService(fixtures []*mockfixtures.DiscoveryFixture) []*servicediscovery.ResourceDiscovery {
|
|
converted := make([]*servicediscovery.ResourceDiscovery, 0, len(fixtures))
|
|
for _, fixture := range fixtures {
|
|
if discovery := mockDiscoveryFixtureToService(fixture); discovery != nil {
|
|
converted = append(converted, discovery)
|
|
}
|
|
}
|
|
return converted
|
|
}
|
|
|
|
func mergeDiscoveryRecords(primary, fixtures []*servicediscovery.ResourceDiscovery) []*servicediscovery.ResourceDiscovery {
|
|
if len(primary) == 0 {
|
|
return fixtures
|
|
}
|
|
if len(fixtures) == 0 {
|
|
return primary
|
|
}
|
|
|
|
merged := make([]*servicediscovery.ResourceDiscovery, 0, len(primary)+len(fixtures))
|
|
seen := make(map[string]struct{}, len(primary)+len(fixtures))
|
|
add := func(discovery *servicediscovery.ResourceDiscovery) {
|
|
if discovery == nil {
|
|
return
|
|
}
|
|
key := strings.TrimSpace(discovery.ID)
|
|
if key == "" {
|
|
key = servicediscovery.MakeResourceID(discovery.ResourceType, discovery.TargetID, discovery.ResourceID)
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
merged = append(merged, discovery)
|
|
}
|
|
for _, discovery := range primary {
|
|
add(discovery)
|
|
}
|
|
for _, discovery := range fixtures {
|
|
add(discovery)
|
|
}
|
|
return merged
|
|
}
|
|
|
|
func discoverySummaryList(discoveries []*servicediscovery.ResourceDiscovery) []servicediscovery.DiscoverySummary {
|
|
summaries := make([]servicediscovery.DiscoverySummary, 0, len(discoveries))
|
|
for _, d := range discoveries {
|
|
if d == nil {
|
|
continue
|
|
}
|
|
summaries = append(summaries, discoverySummaryResponse(d))
|
|
}
|
|
return summaries
|
|
}
|
|
|
|
func mockDiscoveryFixtures() []*servicediscovery.ResourceDiscovery {
|
|
if !mockfixtures.IsMockEnabled() {
|
|
return nil
|
|
}
|
|
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixtures())
|
|
}
|
|
|
|
func mockDiscoveryFixtureByResource(resourceType servicediscovery.ResourceType, targetID, resourceID string) *servicediscovery.ResourceDiscovery {
|
|
if !mockfixtures.IsMockEnabled() {
|
|
return nil
|
|
}
|
|
return mockDiscoveryFixtureToService(mockfixtures.CurrentDiscoveryFixtureByResource(string(resourceType), targetID, resourceID))
|
|
}
|
|
|
|
func mockDiscoveryFixturesByType(resourceType servicediscovery.ResourceType) []*servicediscovery.ResourceDiscovery {
|
|
if !mockfixtures.IsMockEnabled() {
|
|
return nil
|
|
}
|
|
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixturesByType(string(resourceType)))
|
|
}
|
|
|
|
func mockDiscoveryFixturesByTarget(targetID string) []*servicediscovery.ResourceDiscovery {
|
|
if !mockfixtures.IsMockEnabled() {
|
|
return nil
|
|
}
|
|
return mockDiscoveryFixturesToService(mockfixtures.CurrentDiscoveryFixturesByTarget(targetID))
|
|
}
|
|
|
|
func mockDiscoveryStatus() map[string]any {
|
|
fixtures := mockDiscoveryFixtures()
|
|
var latest time.Time
|
|
for _, discovery := range fixtures {
|
|
if discovery != nil && discovery.UpdatedAt.After(latest) {
|
|
latest = discovery.UpdatedAt
|
|
}
|
|
}
|
|
return map[string]any{
|
|
"running": false,
|
|
"last_run": latest,
|
|
"interval": "mock",
|
|
"cache_size": len(fixtures),
|
|
"ai_analyzer_set": false,
|
|
"scanner_set": false,
|
|
"store_set": true,
|
|
"command_scanning": false,
|
|
"deep_scan_timeout": "",
|
|
"ai_analysis_timeout": "",
|
|
"max_discovery_age": "",
|
|
"fingerprint_count": len(fixtures),
|
|
"last_fingerprint_scan": latest,
|
|
"changed_count": 0,
|
|
"stale_count": 0,
|
|
}
|
|
}
|
|
|
|
// HandleListDiscoveries handles GET /api/discovery
|
|
func (h *DiscoveryHandlers) HandleListDiscoveries(w http.ResponseWriter, r *http.Request) {
|
|
if h.service == nil && !mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
|
|
var discoveries []*servicediscovery.ResourceDiscovery
|
|
if h.service != nil {
|
|
serviceDiscoveries, 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
|
|
}
|
|
discoveries = serviceDiscoveries
|
|
}
|
|
|
|
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixtures()))
|
|
|
|
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) {
|
|
// 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]
|
|
|
|
var discovery *servicediscovery.ResourceDiscovery
|
|
if h.service != nil {
|
|
serviceDiscovery, 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
|
|
}
|
|
discovery = serviceDiscovery
|
|
} else if !mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
|
|
if discovery == nil {
|
|
discovery = mockDiscoveryFixtureByResource(resourceType, targetID, resourceID)
|
|
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
|
|
}
|
|
if !h.requireCommandScanningAccess(w, r) {
|
|
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 for host-agent discovery, use target ID as a
|
|
// fallback. Workloads should resolve their own display name from state so a
|
|
// parent node is not mistaken for the workload endpoint.
|
|
if req.Hostname == "" && resourceType == servicediscovery.ResourceTypeAgent {
|
|
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))
|
|
}
|
|
|
|
// HandleUpdateAvailabilityProposal handles
|
|
// PUT /api/discovery/{type}/{target}/{id}/availability-proposal. It records a
|
|
// review disposition only; canonical checks are still created explicitly via
|
|
// POST /api/availability-targets.
|
|
func (h *DiscoveryHandlers) HandleUpdateAvailabilityProposal(w http.ResponseWriter, r *http.Request) {
|
|
if h.service == nil {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/discovery/")
|
|
path = strings.TrimSuffix(path, "/availability-proposal")
|
|
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
|
|
}
|
|
discoveryID := servicediscovery.MakeResourceID(resourceType, parts[1], parts[2])
|
|
|
|
var req servicediscovery.UpdateAvailabilityProposalRequest
|
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
writeDiscoveryError(w, http.StatusBadRequest, "Invalid request body")
|
|
return
|
|
}
|
|
status := strings.ToLower(strings.TrimSpace(req.Status))
|
|
if status != "dismissed" && status != "reviewable" {
|
|
writeDiscoveryError(w, http.StatusBadRequest, "status must be dismissed or reviewable")
|
|
return
|
|
}
|
|
if err := h.service.UpdateAvailabilityProposalDisposition(
|
|
discoveryID,
|
|
strings.TrimSpace(req.EvidenceFingerprint),
|
|
status == "dismissed",
|
|
); err != nil {
|
|
switch {
|
|
case errors.Is(err, servicediscovery.ErrAvailabilityProposalNotFound):
|
|
writeDiscoveryError(w, http.StatusNotFound, err.Error())
|
|
case errors.Is(err, servicediscovery.ErrAvailabilityProposalEvidenceChanged):
|
|
writeDiscoveryError(w, http.StatusConflict, err.Error())
|
|
default:
|
|
log.Error().Err(err).Str("id", discoveryID).Msg("Failed to update availability proposal")
|
|
writeDiscoveryError(w, http.StatusInternalServerError, "Failed to update availability proposal")
|
|
}
|
|
return
|
|
}
|
|
|
|
discovery, err := h.service.GetDiscovery(discoveryID)
|
|
if err != nil || discovery == nil {
|
|
writeDiscoveryError(w, http.StatusInternalServerError, "Proposal updated but failed to fetch result")
|
|
return
|
|
}
|
|
if !h.isAdminRequest(r) {
|
|
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) {
|
|
// 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)
|
|
|
|
var progress *servicediscovery.DiscoveryProgress
|
|
if h.service != nil {
|
|
progress = h.service.GetProgress(discoveryID)
|
|
} else if !mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
if progress == nil {
|
|
// Not currently scanning - check if we have a discovery
|
|
var discovery *servicediscovery.ResourceDiscovery
|
|
if h.service != nil {
|
|
serviceDiscovery, err := h.service.GetDiscovery(discoveryID)
|
|
if err == nil {
|
|
discovery = serviceDiscovery
|
|
}
|
|
}
|
|
if discovery == nil {
|
|
discovery = mockDiscoveryFixtureByResource(resourceType, targetID, resourceID)
|
|
}
|
|
if 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 {
|
|
if mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryJSON(w, mockDiscoveryStatus())
|
|
return
|
|
}
|
|
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)
|
|
}
|
|
|
|
// HandleRunDiscovery handles POST /api/discovery/run.
|
|
func (h *DiscoveryHandlers) HandleRunDiscovery(w http.ResponseWriter, r *http.Request) {
|
|
if h.service == nil {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
if !h.requireCommandScanningAccess(w, r) {
|
|
return
|
|
}
|
|
|
|
summary, err := h.service.RunManualDiscoveryRefresh(r.Context())
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("Failed to run manual discovery refresh")
|
|
writeDiscoveryError(w, http.StatusInternalServerError, "Discovery refresh failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeDiscoveryJSON(w, summary)
|
|
}
|
|
|
|
// 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) {
|
|
// 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
|
|
}
|
|
|
|
var discoveries []*servicediscovery.ResourceDiscovery
|
|
if h.service != nil {
|
|
serviceDiscoveries, 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
|
|
}
|
|
discoveries = serviceDiscoveries
|
|
} else if !mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
|
|
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByType(resourceType)))
|
|
|
|
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) {
|
|
// Parse path
|
|
agentID := strings.TrimPrefix(r.URL.Path, "/api/discovery/agent/")
|
|
|
|
var discoveries []*servicediscovery.ResourceDiscovery
|
|
if h.service != nil {
|
|
serviceDiscoveries, 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
|
|
}
|
|
discoveries = serviceDiscoveries
|
|
} else if !mockfixtures.IsMockEnabled() {
|
|
writeDiscoveryError(w, http.StatusServiceUnavailable, "discovery service not configured")
|
|
return
|
|
}
|
|
|
|
summaries := discoverySummaryList(mergeDiscoveryRecords(discoveries, mockDiscoveryFixturesByTarget(agentID)))
|
|
|
|
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)
|
|
}
|