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:
rcourtman 2025-11-05 18:25:38 +00:00
parent f434a7b9e7
commit c93581e1aa
6 changed files with 149 additions and 17 deletions

2
go.mod
View file

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

5
go.sum
View file

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

View file

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

View file

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

100
pkg/tlsutil/dnscache.go Normal file
View 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))
}

View file

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