mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 16:20:03 +00:00
f2fe81bf97
The Tailscale system extension on macOS applies an NECP policy that captures the Pulse process outbound Go TCP connections, routing them through utun4. RFC 1918 addresses (192.168.0.x) fail with EHOSTUNREACH; Tailscale CGNAT addresses (100.x.x.x) work normally. exec.Command subprocesses bypass NECP. Add a subprocessConn type (subprocess_conn.go) that wraps nc stdin/stdout as a net.Conn with real deadline enforcement (closes the connection on timeout). Modify DialContextWithCache (dnscache.go) to route RFC 1918 addresses through the subprocess relay on macOS, while Tailscale IPs and public IPs continue using the normal dialer. Result: pbs-docker and pi Proxmox/PBS nodes now connect at the transport layer. Remaining pi poll timeouts are caused by a pre-existing server-side reverse DNS lookup delay (3s per HTTPS request); fix is NO_RESOLVE=1 in /etc/default/pveproxy on each Proxmox node.
130 lines
3.2 KiB
Go
130 lines
3.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")
|
|
}
|
|
|
|
// 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)
|
|
}
|