diff --git a/go.mod b/go.mod index 89b5e0bf8..9bfda1756 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/oklog/ulid/v2 v2.1.1 github.com/prometheus/client_golang v1.23.2 + github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 github.com/rs/zerolog v1.34.0 github.com/shirou/gopsutil/v4 v4.25.9 github.com/spf13/cobra v1.9.1 @@ -68,6 +69,7 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sync v0.13.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index c06e2b629..0b67a6e5d 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 h1:18kd+8ZUlt/ARXhljq+14TwAoKa61q6dX8jtwOf6DH8= +github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= @@ -162,6 +164,9 @@ golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/internal/config/config.go b/internal/config/config.go index 432fbb4d1..1b8c9a91e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/auth" "github.com/rcourtman/pulse-go-rewrite/internal/logging" "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil" "github.com/rs/zerolog/log" ) @@ -101,6 +102,7 @@ type Config struct { GuestMetadataRefreshJitter time.Duration `envconfig:"GUEST_METADATA_REFRESH_JITTER" default:"45s" json:"guestMetadataRefreshJitter"` GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"` GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"` + DNSCacheTimeout time.Duration `envconfig:"DNS_CACHE_TIMEOUT" default:"5m" json:"dnsCacheTimeout"` // Logging settings LogLevel string `envconfig:"LOG_LEVEL" default:"info"` @@ -521,6 +523,7 @@ func Load() (*Config, error) { GuestMetadataRefreshJitter: DefaultGuestMetadataRefreshJitter, GuestMetadataRetryBackoff: DefaultGuestMetadataRetryBackoff, GuestMetadataMaxConcurrent: DefaultGuestMetadataMaxConcurrent, + DNSCacheTimeout: 5 * time.Minute, // Default DNS cache timeout LogLevel: "info", LogFormat: "auto", LogMaxSize: 100, @@ -615,10 +618,15 @@ func Load() (*Config, error) { } cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig)) cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled + // Load DNS cache timeout + if systemSettings.DNSCacheTimeout > 0 { + cfg.DNSCacheTimeout = time.Duration(systemSettings.DNSCacheTimeout) * time.Second + } // APIToken no longer loaded from system.json - only from .env log.Info(). Str("updateChannel", cfg.UpdateChannel). Str("logLevel", cfg.LogLevel). + Dur("dnsCacheTimeout", cfg.DNSCacheTimeout). Msg("Loaded system configuration") } else { // No system.json exists - create default one @@ -816,6 +824,20 @@ func Load() (*Config, error) { } } + if dnsCacheTimeout := utils.GetenvTrim("DNS_CACHE_TIMEOUT"); dnsCacheTimeout != "" { + if dur, err := time.ParseDuration(dnsCacheTimeout); err == nil { + if dur <= 0 { + log.Warn().Str("value", dnsCacheTimeout).Msg("Ignoring non-positive DNS_CACHE_TIMEOUT from environment") + } else { + cfg.DNSCacheTimeout = dur + cfg.EnvOverrides["DNS_CACHE_TIMEOUT"] = true + log.Info().Dur("timeout", dur).Msg("DNS cache timeout overridden by environment") + } + } else { + log.Warn().Str("value", dnsCacheTimeout).Msg("Invalid DNS_CACHE_TIMEOUT value, expected duration string") + } + } + // Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" { if p, err := strconv.Atoi(frontendPort); err == nil { @@ -1193,6 +1215,10 @@ func Load() (*Config, error) { Component: "pulse-config", }) + // Initialize DNS cache with configured timeout + // This must be done before any HTTP clients are created + tlsutil.SetDNSCacheTTL(cfg.DNSCacheTimeout) + // Validate configuration if err := cfg.Validate(); err != nil { return nil, fmt.Errorf("load config: invalid configuration: %w", err) @@ -1230,6 +1256,7 @@ func SaveConfig(cfg *Config) error { AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second), AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second), AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second), + DNSCacheTimeout: int(cfg.DNSCacheTimeout / time.Second), // APIToken removed - now handled via .env only } if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil { diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 17700ff3c..3b643453f 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -818,9 +818,10 @@ type SystemSettings struct { 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 - TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + AllowEmbedding bool `json:"allowEmbedding"` // Allow iframe embedding + AllowedEmbedOrigins string `json:"allowedEmbedOrigins,omitempty"` // Comma-separated list of allowed origins for embedding + TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"` + DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes) // APIToken removed - now handled via .env file only } @@ -828,14 +829,15 @@ type SystemSettings struct { func DefaultSystemSettings() *SystemSettings { defaultDiscovery := DefaultDiscoveryConfig() return &SystemSettings{ - PBSPollingInterval: 60, - PMGPollingInterval: 60, - AutoUpdateEnabled: false, - DiscoveryEnabled: false, - DiscoverySubnet: "auto", - DiscoveryConfig: defaultDiscovery, - AllowEmbedding: false, + PBSPollingInterval: 60, + PMGPollingInterval: 60, + AutoUpdateEnabled: false, + DiscoveryEnabled: false, + DiscoverySubnet: "auto", + DiscoveryConfig: defaultDiscovery, + AllowEmbedding: false, TemperatureMonitoringEnabled: true, + DNSCacheTimeout: 300, // Default: 5 minutes (300 seconds) } } diff --git a/pkg/tlsutil/dnscache.go b/pkg/tlsutil/dnscache.go new file mode 100644 index 000000000..1715adae5 --- /dev/null +++ b/pkg/tlsutil/dnscache.go @@ -0,0 +1,100 @@ +package tlsutil + +import ( + "context" + "net" + "sync" + "time" + + "github.com/rs/dnscache" + "github.com/rs/zerolog/log" +) + +var ( + // Global DNS resolver with caching + globalResolver *dnscache.Resolver + globalResolverOnce sync.Once + resolverMutex sync.RWMutex + resolverRefreshTTL time.Duration = 5 * time.Minute // Default TTL +) + +// GetDNSResolver returns the global DNS resolver instance with caching +func GetDNSResolver() *dnscache.Resolver { + globalResolverOnce.Do(func() { + initDNSResolver(resolverRefreshTTL) + }) + return globalResolver +} + +// initDNSResolver initializes the DNS resolver with the specified TTL +func initDNSResolver(ttl time.Duration) { + log.Info(). + Dur("ttl", ttl). + Msg("Initializing DNS resolver cache to reduce DNS query load") + + globalResolver = &dnscache.Resolver{} + + // Start a goroutine to periodically refresh the DNS cache + // This prevents stale DNS entries while still providing caching benefits + go func() { + ticker := time.NewTicker(ttl) + defer ticker.Stop() + + for range ticker.C { + globalResolver.Refresh(true) + log.Debug(). + Dur("ttl", ttl). + Msg("DNS cache refreshed") + } + }() +} + +// SetDNSCacheTTL updates the DNS cache TTL +// This function should be called before any HTTP clients are created +func SetDNSCacheTTL(ttl time.Duration) { + resolverMutex.Lock() + defer resolverMutex.Unlock() + + if ttl <= 0 { + ttl = 5 * time.Minute // Default + } + + resolverRefreshTTL = ttl + + log.Info(). + Dur("ttl", ttl). + Msg("DNS cache TTL configured") +} + +// DialContextWithCache is a DialContext function that uses the DNS cache +func DialContextWithCache(ctx context.Context, network, address string) (net.Conn, error) { + resolver := GetDNSResolver() + + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + + // Look up the IP address using the cached resolver + ips, err := resolver.LookupHost(ctx, host) + if err != nil { + return nil, err + } + + // Use the first IP address + if len(ips) == 0 { + return nil, &net.DNSError{ + Err: "no IP addresses found", + Name: host, + } + } + + // Create a dialer with the resolved IP + dialer := &net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + } + + // Dial with the resolved IP address + return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0], port)) +} diff --git a/pkg/tlsutil/fingerprint.go b/pkg/tlsutil/fingerprint.go index 717dfec0f..096d81032 100644 --- a/pkg/tlsutil/fingerprint.go +++ b/pkg/tlsutil/fingerprint.go @@ -6,7 +6,6 @@ import ( "crypto/x509" "encoding/hex" "fmt" - "net" "net/http" "strings" "time" @@ -53,12 +52,9 @@ func CreateHTTPClientWithTimeout(verifySSL bool, fingerprint string, timeout tim MaxConnsPerHost: 20, // Limit concurrent connections per host IdleConnTimeout: 90 * time.Second, DisableCompression: true, // Disable compression for lower latency - // Add specific timeouts for DNS, TLS handshake, and response headers - // These prevent hanging on DNS resolution or TLS negotiation - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, // Connection timeout - KeepAlive: 30 * time.Second, - }).DialContext, + // Use DNS caching to reduce DNS queries + // This prevents excessive DNS lookups for frequently accessed Proxmox hosts + DialContext: DialContextWithCache, TLSHandshakeTimeout: 10 * time.Second, // TLS handshake timeout ResponseHeaderTimeout: 10 * time.Second, // Time to wait for response headers ExpectContinueTimeout: 1 * time.Second,