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.
This commit is contained in:
rcourtman 2025-10-20 17:41:19 +00:00
parent c91b7874ac
commit 5ebb32ce10
10 changed files with 677 additions and 101 deletions

View file

@ -28,13 +28,16 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/tempproxy" "github.com/rcourtman/pulse-go-rewrite/internal/tempproxy"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket" "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/pbs"
"github.com/rcourtman/pulse-go-rewrite/pkg/pmg" "github.com/rcourtman/pulse-go-rewrite/pkg/pmg"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const minProxyReadyVersion = "4.24.0"
// SetupCode represents a one-time setup code for secure node registration // SetupCode represents a one-time setup code for secure node registration
type SetupCode struct { type SetupCode struct {
ExpiresAt time.Time ExpiresAt time.Time
@ -2455,29 +2458,28 @@ func (h *ConfigHandlers) HandleGetSystemSettings(w http.ResponseWriter, r *http.
persistedSettings, err := h.persistence.LoadSystemSettings() persistedSettings, err := h.persistence.LoadSystemSettings()
if err != nil { if err != nil {
log.Warn().Err(err).Msg("Failed to load persisted system settings") 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 // Get current values from running config
settings := config.SystemSettings{ settings := *persistedSettings
// Note: PVE polling is hardcoded to 10s settings.PBSPollingInterval = int(h.config.PBSPollingInterval.Seconds())
PBSPollingInterval: int(h.config.PBSPollingInterval.Seconds()), settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
BackupPollingInterval: int(h.config.BackupPollingInterval.Seconds()), settings.BackendPort = h.config.BackendPort
BackendPort: h.config.BackendPort, settings.FrontendPort = h.config.FrontendPort
FrontendPort: h.config.FrontendPort, settings.AllowedOrigins = h.config.AllowedOrigins
AllowedOrigins: h.config.AllowedOrigins, settings.ConnectionTimeout = int(h.config.ConnectionTimeout.Seconds())
ConnectionTimeout: int(h.config.ConnectionTimeout.Seconds()), settings.UpdateChannel = h.config.UpdateChannel
UpdateChannel: h.config.UpdateChannel, settings.AutoUpdateEnabled = h.config.AutoUpdateEnabled
AutoUpdateEnabled: h.config.AutoUpdateEnabled, settings.AutoUpdateCheckInterval = int(h.config.AutoUpdateCheckInterval.Hours())
AutoUpdateCheckInterval: int(h.config.AutoUpdateCheckInterval.Hours()), settings.AutoUpdateTime = h.config.AutoUpdateTime
AutoUpdateTime: h.config.AutoUpdateTime, settings.LogLevel = h.config.LogLevel
LogLevel: h.config.LogLevel, // Include log level settings.DiscoveryEnabled = h.config.DiscoveryEnabled
Theme: persistedSettings.Theme, // Include theme from persisted settings settings.DiscoverySubnet = h.config.DiscoverySubnet
AllowEmbedding: persistedSettings.AllowEmbedding, // Include allowEmbedding from persisted settings settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery)
AllowedEmbedOrigins: persistedSettings.AllowedEmbedOrigins, // Include allowedEmbedOrigins from persisted settings
DiscoveryEnabled: persistedSettings.DiscoveryEnabled, // Include discoveryEnabled from persisted settings
DiscoverySubnet: persistedSettings.DiscoverySubnet, // Include discoverySubnet from persisted settings
}
backupEnabled := h.config.EnableBackupPolling backupEnabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &backupEnabled 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") 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) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel() 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_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" 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 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 # Check if temperature proxy is available and override SSH key if it is
PROXY_KEY_URL="%s/api/system/proxy-public-key" PROXY_KEY_URL="%s/api/system/proxy-public-key"
@ -3640,11 +3680,10 @@ if command -v pct >/dev/null 2>&1; then
fi fi
fi fi
# Track whether temperature monitoring can work # Track whether temperature monitoring can work (may be disabled by checks above)
TEMP_MONITORING_AVAILABLE=true
# If Pulse is containerized, try to install proxy automatically # 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 # Try automatic installation - proxy keeps SSH credentials on the host for security
if true; then if true; then
# Download installer script from Pulse server # 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 "✓ Secure proxy architecture enabled"
echo " SSH keys are managed on the host for enhanced security" echo " SSH keys are managed on the host for enhanced security"
echo "" 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 fi
# Configure socket bind mount and restart container automatically if [ "$TEMP_MONITORING_AVAILABLE" = true ] && [ ! -S /run/pulse-sensor-proxy/pulse-sensor-proxy.sock ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " ✗ Proxy socket not found at /run/pulse-sensor-proxy/pulse-sensor-proxy.sock"
echo "Finalizing Setup" echo " Check logs with: journalctl -u pulse-sensor-proxy -n 40"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" TEMP_MONITORING_AVAILABLE=false
echo "" fi
echo "Configuring socket bind mount for container $PULSE_CTID..."
# Configure bind mount for proxy socket if [ "$TEMP_MONITORING_AVAILABLE" = true ]; then
pct set "$PULSE_CTID" -mp0 /run/pulse-sensor-proxy,mp=/mnt/pulse-proxy # Configure socket bind mount and restart container automatically
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
# Check if container is currently running echo "Finalizing Setup"
if pct status "$PULSE_CTID" 2>/dev/null | grep -q "running"; then echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "" echo ""
echo "⚠️ Container $PULSE_CTID is currently running." echo "Configuring socket bind mount for container $PULSE_CTID..."
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..."
# Set up trap to restart container even if script is interrupted # Configure bind mount for proxy socket
trap "echo 'Starting container before exit...'; pct start $PULSE_CTID 2>/dev/null || true" EXIT INT TERM 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/tty 2>/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 # Set up trap to restart container even if script is interrupted
trap - EXIT INT TERM trap "echo 'Starting container before exit...'; pct start $PULSE_CTID 2>/dev/null || true" EXIT INT TERM
echo " ✓ Container started successfully" if pct start "$PULSE_CTID"; then
echo "" 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 fi
else else
echo "" echo ""
@ -4326,7 +4414,7 @@ if [ "$AUTO_REG_SUCCESS" != true ]; then
fi fi
`, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP, `, serverName, time.Now().Format("2006-01-02 15:04:05"), pulseIP,
tokenName, tokenName, tokenName, tokenName, tokenName, tokenName, 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 } else { // PBS
script = fmt.Sprintf(`#!/bin/bash script = fmt.Sprintf(`#!/bin/bash

View file

@ -156,15 +156,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc {
} }
log.Info().Msg("Runtime config updated with new security settings - active immediately") log.Info().Msg("Runtime config updated with new security settings - active immediately")
// Save system settings to system.json // Save system settings to system.json
systemSettings := config.SystemSettings{ systemSettings := config.DefaultSystemSettings()
ConnectionTimeout: 10, // Default systemSettings.ConnectionTimeout = 10 // Default seconds
AutoUpdateEnabled: false, // Default systemSettings.AutoUpdateEnabled = false // Default disabled
} if err := r.persistence.SaveSystemSettings(*systemSettings); err != nil {
if err := r.persistence.SaveSystemSettings(systemSettings); err != nil { log.Error().Err(err).Msg("Failed to save system settings")
log.Error().Err(err).Msg("Failed to save system settings") // Continue anyway - not critical for auth setup
// Continue anyway - not critical for auth setup }
}
// Detect environment // Detect environment
isSystemd := os.Getenv("INVOCATION_ID") != "" isSystemd := os.Getenv("INVOCATION_ID") != ""

View file

@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "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) // Validate connection timeout (min 1 second, max 5 minutes)
if val, ok := rawRequest["connectionTimeout"]; ok { if val, ok := rawRequest["connectionTimeout"]; ok {
if timeout, ok := val.(float64); ok { if timeout, ok := val.(float64); ok {
@ -204,7 +306,10 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
settings, err := h.persistence.LoadSystemSettings() settings, err := h.persistence.LoadSystemSettings()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to load system settings") 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 // Log loaded settings for debugging
@ -217,6 +322,7 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds()) settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
enabled := h.config.EnableBackupPolling enabled := h.config.EnableBackupPolling
settings.BackupPollingEnabled = &enabled settings.BackupPollingEnabled = &enabled
settings.DiscoveryConfig = config.CloneDiscoveryConfig(h.config.Discovery)
} }
// Include env override information // Include env override information
@ -268,10 +374,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
existingSettings, err := h.persistence.LoadSystemSettings() existingSettings, err := h.persistence.LoadSystemSettings()
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to load existing settings") log.Error().Err(err).Msg("Failed to load existing settings")
existingSettings = &config.SystemSettings{} existingSettings = config.DefaultSystemSettings()
} }
if existingSettings == nil { if existingSettings == nil {
existingSettings = &config.SystemSettings{} existingSettings = config.DefaultSystemSettings()
} }
// Read the request body into a map to check which fields were provided // 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 // Start with existing settings
settings := *existingSettings settings := *existingSettings
discoveryConfigUpdated := false
// Only update fields that were provided in the request // Only update fields that were provided in the request
// Note: PVE polling is hardcoded to 10s, legacy polling fields are ignored // 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 != "" { if updates.DiscoverySubnet != "" {
settings.DiscoverySubnet = 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 // Allow clearing of AllowedEmbedOrigins by setting to empty string
if _, ok := rawRequest["allowedEmbedOrigins"]; ok { if _, ok := rawRequest["allowedEmbedOrigins"]; ok {
settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins
@ -401,6 +512,7 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if settings.DiscoverySubnet != "" { if settings.DiscoverySubnet != "" {
h.config.DiscoverySubnet = settings.DiscoverySubnet h.config.DiscoverySubnet = settings.DiscoverySubnet
} }
h.config.Discovery = config.CloneDiscoveryConfig(settings.DiscoveryConfig)
// Start or stop discovery service based on setting change // Start or stop discovery service based on setting change
if h.monitor != nil { if h.monitor != nil {
@ -422,6 +534,12 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
svc.SetSubnet(settings.DiscoverySubnet) 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 // Save to persistence

View file

@ -130,8 +130,9 @@ type Config struct {
AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"` AutoUpdateTime string `envconfig:"AUTO_UPDATE_TIME" default:"03:00"`
// Discovery settings // Discovery settings
DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"` DiscoveryEnabled bool `envconfig:"DISCOVERY_ENABLED" default:"true"`
DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"` DiscoverySubnet string `envconfig:"DISCOVERY_SUBNET" default:"auto"`
Discovery DiscoveryConfig `json:"discoveryConfig"`
// Deprecated - for backward compatibility // Deprecated - for backward compatibility
Port int `envconfig:"PORT"` // Maps to BackendPort Port int `envconfig:"PORT"` // Maps to BackendPort
@ -141,6 +142,71 @@ type Config struct {
EnvOverrides map[string]bool `json:"-"` 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 // PVEInstance represents a Proxmox VE connection
type PVEInstance struct { type PVEInstance struct {
Name string Name string
@ -284,9 +350,11 @@ func Load() (*Config, error) {
DiscoveryEnabled: true, DiscoveryEnabled: true,
DiscoverySubnet: "auto", DiscoverySubnet: "auto",
EnvOverrides: make(map[string]bool), EnvOverrides: make(map[string]bool),
OIDC: NewOIDCConfig(), OIDC: NewOIDCConfig(),
} }
cfg.Discovery = DefaultDiscoveryConfig()
// Initialize persistence // Initialize persistence
persistence := NewConfigPersistence(dataDir) persistence := NewConfigPersistence(dataDir)
if persistence != nil { if persistence != nil {
@ -361,6 +429,7 @@ func Load() (*Config, error) {
if systemSettings.DiscoverySubnet != "" { if systemSettings.DiscoverySubnet != "" {
cfg.DiscoverySubnet = systemSettings.DiscoverySubnet cfg.DiscoverySubnet = systemSettings.DiscoverySubnet
} }
cfg.Discovery = CloneDiscoveryConfig(systemSettings.DiscoveryConfig)
// APIToken no longer loaded from system.json - only from .env // APIToken no longer loaded from system.json - only from .env
log.Info(). log.Info().
Str("updateChannel", cfg.UpdateChannel). Str("updateChannel", cfg.UpdateChannel).
@ -369,13 +438,11 @@ func Load() (*Config, error) {
} else { } else {
// No system.json exists - create default one // No system.json exists - create default one
log.Info().Msg("No system.json found, creating default") log.Info().Msg("No system.json found, creating default")
defaultSettings := SystemSettings{ defaultSettings := DefaultSystemSettings()
ConnectionTimeout: int(cfg.ConnectionTimeout.Seconds()), defaultSettings.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")
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 { if oidcSettings, err := persistence.LoadOIDCConfig(); err == nil && oidcSettings != nil {
@ -741,6 +808,83 @@ func Load() (*Config, error) {
cfg.EnvOverrides["discoverySubnet"] = true cfg.EnvOverrides["discoverySubnet"] = true
log.Info().Str("subnet", discoverySubnet).Msg("Discovery subnet overridden by DISCOVERY_SUBNET env var") 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 != "" { if logLevel := os.Getenv("LOG_LEVEL"); logLevel != "" {
cfg.LogLevel = logLevel cfg.LogLevel = logLevel
cfg.EnvOverrides["logLevel"] = true cfg.EnvOverrides["logLevel"] = true
@ -820,6 +964,7 @@ func SaveConfig(cfg *Config) error {
LogLevel: cfg.LogLevel, LogLevel: cfg.LogLevel,
DiscoveryEnabled: cfg.DiscoveryEnabled, DiscoveryEnabled: cfg.DiscoveryEnabled,
DiscoverySubnet: cfg.DiscoverySubnet, DiscoverySubnet: cfg.DiscoverySubnet,
DiscoveryConfig: CloneDiscoveryConfig(cfg.Discovery),
AdaptivePollingEnabled: &adaptiveEnabled, AdaptivePollingEnabled: &adaptiveEnabled,
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second), AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second), AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),

View file

@ -71,7 +71,7 @@ func (c *ConfigPersistence) ExportConfig(passphrase string) (string, error) {
return "", fmt.Errorf("failed to load system settings: %w", err) return "", fmt.Errorf("failed to load system settings: %w", err)
} }
if systemSettings == nil { if systemSettings == nil {
systemSettings = &SystemSettings{} systemSettings = DefaultSystemSettings()
} }
oidcConfig, err := c.LoadOIDCConfig() oidcConfig, err := c.LoadOIDCConfig()

View file

@ -682,12 +682,27 @@ type SystemSettings struct {
LogLevel string `json:"logLevel,omitempty"` LogLevel string `json:"logLevel,omitempty"`
DiscoveryEnabled bool `json:"discoveryEnabled"` DiscoveryEnabled bool `json:"discoveryEnabled"`
DiscoverySubnet string `json:"discoverySubnet,omitempty"` DiscoverySubnet string `json:"discoverySubnet,omitempty"`
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default Theme string `json:"theme,omitempty"` // User theme preference: "light", "dark", or empty for system default
AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding
AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding
// APIToken removed - now handled via .env file only // 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) // SaveNodesConfig saves nodes configuration to file (encrypted)
func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance, pmgInstances []PMGInstance) error { func (c *ConfigPersistence) SaveNodesConfig(pveInstances []PVEInstance, pbsInstances []PBSInstance, pmgInstances []PMGInstance) error {
return c.saveNodesConfig(pveInstances, pbsInstances, pmgInstances, false) return c.saveNodesConfig(pveInstances, pbsInstances, pmgInstances, false)
@ -1154,13 +1169,16 @@ func (c *ConfigPersistence) LoadSystemSettings() (*SystemSettings, error) {
return nil, err return nil, err
} }
var settings SystemSettings settings := DefaultSystemSettings()
if err := json.Unmarshal(data, &settings); err != nil { if settings == nil {
settings = &SystemSettings{}
}
if err := json.Unmarshal(data, settings); err != nil {
return nil, err return nil, err
} }
log.Info().Str("file", c.systemFile).Msg("System settings loaded") 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 // updateEnvFile updates the .env file with new system settings

View file

@ -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
}
}

View file

@ -6,14 +6,15 @@ import (
"sync" "sync"
"time" "time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket" "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" "github.com/rs/zerolog/log"
) )
// Service handles background network discovery // Service handles background network discovery
type Service struct { type Service struct {
scanner *discovery.Scanner scanner *pkgdiscovery.Scanner
wsHub *websocket.Hub wsHub *websocket.Hub
cache *DiscoveryCache cache *DiscoveryCache
interval time.Duration interval time.Duration
@ -23,17 +24,18 @@ type Service struct {
isScanning bool isScanning bool
stopChan chan struct{} stopChan chan struct{}
ctx context.Context ctx context.Context
cfgProvider func() config.DiscoveryConfig
} }
// DiscoveryCache stores the latest discovery results // DiscoveryCache stores the latest discovery results
type DiscoveryCache struct { type DiscoveryCache struct {
mu sync.RWMutex mu sync.RWMutex
result *discovery.DiscoveryResult result *pkgdiscovery.DiscoveryResult
updated time.Time updated time.Time
} }
// NewService creates a new discovery service // 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 { if interval == 0 {
interval = 5 * time.Minute // Default to 5 minutes 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" subnet = "auto"
} }
if cfgProvider == nil {
cfgProvider = func() config.DiscoveryConfig { return config.DefaultDiscoveryConfig() }
}
return &Service{ return &Service{
scanner: discovery.NewScanner(), scanner: pkgdiscovery.NewScanner(),
wsHub: wsHub, wsHub: wsHub,
cache: &DiscoveryCache{}, cache: &DiscoveryCache{},
interval: interval, interval: interval,
subnet: subnet, subnet: subnet,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
cfgProvider: cfgProvider,
} }
} }
@ -101,7 +108,7 @@ func (s *Service) performScan() {
s.isScanning = true s.isScanning = true
s.mu.Unlock() s.mu.Unlock()
var result *discovery.DiscoveryResult var result *pkgdiscovery.DiscoveryResult
defer func() { defer func() {
s.mu.Lock() s.mu.Lock()
@ -144,14 +151,22 @@ func (s *Service) performScan() {
scanCtx, cancel := context.WithTimeout(s.ctx, 2*time.Minute) scanCtx, cancel := context.WithTimeout(s.ctx, 2*time.Minute)
defer cancel() defer cancel()
// Refresh scanner to ensure environment detection stays current cfg := config.DefaultDiscoveryConfig()
newScanner := discovery.NewScanner() 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.mu.Lock()
s.scanner = newScanner s.scanner = newScanner
s.mu.Unlock() s.mu.Unlock()
// Perform the scan with real-time callback // 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 // Send immediate update for each discovered server
if s.wsHub != nil { if s.wsHub != nil {
s.wsHub.Broadcast(websocket.Message{ s.wsHub.Broadcast(websocket.Message{
@ -221,15 +236,15 @@ func (s *Service) performScan() {
} }
// GetCachedResult returns the cached discovery result // 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() s.cache.mu.RLock()
defer s.cache.mu.RUnlock() defer s.cache.mu.RUnlock()
if s.cache.result == nil { if s.cache.result == nil {
return &discovery.DiscoveryResult{ return &pkgdiscovery.DiscoveryResult{
Servers: []discovery.DiscoveredServer{}, Servers: []pkgdiscovery.DiscoveredServer{},
Errors: []string{}, Errors: []string{},
}, time.Time{} }, time.Time{}
} }
return s.cache.result, s.cache.updated return s.cache.result, s.cache.updated

View file

@ -1915,7 +1915,15 @@ func (m *Monitor) Start(ctx context.Context, wsHub *websocket.Hub) {
if discoverySubnet == "" { if discoverySubnet == "" {
discoverySubnet = "auto" 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 { if m.discoveryService != nil {
m.discoveryService.Start(ctx) m.discoveryService.Start(ctx)
log.Info().Msg("Discovery service initialized and started") log.Info().Msg("Discovery service initialized and started")
@ -5444,7 +5452,16 @@ func (m *Monitor) StartDiscoveryService(ctx context.Context, wsHub *websocket.Hu
subnet = "auto" 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 { if m.discoveryService != nil {
m.discoveryService.Start(ctx) m.discoveryService.Start(ctx)
log.Info().Str("subnet", subnet).Msg("Discovery service started") log.Info().Str("subnet", subnet).Msg("Discovery service started")

View file

@ -423,10 +423,6 @@ func buildEnvironmentInfo(profile *envdetect.EnvironmentProfile) *EnvironmentInf
func ensurePolicyDefaults(policy envdetect.ScanPolicy) envdetect.ScanPolicy { func ensurePolicyDefaults(policy envdetect.ScanPolicy) envdetect.ScanPolicy {
defaults := envdetect.DefaultScanPolicy() defaults := envdetect.DefaultScanPolicy()
if policy == (envdetect.ScanPolicy{}) {
return defaults
}
if policy.MaxConcurrent <= 0 { if policy.MaxConcurrent <= 0 {
policy.MaxConcurrent = defaults.MaxConcurrent policy.MaxConcurrent = defaults.MaxConcurrent
} }