mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
security: add scope checks to AI Patrol and agent profile endpoints
- AI Patrol mutation endpoints (acknowledge, dismiss, suppress, snooze, resolve, findings/note, suppressions/*) now require ai:execute scope to prevent low-privilege tokens from blinding patrol by hiding/suppressing findings - Agent profile admin endpoints (/api/admin/profiles/*) now require settings:write scope to prevent low-privilege tokens from modifying fleet-wide agent behavior
This commit is contained in:
@@ -237,3 +237,25 @@ export function getConfidenceLevel(confidence: number): {
|
||||
}
|
||||
return { label: 'Low confidence', color: 'text-gray-500 dark:text-gray-400' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connected agent info from WebSocket
|
||||
*/
|
||||
export interface ConnectedAgent {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
version: string;
|
||||
platform: string;
|
||||
connected_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of agents connected via WebSocket (for command execution)
|
||||
*/
|
||||
export async function getConnectedAgents(): Promise<{ count: number; agents: ConnectedAgent[] }> {
|
||||
const response = await apiFetch('/api/ai/agents');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get connected agents');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatDiscoveryAge,
|
||||
getCategoryDisplayName,
|
||||
getConfidenceLevel,
|
||||
getConnectedAgents,
|
||||
} from '../../api/discovery';
|
||||
import { GuestMetadataAPI } from '../../api/guestMetadata';
|
||||
import { eventBus } from '../../stores/events';
|
||||
@@ -23,6 +24,8 @@ interface DiscoveryTabProps {
|
||||
customUrl?: string;
|
||||
/** Called after a URL is saved or deleted so the parent can update its state */
|
||||
onCustomUrlChange?: (url: string) => void;
|
||||
/** Whether commands are enabled for this host (from host agent config) */
|
||||
commandsEnabled?: boolean;
|
||||
}
|
||||
|
||||
// Construct the resource ID in the same format the backend uses
|
||||
@@ -94,6 +97,43 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch connected agents (for WebSocket command execution)
|
||||
const [connectedAgents] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
return await getConnectedAgents();
|
||||
} catch {
|
||||
return { count: 0, agents: [] };
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if this host has a connected agent for command execution
|
||||
// Matches backend logic in deep_scanner.go findAgentForHost()
|
||||
const hasConnectedAgent = () => {
|
||||
const agents = connectedAgents()?.agents || [];
|
||||
|
||||
// First try exact match on agent ID
|
||||
if (agents.some(agent => agent.agent_id === props.hostId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Then try hostname match
|
||||
if (agents.some(agent =>
|
||||
agent.hostname === props.hostname ||
|
||||
agent.hostname === props.hostId
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If only one agent connected, backend will use it (fallback)
|
||||
if (agents.length === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Fetch discovery data
|
||||
const [discovery, { refetch, mutate }] = createResource(
|
||||
() => ({ type: props.resourceType, host: props.hostId, id: props.resourceId }),
|
||||
@@ -148,9 +188,18 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
console.error('Discovery failed:', err);
|
||||
// Extract error message for display
|
||||
const message = err instanceof Error ? err.message : 'Discovery scan failed';
|
||||
// Provide helpful message if it's a connection issue
|
||||
// Provide helpful, specific message based on the error and current state
|
||||
if (message.includes('no connected agent')) {
|
||||
setScanError('No agent available. Enable "Pulse Commands" in Settings → Unified Agents for this host.');
|
||||
// Check if commands are enabled to provide more specific guidance
|
||||
if (props.commandsEnabled === false) {
|
||||
setScanError('Commands not enabled. Enable "Pulse Commands" in Settings → Unified Agents for this host.');
|
||||
} else if (props.commandsEnabled === true) {
|
||||
// Commands enabled but no WebSocket connection - likely token scope issue
|
||||
setScanError('Agent not connected for command execution. The API token may be missing the "agent:exec" scope. Check Settings → API Tokens.');
|
||||
} else {
|
||||
// Unknown state - provide general guidance
|
||||
setScanError('No agent available for command execution. Ensure "Pulse Commands" is enabled in Settings → Unified Agents and the API token has "agent:exec" scope.');
|
||||
}
|
||||
} else {
|
||||
setScanError(message);
|
||||
}
|
||||
@@ -420,6 +469,56 @@ export const DiscoveryTab: Component<DiscoveryTabProps> = (props) => {
|
||||
Run a discovery scan to identify services and configurations
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection Status Warning - Show when commands are needed but not available */}
|
||||
<Show when={props.resourceType === 'host' && !connectedAgents.loading}>
|
||||
<Show when={props.commandsEnabled === false}>
|
||||
<div class="mb-4 mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div class="text-xs">
|
||||
<p class="font-medium text-amber-800 dark:text-amber-200">Commands not enabled</p>
|
||||
<p class="text-amber-700 dark:text-amber-300 mt-0.5">
|
||||
Discovery requires command execution. Enable "Pulse Commands" in{' '}
|
||||
<a href="/settings/agents" class="underline hover:no-underline">Settings → Unified Agents</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.commandsEnabled === true && !hasConnectedAgent()}>
|
||||
<div class="mb-4 mx-auto max-w-md rounded-lg border border-amber-200 bg-amber-50 p-3 text-left dark:border-amber-800/50 dark:bg-amber-900/20">
|
||||
<div class="flex items-start gap-2">
|
||||
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<div class="text-xs">
|
||||
<p class="font-medium text-amber-800 dark:text-amber-200">Agent not connected for commands</p>
|
||||
<p class="text-amber-700 dark:text-amber-300 mt-0.5">
|
||||
Commands are enabled, but the agent isn't connected via WebSocket. Check that the API token has the{' '}
|
||||
<code class="px-1 py-0.5 bg-amber-100 dark:bg-amber-800/50 rounded">agent:exec</code>{' '}
|
||||
scope in <a href="/settings/api" class="underline hover:no-underline">Settings → API Tokens</a>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.commandsEnabled === true && hasConnectedAgent()}>
|
||||
<div class="mb-4 mx-auto max-w-md rounded-lg border border-green-200 bg-green-50 p-3 text-left dark:border-green-800/50 dark:bg-green-900/20">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-green-500 dark:text-green-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p class="text-xs font-medium text-green-800 dark:text-green-200">
|
||||
Agent connected and ready for command execution
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<button
|
||||
onClick={() => handleTriggerDiscovery(true)}
|
||||
disabled={isScanning()}
|
||||
|
||||
@@ -350,6 +350,7 @@ export const HostDrawer: Component<HostDrawerProps> = (props) => {
|
||||
guestId={props.host.id}
|
||||
customUrl={props.customUrl}
|
||||
onCustomUrlChange={(url) => props.onCustomUrlChange?.(props.host.id, url)}
|
||||
commandsEnabled={props.host.commandsEnabled}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -173,10 +173,29 @@ func (a *discoveryStateAdapter) GetState() servicediscovery.StateSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Hosts
|
||||
hosts := make([]servicediscovery.Host, len(state.Hosts))
|
||||
for i, h := range state.Hosts {
|
||||
hosts[i] = servicediscovery.Host{
|
||||
ID: h.ID,
|
||||
Hostname: h.Hostname,
|
||||
DisplayName: h.DisplayName,
|
||||
Platform: h.Platform,
|
||||
OSName: h.OSName,
|
||||
OSVersion: h.OSVersion,
|
||||
KernelVersion: h.KernelVersion,
|
||||
Architecture: h.Architecture,
|
||||
CPUCount: h.CPUCount,
|
||||
Status: h.Status,
|
||||
Tags: h.Tags,
|
||||
}
|
||||
}
|
||||
|
||||
return servicediscovery.StateSnapshot{
|
||||
VMs: vms,
|
||||
Containers: containers,
|
||||
DockerHosts: dockerHosts,
|
||||
Hosts: hosts,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -515,11 +515,12 @@ func (r *Router) setupRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
// Config Profile Routes - Protected by Admin Auth and Pro License
|
||||
// Config Profile Routes - Protected by Admin Auth, Settings Scope, and Pro License
|
||||
// SECURITY: Require settings:write scope to prevent low-privilege tokens from modifying agent profiles
|
||||
// r.configProfileHandler.ServeHTTP implements http.Handler, so we wrap it
|
||||
r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
|
||||
r.mux.Handle("/api/admin/profiles/", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, RequireLicenseFeature(r.licenseHandlers, license.FeatureAgentProfiles, func(w http.ResponseWriter, req *http.Request) {
|
||||
http.StripPrefix("/api/admin/profiles", r.configProfileHandler).ServeHTTP(w, req)
|
||||
})))
|
||||
}))))
|
||||
|
||||
// System settings routes
|
||||
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -1421,19 +1422,22 @@ func (r *Router) setupRoutes() {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, r.aiSettingsHandler.HandleGetFindingsHistory))
|
||||
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, r.aiSettingsHandler.HandleForcePatrol))
|
||||
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, r.aiSettingsHandler.HandleAcknowledgeFinding))
|
||||
// SECURITY: AI Patrol read endpoints - require ai:execute scope
|
||||
r.mux.HandleFunc("/api/ai/patrol/history", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetFindingsHistory)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/run", RequireAdmin(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleForcePatrol)))
|
||||
// SECURITY: AI Patrol mutation endpoints - require ai:execute scope to prevent low-privilege tokens from
|
||||
// dismissing, suppressing, or otherwise hiding findings. This prevents attackers from blinding AI Patrol.
|
||||
r.mux.HandleFunc("/api/ai/patrol/acknowledge", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleAcknowledgeFinding)))
|
||||
// Dismiss and resolve don't require Pro license - users should be able to clear findings they can see
|
||||
// This is especially important for users who accumulated findings before fixing the patrol-without-AI bug
|
||||
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, r.aiSettingsHandler.HandleDismissFinding))
|
||||
r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, r.aiSettingsHandler.HandleSetFindingNote))
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, r.aiSettingsHandler.HandleSuppressFinding))
|
||||
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, r.aiSettingsHandler.HandleSnoozeFinding))
|
||||
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, r.aiSettingsHandler.HandleResolveFinding))
|
||||
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, r.aiSettingsHandler.HandleGetPatrolRunHistory))
|
||||
// Suppression rules management - free with patrol
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, func(w http.ResponseWriter, req *http.Request) {
|
||||
r.mux.HandleFunc("/api/ai/patrol/dismiss", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDismissFinding)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/findings/note", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSetFindingNote)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppress", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSuppressFinding)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/snooze", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleSnoozeFinding)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/resolve", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleResolveFinding)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/runs", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetPatrolRunHistory)))
|
||||
// Suppression rules management - require scope to prevent low-privilege tokens from creating suppression rules
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppressions", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, func(w http.ResponseWriter, req *http.Request) {
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
r.aiSettingsHandler.HandleGetSuppressionRules(w, req)
|
||||
@@ -1442,9 +1446,9 @@ func (r *Router) setupRoutes() {
|
||||
default:
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}))
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, r.aiSettingsHandler.HandleDeleteSuppressionRule))
|
||||
r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, r.aiSettingsHandler.HandleGetDismissedFindings))
|
||||
})))
|
||||
r.mux.HandleFunc("/api/ai/patrol/suppressions/", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleDeleteSuppressionRule)))
|
||||
r.mux.HandleFunc("/api/ai/patrol/dismissed", RequireAuth(r.config, RequireScope(config.ScopeAIExecute, r.aiSettingsHandler.HandleGetDismissedFindings)))
|
||||
|
||||
// Patrol Autonomy - monitor/approval free, assisted/full require Pro (enforced in handlers)
|
||||
r.mux.HandleFunc("/api/ai/patrol/autonomy", RequireAdmin(r.config, RequireScope(config.ScopeSettingsWrite, func(w http.ResponseWriter, req *http.Request) {
|
||||
|
||||
@@ -8965,6 +8965,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
|
||||
contentSuccess := 0 // Number of successful storage content fetches
|
||||
contentFailures := 0 // Number of failed storage content fetches
|
||||
storageQueryErrors := 0 // Number of nodes where storage list could not be queried
|
||||
hadPermissionError := false // Track if any permission errors occurred this cycle
|
||||
storagePreserveNeeded := map[string]struct{}{}
|
||||
storageSuccess := map[string]struct{}{}
|
||||
|
||||
@@ -9051,6 +9052,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
|
||||
// Check if this is a permission error
|
||||
if strings.Contains(errStr, "403") || strings.Contains(errStr, "401") ||
|
||||
strings.Contains(errStr, "permission") || strings.Contains(errStr, "forbidden") {
|
||||
hadPermissionError = true
|
||||
m.mu.Lock()
|
||||
m.backupPermissionWarnings[instanceName] = "Missing PVEDatastoreAdmin permission on /storage. Run: pveum aclmod /storage -user pulse-monitor@pam -role PVEDatastoreAdmin"
|
||||
m.mu.Unlock()
|
||||
@@ -9072,11 +9074,6 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
|
||||
continue
|
||||
}
|
||||
|
||||
// Clear any previous permission warning on success
|
||||
m.mu.Lock()
|
||||
delete(m.backupPermissionWarnings, instanceName)
|
||||
m.mu.Unlock()
|
||||
|
||||
contentSuccess++
|
||||
storageSuccess[storage.Storage] = struct{}{}
|
||||
delete(storagePreserveNeeded, storage.Storage)
|
||||
@@ -9212,6 +9209,13 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
|
||||
m.alertManager.CheckBackups(pveStorage, pbsBackups, pmgBackups, guestsByKey, guestsByVMID)
|
||||
}
|
||||
|
||||
// Clear permission warning if no permission errors occurred this cycle
|
||||
if !hadPermissionError {
|
||||
m.mu.Lock()
|
||||
delete(m.backupPermissionWarnings, instanceName)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
log.Debug().
|
||||
Str("instance", instanceName).
|
||||
Int("count", len(allBackups)).
|
||||
|
||||
@@ -370,6 +370,20 @@ func (s *DeepScanner) getTargetID(resourceType ResourceType, resourceID string)
|
||||
func (s *DeepScanner) findAgentForHost(hostID, hostname string) string {
|
||||
agents := s.executor.GetConnectedAgents()
|
||||
|
||||
log.Debug().
|
||||
Str("hostID", hostID).
|
||||
Str("hostname", hostname).
|
||||
Int("connected_agents", len(agents)).
|
||||
Msg("Finding agent for host")
|
||||
|
||||
// Log connected agents for debugging
|
||||
for _, agent := range agents {
|
||||
log.Debug().
|
||||
Str("agent_id", agent.AgentID).
|
||||
Str("agent_hostname", agent.Hostname).
|
||||
Msg("Connected agent")
|
||||
}
|
||||
|
||||
// First try exact match on agent ID
|
||||
for _, agent := range agents {
|
||||
if agent.AgentID == hostID {
|
||||
|
||||
@@ -80,6 +80,22 @@ type StateSnapshot struct {
|
||||
Containers []Container
|
||||
DockerHosts []DockerHost
|
||||
KubernetesClusters []KubernetesCluster
|
||||
Hosts []Host
|
||||
}
|
||||
|
||||
// Host represents a host system (via host-agent).
|
||||
type Host struct {
|
||||
ID string
|
||||
Hostname string
|
||||
DisplayName string
|
||||
Platform string // e.g., "linux", "darwin", "windows"
|
||||
OSName string // e.g., "Unraid", "Ubuntu", "Debian"
|
||||
OSVersion string
|
||||
KernelVersion string
|
||||
Architecture string // e.g., "amd64", "arm64"
|
||||
CPUCount int
|
||||
Status string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
// VM represents a virtual machine.
|
||||
@@ -1125,11 +1141,15 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
|
||||
// Run deep scan if scanner is available
|
||||
var scanResult *ScanResult
|
||||
var scanError error
|
||||
if s.scanner != nil {
|
||||
var err error
|
||||
scanResult, err = s.scanner.Scan(ctx, req)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("id", resourceID).Msg("Deep scan failed, using metadata only")
|
||||
scanResult, scanError = s.scanner.Scan(ctx, req)
|
||||
if scanError != nil {
|
||||
log.Warn().
|
||||
Err(scanError).
|
||||
Str("id", resourceID).
|
||||
Str("resource_type", string(req.ResourceType)).
|
||||
Msg("Deep scan failed, falling back to metadata-only analysis. For full discovery, ensure the host agent is connected with commands enabled.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,6 +1242,19 @@ func (s *Service) DiscoverResource(ctx context.Context, req DiscoveryRequest) (*
|
||||
Msg("Parsed Docker bind mounts from on-demand discovery")
|
||||
}
|
||||
}
|
||||
} else if scanError != nil {
|
||||
// Add note to reasoning when we couldn't run commands
|
||||
metadataNote := "[Note: Discovery was limited to metadata-only analysis because command execution was unavailable. "
|
||||
if strings.Contains(scanError.Error(), "no connected agent") {
|
||||
metadataNote += "To enable full discovery with command execution, ensure the host agent has 'Pulse Commands' enabled in Settings → Unified Agents.]"
|
||||
} else {
|
||||
metadataNote += "Error: " + scanError.Error() + "]"
|
||||
}
|
||||
if discovery.AIReasoning != "" {
|
||||
discovery.AIReasoning = metadataNote + " " + discovery.AIReasoning
|
||||
} else {
|
||||
discovery.AIReasoning = metadataNote
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve user notes from existing discovery
|
||||
@@ -1294,6 +1327,24 @@ func (s *Service) getResourceMetadata(req DiscoveryRequest) map[string]any {
|
||||
break
|
||||
}
|
||||
}
|
||||
case ResourceTypeHost:
|
||||
for _, host := range state.Hosts {
|
||||
if host.ID == req.ResourceID || host.Hostname == req.ResourceID || host.ID == req.HostID {
|
||||
metadata["hostname"] = host.Hostname
|
||||
metadata["display_name"] = host.DisplayName
|
||||
metadata["platform"] = host.Platform
|
||||
metadata["os_name"] = host.OSName
|
||||
metadata["os_version"] = host.OSVersion
|
||||
metadata["kernel_version"] = host.KernelVersion
|
||||
metadata["architecture"] = host.Architecture
|
||||
metadata["cpu_count"] = host.CPUCount
|
||||
metadata["status"] = host.Status
|
||||
if len(host.Tags) > 0 {
|
||||
metadata["tags"] = host.Tags
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return metadata
|
||||
|
||||
@@ -15,4 +15,6 @@ const (
|
||||
ResourceNodes = "nodes"
|
||||
ResourceUsers = "users"
|
||||
ResourceLicense = "license"
|
||||
ResourceAI = "ai"
|
||||
ResourceDiscovery = "discovery"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user