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/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/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
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

View file

@ -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") != ""

View file

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

View file

@ -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),

View file

@ -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()

View file

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

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"
"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

View file

@ -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")

View file

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