From 5ebb32ce1019bdd3dc2065d09c55c43181266a99 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 20 Oct 2025 17:41:19 +0000 Subject: [PATCH] feat: enhance runtime configuration and system settings management Improves configuration handling and system settings APIs to support v4.24.0 features including runtime logging controls, adaptive polling configuration, and enhanced config export/persistence. Changes: - Add config override system for discovery service - Enhance system settings API with runtime logging controls - Improve config persistence and export functionality - Update security setup handling - Refine monitoring and discovery service integration These changes provide the backend support for the configuration features documented in the v4.24.0 release. --- internal/api/config_handlers.go | 196 +++++++++++++++++++------- internal/api/security_setup_fix.go | 17 ++- internal/api/system_settings.go | 124 +++++++++++++++- internal/config/config.go | 165 ++++++++++++++++++++-- internal/config/export.go | 2 +- internal/config/persistence.go | 24 +++- internal/discovery/config_override.go | 180 +++++++++++++++++++++++ internal/discovery/service.go | 45 ++++-- internal/monitoring/monitor.go | 21 ++- pkg/discovery/discovery.go | 4 - 10 files changed, 677 insertions(+), 101 deletions(-) create mode 100644 internal/discovery/config_override.go diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 93f4c79..a564822 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -28,13 +28,16 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" - "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" + discoveryinternal "github.com/rcourtman/pulse-go-rewrite/internal/discovery" + pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" "github.com/rcourtman/pulse-go-rewrite/pkg/pmg" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" "github.com/rs/zerolog/log" ) +const minProxyReadyVersion = "4.24.0" + // SetupCode represents a one-time setup code for secure node registration type SetupCode struct { ExpiresAt time.Time @@ -2455,29 +2458,28 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http. persistedSettings, err := h.persistence.LoadSystemSettings() if err != nil { log.Warn().Err(err).Msg("Failed to load persisted system settings") - persistedSettings = &config.SystemSettings{} + persistedSettings = config.DefaultSystemSettings() + } + if persistedSettings == nil { + persistedSettings = config.DefaultSystemSettings() } // Get current values from running config - settings := config.SystemSettings{ - // Note: PVE polling is hardcoded to 10s - PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()), - BackupPollingInterval: int(h.config.BackupPollingInterval.Seconds()), - BackendPort: h.config.BackendPort, - FrontendPort: h.config.FrontendPort, - AllowedOrigins: h.config.AllowedOrigins, - ConnectionTimeout: int(h.config.ConnectionTimeout.Seconds()), - UpdateChannel: h.config.UpdateChannel, - AutoUpdateEnabled: h.config.AutoUpdateEnabled, - AutoUpdateCheckInterval: int(h.config.AutoUpdateCheckInterval.Hours()), - AutoUpdateTime: h.config.AutoUpdateTime, - LogLevel: h.config.LogLevel, // Include log level - Theme: persistedSettings.Theme, // Include theme from persisted settings - AllowEmbedding: persistedSettings.AllowEmbedding, // Include allowEmbedding from persisted settings - AllowedEmbedOrigins: persistedSettings.AllowedEmbedOrigins, // Include allowedEmbedOrigins from persisted settings - DiscoveryEnabled: persistedSettings.DiscoveryEnabled, // Include discoveryEnabled from persisted settings - DiscoverySubnet: persistedSettings.DiscoverySubnet, // Include discoverySubnet from persisted settings - } + settings := *persistedSettings + settings.PBSPollingInterval = int(h.config.PBSPollingInterval.Seconds()) + settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds()) + settings.BackendPort = h.config.BackendPort + settings.FrontendPort = h.config.FrontendPort + settings.AllowedOrigins = h.config.AllowedOrigins + settings.ConnectionTimeout = int(h.config.ConnectionTimeout.Seconds()) + settings.UpdateChannel = h.config.UpdateChannel + settings.AutoUpdateEnabled = h.config.AutoUpdateEnabled + settings.AutoUpdateCheckInterval = int(h.config.AutoUpdateCheckInterval.Hours()) + settings.AutoUpdateTime = h.config.AutoUpdateTime + settings.LogLevel = h.config.LogLevel + settings.DiscoveryEnabled = h.config.DiscoveryEnabled + settings.DiscoverySubnet = h.config.DiscoverySubnet + settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery) backupEnabled := h.config.EnableBackupPolling settings.BackupPollingEnabled = &backupEnabled @@ -2927,7 +2929,11 @@ func (h *ConfigHandlers) HandleDiscoverServers(w http.ResponseWriter, r *http.Re log.Info().Str("subnet", subnet).Msg("Starting manual discovery scan") - scanner := discovery.NewScanner() + scanner, buildErr := discoveryinternal.BuildScanner(h.config.Discovery) + if buildErr != nil { + log.Warn().Err(buildErr).Msg("Falling back to default scanner for manual discovery") + scanner = pkgdiscovery.NewScanner() + } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -3594,6 +3600,40 @@ SSH_SENSORS_PUBLIC_KEY="%s" SSH_PROXY_KEY_ENTRY="restrict,permitopen=\"*:22\" $SSH_PROXY_PUBLIC_KEY # pulse-proxyjump" SSH_SENSORS_KEY_ENTRY="command=\"sensors -j\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty $SSH_SENSORS_PUBLIC_KEY # pulse-sensors" TEMPERATURE_ENABLED=false +TEMP_MONITORING_AVAILABLE=true +MIN_PROXY_VERSION="%s" +PULSE_VERSION_ENDPOINT="%s/api/version" + +version_ge() { + if command -v dpkg >/dev/null 2>&1; then + dpkg --compare-versions "$1" ge "$2" + return $? + fi + if command -v sort >/dev/null 2>&1; then + [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ] + return $? + fi + [ "$1" = "$2" ] +} + +PULSE_VERSION=$(curl -s -f "$PULSE_VERSION_ENDPOINT" 2>/dev/null | awk -F'"' '/"version":/{print $4}' | head -n1) +if [ -z "$PULSE_VERSION" ]; then + echo "" + echo "⚠️ Could not determine Pulse version from $PULSE_VERSION_ENDPOINT" + echo " Temperature proxy requires Pulse $MIN_PROXY_VERSION or later." + TEMP_MONITORING_AVAILABLE=false +elif ! version_ge "$PULSE_VERSION" "$MIN_PROXY_VERSION"; then + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "⚠️ Pulse upgrade required for temperature proxy" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Detected Pulse version: $PULSE_VERSION" + echo "Minimum required version: $MIN_PROXY_VERSION" + echo "" + echo "Please upgrade the Pulse container before rerunning this setup script." + echo "" + TEMP_MONITORING_AVAILABLE=false +fi # Check if temperature proxy is available and override SSH key if it is PROXY_KEY_URL="%s/api/system/proxy-public-key" @@ -3640,11 +3680,10 @@ if command -v pct >/dev/null 2>&1; then fi fi -# Track whether temperature monitoring can work -TEMP_MONITORING_AVAILABLE=true +# Track whether temperature monitoring can work (may be disabled by checks above) # If Pulse is containerized, try to install proxy automatically -if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then +if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then # Try automatic installation - proxy keeps SSH credentials on the host for security if true; then # Download installer script from Pulse server @@ -3677,42 +3716,91 @@ if [ "$PULSE_IS_CONTAINERIZED" = true ] && [ -n "$PULSE_CTID" ]; then echo "✓ Secure proxy architecture enabled" echo " SSH keys are managed on the host for enhanced security" echo "" + else + echo "" + echo "⚠️ pulse-sensor-proxy service is not active. Check logs with:" + echo " journalctl -u pulse-sensor-proxy -n 40" + TEMP_MONITORING_AVAILABLE=false fi - # Configure socket bind mount and restart container automatically - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Finalizing Setup" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - echo "Configuring socket bind mount for container $PULSE_CTID..." + if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ ! -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]; then + echo " ✗ Proxy socket not found at /run/pulse-sensor-proxy/pulse-sensor-proxy.sock" + echo " Check logs with: journalctl -u pulse-sensor-proxy -n 40" + TEMP_MONITORING_AVAILABLE=false + fi - # Configure bind mount for proxy socket - pct set "$PULSE_CTID" -mp0 /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy - - # Check if container is currently running - if pct status "$PULSE_CTID" 2>/dev/null | grep -q "running"; then + if [ "$TEMP_MONITORING_AVAILABLE" = true ]; then + # Configure socket bind mount and restart container automatically + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "Finalizing Setup" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - echo "⚠️ Container $PULSE_CTID is currently running." - echo " The proxy socket will be available after a restart." - echo "" - echo " Restart manually when ready:" - echo " pct stop $PULSE_CTID && sleep 2 && pct start $PULSE_CTID" - echo "" - echo " Or the socket may be hot-plugged automatically (LXC 4.0+)" - echo "" - else - echo "Container is stopped, starting it now..." + echo "Configuring socket bind mount for container $PULSE_CTID..." - # Set up trap to restart container even if script is interrupted - trap "echo 'Starting container before exit...'; pct start $PULSE_CTID 2>/dev/null || true" EXIT INT TERM + # Configure bind mount for proxy socket + pct set "$PULSE_CTID" -mp0 /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy - pct start "$PULSE_CTID" + CONTAINER_RESTARTED=false + # Check if container is currently running + if pct status "$PULSE_CTID" 2>/dev/null | grep -q "running"; then + echo "" + echo "⚠️ Container $PULSE_CTID must be restarted to expose the proxy socket." + echo "Restart now? [Y/n]" + printf "> " + if [ -t 0 ]; then + read -n 1 -r RESTART_REPLY + echo "" + else + if read -n 1 -r RESTART_REPLY /dev/null; then + echo "" + else + RESTART_REPLY="n" + echo "(No terminal available - skipping automatic restart)" + fi + fi + RESTART_REPLY=${RESTART_REPLY:-Y} + if [[ "$RESTART_REPLY" =~ ^[Yy]$ ]]; then + echo "" + echo "Restarting container $PULSE_CTID..." + if pct stop "$PULSE_CTID" && sleep 2 && pct start "$PULSE_CTID"; then + echo " ✓ Container restarted" + CONTAINER_RESTARTED=true + else + echo " ✗ Failed to restart container automatically." + echo " Please run: pct stop $PULSE_CTID && sleep 2 && pct start $PULSE_CTID" + fi + else + echo "" + echo "Please restart manually when ready:" + echo " pct stop $PULSE_CTID && sleep 2 && pct start $PULSE_CTID" + fi + else + echo "Container is stopped, starting it now..." - # Clear the trap after successful start - trap - EXIT INT TERM + # Set up trap to restart container even if script is interrupted + trap "echo 'Starting container before exit...'; pct start $PULSE_CTID 2>/dev/null || true" EXIT INT TERM - echo " ✓ Container started successfully" - echo "" + if pct start "$PULSE_CTID"; then + CONTAINER_RESTARTED=true + echo " ✓ Container started successfully" + echo "" + else + echo " ✗ Failed to start container $PULSE_CTID automatically." + fi + + # Clear the trap after successful start + trap - EXIT INT TERM + fi + + if [ "$CONTAINER_RESTARTED" = true ]; then + if ! pct exec "$PULSE_CTID" -- test -S /mnt/pulse-proxy/pulse-sensor-proxy.sock 2>/dev/null; then + echo " ✗ Proxy socket not visible inside the container (/mnt/pulse-proxy/pulse-sensor-proxy.sock)" + echo " Verify the bind mount and container restart completed successfully." + TEMP_MONITORING_AVAILABLE=false + fi + else + TEMP_MONITORING_AVAILABLE=false + fi fi else echo "" @@ -4326,7 +4414,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then fi `, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, - authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshKeys.ProxyPublicKey, sshKeys.SensorsPublicKey, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName) + authToken, pulseURL, serverHost, tokenName, tokenName, storagePerms, sshKeys.ProxyPublicKey, sshKeys.SensorsPublicKey, minProxyReadyVersion, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, pulseURL, authToken, pulseURL, authToken, pulseURL, tokenName) } else { // PBS script = fmt.Sprintf(`#!/bin/bash diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 6de447d..9927272 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -156,15 +156,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { } log.Info().Msg("Runtime config updated with new security settings - active immediately") - // Save system settings to system.json - systemSettings := config.SystemSettings{ - ConnectionTimeout: 10, // Default - AutoUpdateEnabled: false, // Default - } - if err := r.persistence.SaveSystemSettings(systemSettings); err != nil { - log.Error().Err(err).Msg("Failed to save system settings") - // Continue anyway - not critical for auth setup - } + // Save system settings to system.json + systemSettings := config.DefaultSystemSettings() + systemSettings.ConnectionTimeout = 10 // Default seconds + systemSettings.AutoUpdateEnabled = false // Default disabled + if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil { + log.Error().Err(err).Msg("Failed to save system settings") + // Continue anyway - not critical for auth setup + } // Detect environment isSystemd := os.Getenv("INVOCATION_ID") != "" diff --git a/internal/api/system_settings.go b/internal/api/system_settings.go index 38e1e6f..590e5ec 100644 --- a/internal/api/system_settings.go +++ b/internal/api/system_settings.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "os" "path/filepath" @@ -152,6 +153,107 @@ func validateSystemSettings(settings *config.SystemSettings, rawRequest map[stri } } + if val, ok := rawRequest["discoveryConfig"]; ok { + cfgMap, ok := val.(map[string]interface{}) + if !ok { + return fmt.Errorf("discoveryConfig must be an object") + } + + if envVal, exists := cfgMap["environmentOverride"]; exists { + envStr, ok := envVal.(string) + if !ok { + return fmt.Errorf("discoveryConfig.environmentOverride must be a string") + } + if !config.IsValidDiscoveryEnvironment(envStr) { + return fmt.Errorf("invalid discovery environment override: %s", envStr) + } + } + + if allowVal, exists := cfgMap["subnetAllowlist"]; exists { + items, ok := allowVal.([]interface{}) + if !ok { + return fmt.Errorf("discoveryConfig.subnetAllowlist must be an array of CIDR strings") + } + for _, item := range items { + cidr, ok := item.(string) + if !ok { + return fmt.Errorf("discoveryConfig.subnetAllowlist entries must be strings") + } + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid CIDR in discoveryConfig.subnetAllowlist: %s", cidr) + } + } + } + + if blockVal, exists := cfgMap["subnetBlocklist"]; exists { + items, ok := blockVal.([]interface{}) + if !ok { + return fmt.Errorf("discoveryConfig.subnetBlocklist must be an array of CIDR strings") + } + for _, item := range items { + cidr, ok := item.(string) + if !ok { + return fmt.Errorf("discoveryConfig.subnetBlocklist entries must be strings") + } + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid CIDR in discoveryConfig.subnetBlocklist: %s", cidr) + } + } + } + + if hostsVal, exists := cfgMap["maxHostsPerScan"]; exists { + value, ok := hostsVal.(float64) + if !ok { + return fmt.Errorf("discoveryConfig.maxHostsPerScan must be a number") + } + if value <= 0 { + return fmt.Errorf("discoveryConfig.maxHostsPerScan must be greater than zero") + } + } + + if concurrentVal, exists := cfgMap["maxConcurrent"]; exists { + value, ok := concurrentVal.(float64) + if !ok { + return fmt.Errorf("discoveryConfig.maxConcurrent must be a number") + } + if value <= 0 || value > 1000 { + return fmt.Errorf("discoveryConfig.maxConcurrent must be between 1 and 1000") + } + } + + if val, exists := cfgMap["enableReverseDns"]; exists { + if _, ok := val.(bool); !ok { + return fmt.Errorf("discoveryConfig.enableReverseDns must be a boolean") + } + } + + if val, exists := cfgMap["scanGateways"]; exists { + if _, ok := val.(bool); !ok { + return fmt.Errorf("discoveryConfig.scanGateways must be a boolean") + } + } + + if val, exists := cfgMap["dialTimeoutMs"]; exists { + timeout, ok := val.(float64) + if !ok { + return fmt.Errorf("discoveryConfig.dialTimeoutMs must be a number") + } + if timeout <= 0 { + return fmt.Errorf("discoveryConfig.dialTimeoutMs must be greater than zero") + } + } + + if val, exists := cfgMap["httpTimeoutMs"]; exists { + timeout, ok := val.(float64) + if !ok { + return fmt.Errorf("discoveryConfig.httpTimeoutMs must be a number") + } + if timeout <= 0 { + return fmt.Errorf("discoveryConfig.httpTimeoutMs must be greater than zero") + } + } + } + // Validate connection timeout (min 1 second, max 5 minutes) if val, ok := rawRequest["connectionTimeout"]; ok { if timeout, ok := val.(float64); ok { @@ -204,7 +306,10 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r settings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load system settings") - settings = &config.SystemSettings{} + settings = config.DefaultSystemSettings() + } + if settings == nil { + settings = config.DefaultSystemSettings() } // Log loaded settings for debugging @@ -217,6 +322,7 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds()) enabled := h.config.EnableBackupPolling settings.BackupPollingEnabled = &enabled + settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery) } // Include env override information @@ -268,10 +374,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter existingSettings, err := h.persistence.LoadSystemSettings() if err != nil { log.Error().Err(err).Msg("Failed to load existing settings") - existingSettings = &config.SystemSettings{} + existingSettings = config.DefaultSystemSettings() } if existingSettings == nil { - existingSettings = &config.SystemSettings{} + existingSettings = config.DefaultSystemSettings() } // Read the request body into a map to check which fields were provided @@ -303,6 +409,7 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter // Start with existing settings settings := *existingSettings + discoveryConfigUpdated := false // Only update fields that were provided in the request // Note: PVE polling is hardcoded to 10s, legacy polling fields are ignored @@ -336,6 +443,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter if updates.DiscoverySubnet != "" { settings.DiscoverySubnet = updates.DiscoverySubnet } + if _, ok := rawRequest["discoveryConfig"]; ok { + settings.DiscoveryConfig = config.CloneDiscoveryConfig(updates.DiscoveryConfig) + discoveryConfigUpdated = true + } // Allow clearing of AllowedEmbedOrigins by setting to empty string if _, ok := rawRequest["allowedEmbedOrigins"]; ok { settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins @@ -401,6 +512,7 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter if settings.DiscoverySubnet != "" { h.config.DiscoverySubnet = settings.DiscoverySubnet } + h.config.Discovery = config.CloneDiscoveryConfig(settings.DiscoveryConfig) // Start or stop discovery service based on setting change if h.monitor != nil { @@ -422,6 +534,12 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter svc.SetSubnet(settings.DiscoverySubnet) } } + if discoveryConfigUpdated && settings.DiscoveryEnabled { + if svc := h.monitor.GetDiscoveryService(); svc != nil { + log.Info().Msg("Discovery configuration changed; triggering refresh") + svc.ForceRefresh() + } + } } // Save to persistence diff --git a/internal/config/config.go b/internal/config/config.go index 54db08b..7e895b7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -130,8 +130,9 @@ type Config struct { AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"` // Discovery settings - DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"` - DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"` + DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"` + DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"` + Discovery DiscoveryConfig `json:"discoveryConfig"` // Deprecated - for backward compatibility Port int `envconfig:"PORT"` // Maps to BackendPort @@ -141,6 +142,71 @@ type Config struct { EnvOverrides map[string]bool `json:"-"` } +// DiscoveryConfig captures overrides for network discovery behaviour. +type DiscoveryConfig struct { + EnvironmentOverride string `json:"environmentOverride,omitempty"` + SubnetAllowlist []string `json:"subnetAllowlist,omitempty"` + SubnetBlocklist []string `json:"subnetBlocklist,omitempty"` + MaxHostsPerScan int `json:"maxHostsPerScan,omitempty"` + MaxConcurrent int `json:"maxConcurrent,omitempty"` + EnableReverseDNS bool `json:"enableReverseDns"` + ScanGateways bool `json:"scanGateways"` + DialTimeout int `json:"dialTimeoutMs,omitempty"` + HTTPTimeout int `json:"httpTimeoutMs,omitempty"` +} + +// DefaultDiscoveryConfig returns opinionated defaults for discovery behaviour. +func DefaultDiscoveryConfig() DiscoveryConfig { + return DiscoveryConfig{ + EnvironmentOverride: "auto", + SubnetAllowlist: []string{}, + SubnetBlocklist: []string{"169.254.0.0/16"}, + MaxHostsPerScan: 1024, + MaxConcurrent: 50, + EnableReverseDNS: true, + ScanGateways: true, + DialTimeout: 1000, + HTTPTimeout: 2000, + } +} + +// CloneDiscoveryConfig returns a deep copy of the provided discovery config. +func CloneDiscoveryConfig(cfg DiscoveryConfig) DiscoveryConfig { + clone := cfg + if cfg.SubnetAllowlist != nil { + clone.SubnetAllowlist = append([]string(nil), cfg.SubnetAllowlist...) + } + if cfg.SubnetBlocklist != nil { + clone.SubnetBlocklist = append([]string(nil), cfg.SubnetBlocklist...) + } + return clone +} + +// IsValidDiscoveryEnvironment reports whether the supplied override is recognised. +func IsValidDiscoveryEnvironment(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto", "native", "docker_host", "docker_bridge", "lxc_privileged", "lxc_unprivileged": + return true + default: + return false + } +} + +func splitAndTrim(value string) []string { + if value == "" { + return []string{} + } + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, part := range parts { + trimmed := strings.TrimSpace(part) + if trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + // PVEInstance represents a Proxmox VE connection type PVEInstance struct { Name string @@ -284,9 +350,11 @@ func Load() (*Config, error) { DiscoveryEnabled: true, DiscoverySubnet: "auto", EnvOverrides: make(map[string]bool), - OIDC: NewOIDCConfig(), + OIDC: NewOIDCConfig(), } + cfg.Discovery = DefaultDiscoveryConfig() + // Initialize persistence persistence := NewConfigPersistence(dataDir) if persistence != nil { @@ -361,6 +429,7 @@ func Load() (*Config, error) { if systemSettings.DiscoverySubnet != "" { cfg.DiscoverySubnet = systemSettings.DiscoverySubnet } + cfg.Discovery = CloneDiscoveryConfig(systemSettings.DiscoveryConfig) // APIToken no longer loaded from system.json - only from .env log.Info(). Str("updateChannel", cfg.UpdateChannel). @@ -369,13 +438,11 @@ func Load() (*Config, error) { } else { // No system.json exists - create default one log.Info().Msg("No system.json found, creating default") - defaultSettings := SystemSettings{ - ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()), - AutoUpdateEnabled: false, - } - if err := persistence.SaveSystemSettings(defaultSettings); err != nil { - log.Warn().Err(err).Msg("Failed to create default system.json") - } + defaultSettings := DefaultSystemSettings() + defaultSettings.ConnectionTimeout = int(cfg.ConnectionTimeout.Seconds()) + if err := persistence.SaveSystemSettings(*defaultSettings); err != nil { + log.Warn().Err(err).Msg("Failed to create default system.json") + } } if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil { @@ -741,6 +808,83 @@ func Load() (*Config, error) { cfg.EnvOverrides["discoverySubnet"] = true log.Info().Str("subnet", discoverySubnet).Msg("Discovery subnet overridden by DISCOVERY_SUBNET env var") } + if envOverride := strings.TrimSpace(os.Getenv("DISCOVERY_ENVIRONMENT_OVERRIDE")); envOverride != "" { + if IsValidDiscoveryEnvironment(envOverride) { + cfg.Discovery.EnvironmentOverride = strings.ToLower(envOverride) + cfg.EnvOverrides["discoveryEnvironmentOverride"] = true + log.Info().Str("environment", cfg.Discovery.EnvironmentOverride).Msg("Discovery environment override set by DISCOVERY_ENVIRONMENT_OVERRIDE") + } else { + log.Warn().Str("value", envOverride).Msg("Ignoring invalid DISCOVERY_ENVIRONMENT_OVERRIDE value") + } + } + if allowlistEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SUBNET_ALLOWLIST")); allowlistEnv != "" { + parts := splitAndTrim(allowlistEnv) + cfg.Discovery.SubnetAllowlist = parts + cfg.EnvOverrides["discoverySubnetAllowlist"] = true + log.Info().Int("allowlistCount", len(parts)).Msg("Discovery subnet allowlist overridden by DISCOVERY_SUBNET_ALLOWLIST") + } + if blocklistEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SUBNET_BLOCKLIST")); blocklistEnv != "" { + parts := splitAndTrim(blocklistEnv) + cfg.Discovery.SubnetBlocklist = parts + cfg.EnvOverrides["discoverySubnetBlocklist"] = true + log.Info().Int("blocklistCount", len(parts)).Msg("Discovery subnet blocklist overridden by DISCOVERY_SUBNET_BLOCKLIST") + } + if maxHostsEnv := strings.TrimSpace(os.Getenv("DISCOVERY_MAX_HOSTS_PER_SCAN")); maxHostsEnv != "" { + if v, err := strconv.Atoi(maxHostsEnv); err == nil && v > 0 { + cfg.Discovery.MaxHostsPerScan = v + cfg.EnvOverrides["discoveryMaxHostsPerScan"] = true + log.Info().Int("maxHostsPerScan", v).Msg("Discovery max hosts per scan overridden by DISCOVERY_MAX_HOSTS_PER_SCAN") + } else { + log.Warn().Str("value", maxHostsEnv).Msg("Ignoring invalid DISCOVERY_MAX_HOSTS_PER_SCAN value") + } + } + if maxConcurrentEnv := strings.TrimSpace(os.Getenv("DISCOVERY_MAX_CONCURRENT")); maxConcurrentEnv != "" { + if v, err := strconv.Atoi(maxConcurrentEnv); err == nil && v > 0 { + cfg.Discovery.MaxConcurrent = v + cfg.EnvOverrides["discoveryMaxConcurrent"] = true + log.Info().Int("maxConcurrent", v).Msg("Discovery concurrency overridden by DISCOVERY_MAX_CONCURRENT") + } else { + log.Warn().Str("value", maxConcurrentEnv).Msg("Ignoring invalid DISCOVERY_MAX_CONCURRENT value") + } + } + if reverseDNSEnv := strings.TrimSpace(os.Getenv("DISCOVERY_ENABLE_REVERSE_DNS")); reverseDNSEnv != "" { + switch strings.ToLower(reverseDNSEnv) { + case "0", "false", "no", "off": + cfg.Discovery.EnableReverseDNS = false + default: + cfg.Discovery.EnableReverseDNS = true + } + cfg.EnvOverrides["discoveryEnableReverseDns"] = true + log.Info().Bool("enableReverseDNS", cfg.Discovery.EnableReverseDNS).Msg("Discovery reverse DNS setting overridden by DISCOVERY_ENABLE_REVERSE_DNS") + } + if scanGatewaysEnv := strings.TrimSpace(os.Getenv("DISCOVERY_SCAN_GATEWAYS")); scanGatewaysEnv != "" { + switch strings.ToLower(scanGatewaysEnv) { + case "0", "false", "no", "off": + cfg.Discovery.ScanGateways = false + default: + cfg.Discovery.ScanGateways = true + } + cfg.EnvOverrides["discoveryScanGateways"] = true + log.Info().Bool("scanGateways", cfg.Discovery.ScanGateways).Msg("Discovery gateway scanning overridden by DISCOVERY_SCAN_GATEWAYS") + } + if dialTimeoutEnv := strings.TrimSpace(os.Getenv("DISCOVERY_DIAL_TIMEOUT_MS")); dialTimeoutEnv != "" { + if v, err := strconv.Atoi(dialTimeoutEnv); err == nil && v > 0 { + cfg.Discovery.DialTimeout = v + cfg.EnvOverrides["discoveryDialTimeoutMs"] = true + log.Info().Int("dialTimeoutMs", v).Msg("Discovery dial timeout overridden by DISCOVERY_DIAL_TIMEOUT_MS") + } else { + log.Warn().Str("value", dialTimeoutEnv).Msg("Ignoring invalid DISCOVERY_DIAL_TIMEOUT_MS value") + } + } + if httpTimeoutEnv := strings.TrimSpace(os.Getenv("DISCOVERY_HTTP_TIMEOUT_MS")); httpTimeoutEnv != "" { + if v, err := strconv.Atoi(httpTimeoutEnv); err == nil && v > 0 { + cfg.Discovery.HTTPTimeout = v + cfg.EnvOverrides["discoveryHttpTimeoutMs"] = true + log.Info().Int("httpTimeoutMs", v).Msg("Discovery HTTP timeout overridden by DISCOVERY_HTTP_TIMEOUT_MS") + } else { + log.Warn().Str("value", httpTimeoutEnv).Msg("Ignoring invalid DISCOVERY_HTTP_TIMEOUT_MS value") + } + } if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" { cfg.LogLevel = logLevel cfg.EnvOverrides["logLevel"] = true @@ -820,6 +964,7 @@ func SaveConfig(cfg *Config) error { LogLevel: cfg.LogLevel, DiscoveryEnabled: cfg.DiscoveryEnabled, DiscoverySubnet: cfg.DiscoverySubnet, + DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery), AdaptivePollingEnabled: &adaptiveEnabled, AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second), AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second), diff --git a/internal/config/export.go b/internal/config/export.go index 587ce24..1aca2d0 100644 --- a/internal/config/export.go +++ b/internal/config/export.go @@ -71,7 +71,7 @@ func (c *ConfigPersistence) ExportConfig(passphrase string) (string, error) { return "", fmt.Errorf("failed to load system settings: %w", err) } if systemSettings == nil { - systemSettings = &SystemSettings{} + systemSettings = DefaultSystemSettings() } oidcConfig, err := c.LoadOIDCConfig() diff --git a/internal/config/persistence.go b/internal/config/persistence.go index d72f5dc..ceb0251 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -682,12 +682,27 @@ type SystemSettings struct { LogLevel string `json:"logLevel,omitempty"` DiscoveryEnabled bool `json:"discoveryEnabled"` DiscoverySubnet string `json:"discoverySubnet,omitempty"` + DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"` Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding // APIToken removed - now handled via .env file only } +// DefaultSystemSettings returns a SystemSettings struct populated with sane defaults. +func DefaultSystemSettings() *SystemSettings { + defaultDiscovery := DefaultDiscoveryConfig() + return &SystemSettings{ + PBSPollingInterval: 60, + PMGPollingInterval: 60, + AutoUpdateEnabled: false, + DiscoveryEnabled: true, + DiscoverySubnet: "auto", + DiscoveryConfig: defaultDiscovery, + AllowEmbedding: false, + } +} + // SaveNodesConfig saves nodes configuration to file (encrypted) func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance, pmgInstances []PMGInstance) error { return c.saveNodesConfig(pveInstances, pbsInstances, pmgInstances, false) @@ -1154,13 +1169,16 @@ func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) { return nil, err } - var settings SystemSettings - if err := json.Unmarshal(data, &settings); err != nil { + settings := DefaultSystemSettings() + if settings == nil { + settings = &SystemSettings{} + } + if err := json.Unmarshal(data, settings); err != nil { return nil, err } log.Info().Str("file", c.systemFile).Msg("System settings loaded") - return &settings, nil + return settings, nil } // updateEnvFile updates the .env file with new system settings diff --git a/internal/discovery/config_override.go b/internal/discovery/config_override.go new file mode 100644 index 0000000..70113b5 --- /dev/null +++ b/internal/discovery/config_override.go @@ -0,0 +1,180 @@ +package discovery + +import ( + "fmt" + "net" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" + "github.com/rcourtman/pulse-go-rewrite/pkg/discovery/envdetect" +) + +// BuildScanner creates a discovery scanner configured using the supplied discovery config. +func BuildScanner(cfg config.DiscoveryConfig) (*pkgdiscovery.Scanner, error) { + profile, err := envdetect.DetectEnvironment() + if err != nil { + return nil, err + } + + ApplyConfigToProfile(profile, cfg) + return pkgdiscovery.NewScannerWithProfile(profile), nil +} + +// ApplyConfigToProfile mutates the supplied environment profile according to the discovery config. +func ApplyConfigToProfile(profile *envdetect.EnvironmentProfile, cfg config.DiscoveryConfig) { + if profile == nil { + return + } + + // Environment override + if env, ok := environmentFromOverride(cfg.EnvironmentOverride); ok { + profile.Type = env + filterPhasesForEnvironment(profile, env) + } else if cfg.EnvironmentOverride != "" && strings.ToLower(cfg.EnvironmentOverride) != "auto" { + profile.Warnings = append(profile.Warnings, fmt.Sprintf("Unknown environment override: %s", cfg.EnvironmentOverride)) + } + + // Apply subnet blocklist + blocked := parseCIDRMap(cfg.SubnetBlocklist, &profile.Warnings) + if len(blocked) > 0 { + var filtered []envdetect.SubnetPhase + for _, phase := range profile.Phases { + var kept []net.IPNet + for _, subnet := range phase.Subnets { + if _, blocked := blocked[subnet.String()]; blocked { + continue + } + kept = append(kept, subnet) + } + if len(kept) > 0 { + phase.Subnets = kept + filtered = append(filtered, phase) + } + } + profile.Phases = filtered + } + + // Apply subnet allowlist as highest priority phase + if len(cfg.SubnetAllowlist) > 0 { + allowlist := parseCIDRs(cfg.SubnetAllowlist, &profile.Warnings) + if len(allowlist) > 0 { + if len(blocked) > 0 { + filtered := allowlist[:0] + for _, subnet := range allowlist { + if _, skip := blocked[subnet.String()]; skip { + continue + } + filtered = append(filtered, subnet) + } + allowlist = filtered + } + } + if len(allowlist) > 0 { + allowPhase := envdetect.SubnetPhase{ + Name: "config_allowlist", + Subnets: allowlist, + Confidence: 1.0, + Priority: 0, + } + profile.Phases = append([]envdetect.SubnetPhase{allowPhase}, profile.Phases...) + } + } + + // Override scan policy + if cfg.MaxHostsPerScan > 0 { + profile.Policy.MaxHostsPerScan = cfg.MaxHostsPerScan + } + if cfg.MaxConcurrent > 0 { + profile.Policy.MaxConcurrent = cfg.MaxConcurrent + } + profile.Policy.EnableReverseDNS = cfg.EnableReverseDNS + profile.Policy.ScanGateways = cfg.ScanGateways + + if cfg.DialTimeout > 0 { + profile.Policy.DialTimeout = time.Duration(cfg.DialTimeout) * time.Millisecond + } + if cfg.HTTPTimeout > 0 { + profile.Policy.HTTPTimeout = time.Duration(cfg.HTTPTimeout) * time.Millisecond + } +} + +func parseCIDRs(values []string, warnings *[]string) []net.IPNet { + var subnets []net.IPNet + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + _, ipNet, err := net.ParseCIDR(value) + if err != nil { + if warnings != nil { + *warnings = append(*warnings, fmt.Sprintf("Invalid CIDR '%s' ignored", value)) + } + continue + } + subnets = append(subnets, *ipNet) + } + return subnets +} + +func parseCIDRMap(values []string, warnings *[]string) map[string]struct{} { + cidrs := parseCIDRs(values, warnings) + result := make(map[string]struct{}, len(cidrs)) + for _, cidr := range cidrs { + result[cidr.String()] = struct{}{} + } + return result +} + +func environmentFromOverride(value string) (envdetect.Environment, bool) { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "", "auto": + return envdetect.Unknown, false + case "native": + return envdetect.Native, true + case "docker_host": + return envdetect.DockerHost, true + case "docker_bridge": + return envdetect.DockerBridge, true + case "lxc_privileged": + return envdetect.LXCPrivileged, true + case "lxc_unprivileged": + return envdetect.LXCUnprivileged, true + default: + return envdetect.Unknown, false + } +} + +func filterPhasesForEnvironment(profile *envdetect.EnvironmentProfile, env envdetect.Environment) { + if len(profile.Phases) == 0 { + return + } + + var keep []envdetect.SubnetPhase + for _, phase := range profile.Phases { + name := strings.ToLower(phase.Name) + switch env { + case envdetect.Native, envdetect.DockerHost, envdetect.LXCPrivileged: + if strings.Contains(name, "local") || strings.Contains(name, "host") { + keep = append(keep, phase) + } + case envdetect.DockerBridge: + if strings.Contains(name, "container") || strings.Contains(name, "inferred") || strings.Contains(name, "host") { + keep = append(keep, phase) + } + case envdetect.LXCUnprivileged: + if strings.Contains(name, "lxc") || strings.Contains(name, "container") || strings.Contains(name, "parent") { + keep = append(keep, phase) + } + default: + keep = append(keep, phase) + } + } + + if len(keep) > 0 { + profile.Phases = keep + } +} diff --git a/internal/discovery/service.go b/internal/discovery/service.go index ca40622..c2a18a3 100644 --- a/internal/discovery/service.go +++ b/internal/discovery/service.go @@ -6,14 +6,15 @@ import ( "sync" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/websocket" - "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" + pkgdiscovery "github.com/rcourtman/pulse-go-rewrite/pkg/discovery" "github.com/rs/zerolog/log" ) // Service handles background network discovery type Service struct { - scanner *discovery.Scanner + scanner *pkgdiscovery.Scanner wsHub *websocket.Hub cache *DiscoveryCache interval time.Duration @@ -23,17 +24,18 @@ type Service struct { isScanning bool stopChan chan struct{} ctx context.Context + cfgProvider func() config.DiscoveryConfig } // DiscoveryCache stores the latest discovery results type DiscoveryCache struct { mu sync.RWMutex - result *discovery.DiscoveryResult + result *pkgdiscovery.DiscoveryResult updated time.Time } // NewService creates a new discovery service -func NewService(wsHub *websocket.Hub, interval time.Duration, subnet string) *Service { +func NewService(wsHub *websocket.Hub, interval time.Duration, subnet string, cfgProvider func() config.DiscoveryConfig) *Service { if interval == 0 { interval = 5 * time.Minute // Default to 5 minutes } @@ -41,13 +43,18 @@ func NewService(wsHub *websocket.Hub, interval time.Duration, subnet string) *Se subnet = "auto" } + if cfgProvider == nil { + cfgProvider = func() config.DiscoveryConfig { return config.DefaultDiscoveryConfig() } + } + return &Service{ - scanner: discovery.NewScanner(), + scanner: pkgdiscovery.NewScanner(), wsHub: wsHub, cache: &DiscoveryCache{}, interval: interval, subnet: subnet, stopChan: make(chan struct{}), + cfgProvider: cfgProvider, } } @@ -101,7 +108,7 @@ func (s *Service) performScan() { s.isScanning = true s.mu.Unlock() - var result *discovery.DiscoveryResult + var result *pkgdiscovery.DiscoveryResult defer func() { s.mu.Lock() @@ -144,14 +151,22 @@ func (s *Service) performScan() { scanCtx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) defer cancel() - // Refresh scanner to ensure environment detection stays current - newScanner := discovery.NewScanner() + cfg := config.DefaultDiscoveryConfig() + if s.cfgProvider != nil { + cfg = config.CloneDiscoveryConfig(s.cfgProvider()) + } + + newScanner, err := BuildScanner(cfg) + if err != nil { + log.Warn().Err(err).Msg("Environment detection failed during discovery; falling back to default scanner configuration") + newScanner = pkgdiscovery.NewScanner() + } s.mu.Lock() s.scanner = newScanner s.mu.Unlock() // Perform the scan with real-time callback - result, err = newScanner.DiscoverServersWithCallback(scanCtx, s.subnet, func(server discovery.DiscoveredServer, phase string) { + result, err = newScanner.DiscoverServersWithCallback(scanCtx, s.subnet, func(server pkgdiscovery.DiscoveredServer, phase string) { // Send immediate update for each discovered server if s.wsHub != nil { s.wsHub.Broadcast(websocket.Message{ @@ -221,15 +236,15 @@ func (s *Service) performScan() { } // GetCachedResult returns the cached discovery result -func (s *Service) GetCachedResult() (*discovery.DiscoveryResult, time.Time) { +func (s *Service) GetCachedResult() (*pkgdiscovery.DiscoveryResult, time.Time) { s.cache.mu.RLock() defer s.cache.mu.RUnlock() - if s.cache.result == nil { - return &discovery.DiscoveryResult{ - Servers: []discovery.DiscoveredServer{}, - Errors: []string{}, - }, time.Time{} + if s.cache.result == nil { + return &pkgdiscovery.DiscoveryResult{ + Servers: []pkgdiscovery.DiscoveredServer{}, + Errors: []string{}, + }, time.Time{} } return s.cache.result, s.cache.updated diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 61b19ac..619c7ae 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -1915,7 +1915,15 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) { if discoverySubnet == "" { discoverySubnet = "auto" } - m.discoveryService = discovery.NewService(wsHub, 5*time.Minute, discoverySubnet) + cfgProvider := func() config.DiscoveryConfig { + m.mu.RLock() + defer m.mu.RUnlock() + if m.config == nil { + return config.DefaultDiscoveryConfig() + } + return config.CloneDiscoveryConfig(m.config.Discovery) + } + m.discoveryService = discovery.NewService(wsHub, 5*time.Minute, discoverySubnet, cfgProvider) if m.discoveryService != nil { m.discoveryService.Start(ctx) log.Info().Msg("Discovery service initialized and started") @@ -5444,7 +5452,16 @@ func (m *Monitor) StartDiscoveryService(ctx context.Context, wsHub *websocket.Hu subnet = "auto" } - m.discoveryService = discovery.NewService(wsHub, 5*time.Minute, subnet) + cfgProvider := func() config.DiscoveryConfig { + m.mu.RLock() + defer m.mu.RUnlock() + if m.config == nil { + return config.DefaultDiscoveryConfig() + } + return config.CloneDiscoveryConfig(m.config.Discovery) + } + + m.discoveryService = discovery.NewService(wsHub, 5*time.Minute, subnet, cfgProvider) if m.discoveryService != nil { m.discoveryService.Start(ctx) log.Info().Str("subnet", subnet).Msg("Discovery service started") diff --git a/pkg/discovery/discovery.go b/pkg/discovery/discovery.go index 2589e7e..c2d7b87 100644 --- a/pkg/discovery/discovery.go +++ b/pkg/discovery/discovery.go @@ -423,10 +423,6 @@ func buildEnvironmentInfo(profile *envdetect.EnvironmentProfile) *EnvironmentInf func ensurePolicyDefaults(policy envdetect.ScanPolicy) envdetect.ScanPolicy { defaults := envdetect.DefaultScanPolicy() - if policy == (envdetect.ScanPolicy{}) { - return defaults - } - if policy.MaxConcurrent <= 0 { policy.MaxConcurrent = defaults.MaxConcurrent }