Files
pulse/pkg/tlsutil/dnscache.go
T
courtmanr@gmail.com b45bd66b94 Route discovery policy DNS through the cached resolver (#1638)
The discovery-policy check resolved endpoint hostnames with a bare
net.LookupIP while the actual dials went through pkg/tlsutil's process-global
cached resolver, so the policy and the connection reasoned about two different
DNS views. That split is why 108aa4e20 had to skip resolution entirely for the
default policy, which left the injected 169.254.0.0/16 blocklist enforced only
against literal IPs: a hostname endpoint pointed at the metadata range walked
straight through.

Resolve through tlsutil.LookupHostCached instead. The shared resolver caches
answers and lookup failures alike until its next refresh, so repeat poll cycles
cost a cache hit rather than a query and the per-poll DNS volume that opened
#1638 stays gone. With that in place the default-policy skip is removed and the
blocklist applies to resolved addresses again, and the five-minute decision
cache is dropped rather than kept: it bought nothing on top of the resolver
cache, made the verdict trail the configuration, and memoized the fail-open
"resolution failed, allow" outcome for minutes even with an explicit allowlist
configured. Its claim to match a DNS refresh interval that operators configure
through DNS_CACHE_TIMEOUT goes with it.

The SSH backoffs now only escalate for work that ran. A knownhosts manager
suppressing a call inside its own window reports ErrKeyscanSuppressed, and the
temperature layer neither records a failure nor pays for the RPi fallback in
that case. An expired collection deadline is our own budget rather than
evidence about the host, so it holds the window at the floor. Both backoffs
decay once a retry deadline is more than one window past, and replacing the
temperature SSH key on disk clears both maps so a repaired key is tried on the
next cycle instead of after fifteen minutes.

Refs discussion #1638.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 12:08:10 +01:00

163 lines
4.2 KiB
Go

package tlsutil
import (
"context"
"net"
"runtime"
"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")
}
// LookupHostCached resolves host through the same process-global cached
// resolver that DialContextWithCache uses, returning parsed addresses.
//
// Callers that reason about which addresses a connection will actually reach
// (endpoint policy checks, for example) must go through this rather than
// net.LookupIP, so the decision and the dial share one DNS view. The resolver
// caches both answers and lookup errors until the next refresh tick, so repeat
// callers cost a map lookup rather than a query (#1638).
func LookupHostCached(ctx context.Context, host string) ([]net.IP, error) {
if ctx == nil {
ctx = context.Background()
}
addrs, err := GetDNSResolver().LookupHost(ctx, host)
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, addr := range addrs {
if ip := net.ParseIP(addr); ip != nil {
ips = append(ips, ip)
}
}
if len(ips) == 0 {
return nil, &net.DNSError{
Err: "no IP addresses found",
Name: host,
}
}
return ips, nil
}
// DialContextWithCache is a DialContext function that uses the DNS cache.
// On macOS, connections to RFC 1918 addresses are routed through a subprocess
// (nc) to bypass VPN/NECP routing captures that affect the host process.
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,
}
}
resolvedIP := net.ParseIP(ips[0])
resolvedAddr := net.JoinHostPort(ips[0], port)
// On macOS, route local-network connections through a subprocess to bypass
// Tailscale NECP routing captures that prevent the host process from
// reaching RFC 1918 addresses directly.
if runtime.GOOS == "darwin" && isPrivateNonVPN(resolvedIP) {
return dialViaSubprocess(ctx, network, resolvedAddr)
}
// 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, resolvedAddr)
}
// isPrivateNonVPN returns true for RFC 1918 private IPv4 addresses, excluding
// the Tailscale CGNAT range 100.64.0.0/10 so those connections still route
// through the Tailscale tunnel.
func isPrivateNonVPN(ip net.IP) bool {
ip4 := ip.To4()
if ip4 == nil {
return false
}
if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
return false
}
return ip4[0] == 10 ||
(ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) ||
(ip4[0] == 192 && ip4[1] == 168)
}