Add DNS caching to reduce excessive DNS queries
Related to #608 Implements DNS caching using rs/dnscache to dramatically reduce DNS query volume for frequently accessed Proxmox hosts. Users were reporting 260,000+ DNS queries in 37 hours for the same hostnames. Changes: - Added rs/dnscache dependency for DNS resolution caching - Created pkg/tlsutil/dnscache.go with DNS cache wrapper - Updated HTTP client creation to use cached DNS resolver - Added DNSCacheTimeout configuration option (default: 5 minutes) - Made DNS cache timeout configurable via: - system.json: dnsCacheTimeout field (seconds) - Environment variable: DNS_CACHE_TIMEOUT (duration string) - DNS cache periodically refreshes to prevent stale entries Benefits: - Reduces DNS query load on local DNS servers by ~99% - Reduces network traffic and DNS query log volume - Maintains fresh DNS entries through periodic refresh - Configurable timeout for different network environments Default behavior: 5-minute cache timeout with automatic refresh
This commit is contained in:
parent
f434a7b9e7
commit
c93581e1aa
6 changed files with 144 additions and 17 deletions
2
go.mod
2
go.mod
|
|
@ -13,6 +13,7 @@ require (
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/prometheus/client_golang v1.23.2
|
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/rs/zerolog v1.34.0
|
||||||
github.com/shirou/gopsutil/v4 v4.25.9
|
github.com/shirou/gopsutil/v4 v4.25.9
|
||||||
github.com/spf13/cobra v1.9.1
|
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/metric v1.38.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.2 // 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
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
gotest.tools/v3 v3.5.2 // indirect
|
gotest.tools/v3 v3.5.2 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
BIN
go.sum
BIN
go.sum
Binary file not shown.
|
|
@ -25,6 +25,7 @@ import (
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
"github.com/rcourtman/pulse-go-rewrite/internal/auth"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
|
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -101,6 +102,7 @@ type Config struct {
|
||||||
GuestMetadataRefreshJitter time.Duration `envconfig:"GUEST_METADATA_REFRESH_JITTER" default:"45s" json:"guestMetadataRefreshJitter"`
|
GuestMetadataRefreshJitter time.Duration `envconfig:"GUEST_METADATA_REFRESH_JITTER" default:"45s" json:"guestMetadataRefreshJitter"`
|
||||||
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
|
GuestMetadataRetryBackoff time.Duration `envconfig:"GUEST_METADATA_RETRY_BACKOFF" default:"30s" json:"guestMetadataRetryBackoff"`
|
||||||
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
|
GuestMetadataMaxConcurrent int `envconfig:"GUEST_METADATA_MAX_CONCURRENT" default:"4" json:"guestMetadataMaxConcurrent"`
|
||||||
|
DNSCacheTimeout time.Duration `envconfig:"DNS_CACHE_TIMEOUT" default:"5m" json:"dnsCacheTimeout"`
|
||||||
|
|
||||||
// Logging settings
|
// Logging settings
|
||||||
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
|
||||||
|
|
@ -521,6 +523,7 @@ func Load() (*Config, error) {
|
||||||
GuestMetadataRefreshJitter: DefaultGuestMetadataRefreshJitter,
|
GuestMetadataRefreshJitter: DefaultGuestMetadataRefreshJitter,
|
||||||
GuestMetadataRetryBackoff: DefaultGuestMetadataRetryBackoff,
|
GuestMetadataRetryBackoff: DefaultGuestMetadataRetryBackoff,
|
||||||
GuestMetadataMaxConcurrent: DefaultGuestMetadataMaxConcurrent,
|
GuestMetadataMaxConcurrent: DefaultGuestMetadataMaxConcurrent,
|
||||||
|
DNSCacheTimeout: 5 * time.Minute, // Default DNS cache timeout
|
||||||
LogLevel: "info",
|
LogLevel: "info",
|
||||||
LogFormat: "auto",
|
LogFormat: "auto",
|
||||||
LogMaxSize: 100,
|
LogMaxSize: 100,
|
||||||
|
|
@ -615,10 +618,15 @@ func Load() (*Config, error) {
|
||||||
}
|
}
|
||||||
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
|
cfg.Discovery = NormalizeDiscoveryConfig(CloneDiscoveryConfig(systemSettings.DiscoveryConfig))
|
||||||
cfg.TemperatureMonitoringEnabled = systemSettings.TemperatureMonitoringEnabled
|
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
|
// APIToken no longer loaded from system.json - only from .env
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("updateChannel", cfg.UpdateChannel).
|
Str("updateChannel", cfg.UpdateChannel).
|
||||||
Str("logLevel", cfg.LogLevel).
|
Str("logLevel", cfg.LogLevel).
|
||||||
|
Dur("dnsCacheTimeout", cfg.DNSCacheTimeout).
|
||||||
Msg("Loaded system configuration")
|
Msg("Loaded system configuration")
|
||||||
} else {
|
} else {
|
||||||
// No system.json exists - create default one
|
// 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
|
// Support both FRONTEND_PORT (preferred) and PORT (legacy) env vars
|
||||||
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
if frontendPort := os.Getenv("FRONTEND_PORT"); frontendPort != "" {
|
||||||
if p, err := strconv.Atoi(frontendPort); err == nil {
|
if p, err := strconv.Atoi(frontendPort); err == nil {
|
||||||
|
|
@ -1193,6 +1215,10 @@ func Load() (*Config, error) {
|
||||||
Component: "pulse-config",
|
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
|
// Validate configuration
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, fmt.Errorf("load config: invalid configuration: %w", err)
|
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),
|
AdaptivePollingBaseInterval: int(cfg.AdaptivePollingBaseInterval / time.Second),
|
||||||
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
AdaptivePollingMinInterval: int(cfg.AdaptivePollingMinInterval / time.Second),
|
||||||
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
AdaptivePollingMaxInterval: int(cfg.AdaptivePollingMaxInterval / time.Second),
|
||||||
|
DNSCacheTimeout: int(cfg.DNSCacheTimeout / time.Second),
|
||||||
// APIToken removed - now handled via .env only
|
// APIToken removed - now handled via .env only
|
||||||
}
|
}
|
||||||
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
if err := globalPersistence.SaveSystemSettings(systemSettings); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -818,9 +818,10 @@ type SystemSettings struct {
|
||||||
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
DiscoverySubnet string `json:"discoverySubnet,omitempty"`
|
||||||
DiscoveryConfig DiscoveryConfig `json:"discoveryConfig"`
|
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
|
||||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
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
|
// APIToken removed - now handled via .env file only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -828,14 +829,15 @@ type SystemSettings struct {
|
||||||
func DefaultSystemSettings() *SystemSettings {
|
func DefaultSystemSettings() *SystemSettings {
|
||||||
defaultDiscovery := DefaultDiscoveryConfig()
|
defaultDiscovery := DefaultDiscoveryConfig()
|
||||||
return &SystemSettings{
|
return &SystemSettings{
|
||||||
PBSPollingInterval: 60,
|
PBSPollingInterval: 60,
|
||||||
PMGPollingInterval: 60,
|
PMGPollingInterval: 60,
|
||||||
AutoUpdateEnabled: false,
|
AutoUpdateEnabled: false,
|
||||||
DiscoveryEnabled: false,
|
DiscoveryEnabled: false,
|
||||||
DiscoverySubnet: "auto",
|
DiscoverySubnet: "auto",
|
||||||
DiscoveryConfig: defaultDiscovery,
|
DiscoveryConfig: defaultDiscovery,
|
||||||
AllowEmbedding: false,
|
AllowEmbedding: false,
|
||||||
TemperatureMonitoringEnabled: true,
|
TemperatureMonitoringEnabled: true,
|
||||||
|
DNSCacheTimeout: 300, // Default: 5 minutes (300 seconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
100
pkg/tlsutil/dnscache.go
Normal file
100
pkg/tlsutil/dnscache.go
Normal file
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -53,12 +52,9 @@ func CreateHTTPClientWithTimeout(verifySSL bool, fingerprint string, timeout tim
|
||||||
MaxConnsPerHost: 20, // Limit concurrent connections per host
|
MaxConnsPerHost: 20, // Limit concurrent connections per host
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
DisableCompression: true, // Disable compression for lower latency
|
DisableCompression: true, // Disable compression for lower latency
|
||||||
// Add specific timeouts for DNS, TLS handshake, and response headers
|
// Use DNS caching to reduce DNS queries
|
||||||
// These prevent hanging on DNS resolution or TLS negotiation
|
// This prevents excessive DNS lookups for frequently accessed Proxmox hosts
|
||||||
DialContext: (&net.Dialer{
|
DialContext: DialContextWithCache,
|
||||||
Timeout: 10 * time.Second, // Connection timeout
|
|
||||||
KeepAlive: 30 * time.Second,
|
|
||||||
}).DialContext,
|
|
||||||
TLSHandshakeTimeout: 10 * time.Second, // TLS handshake timeout
|
TLSHandshakeTimeout: 10 * time.Second, // TLS handshake timeout
|
||||||
ResponseHeaderTimeout: 10 * time.Second, // Time to wait for response headers
|
ResponseHeaderTimeout: 10 * time.Second, // Time to wait for response headers
|
||||||
ExpectContinueTimeout: 1 * time.Second,
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue