This commit is contained in:
Raj Singh 2026-01-15 19:11:07 -05:00
parent 8246e311fd
commit d90a91ea82
3 changed files with 161 additions and 8 deletions

View File

@ -18,6 +18,7 @@ import (
"time"
"github.com/fsnotify/fsnotify"
"github.com/miekg/dns"
"tailscale.com/kube/ingressservices"
"tailscale.com/kube/kubeclient"
"tailscale.com/util/linuxfw"
@ -308,29 +309,124 @@ func ensureIngressRulesAdded(cfgs map[string]ingressservices.Config, nfr linuxfw
return nil
}
// dnsResult holds the result of a DNS lookup including TTL information.
type dnsResult struct {
IPs []net.IP
TTL uint32 // minimum TTL from all returned records, 0 if unknown
}
// lookupIPWithTTL resolves a hostname and returns the IP addresses along with the
// minimum TTL from the DNS response. It tries to use the system resolver via miekg/dns
// to get TTL information. If that fails, it falls back to net.LookupIP without TTL.
func lookupIPWithTTL(hostname string) (dnsResult, error) {
// Try to get TTL using miekg/dns by querying the system resolver
result, err := lookupWithMiekgDNS(hostname)
if err == nil && len(result.IPs) > 0 {
return result, nil
}
// Fallback to standard library (no TTL information available)
ips, err := net.LookupIP(hostname)
if err != nil {
return dnsResult{}, err
}
return dnsResult{IPs: ips, TTL: 0}, nil
}
// lookupWithMiekgDNS uses miekg/dns to query the system resolver for A and AAAA records.
// This allows us to get TTL information from the DNS response.
func lookupWithMiekgDNS(hostname string) (dnsResult, error) {
// Ensure hostname is FQDN
if !dns.IsFqdn(hostname) {
hostname = dns.Fqdn(hostname)
}
// Get system resolver address
config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil {
return dnsResult{}, fmt.Errorf("failed to read resolv.conf: %w", err)
}
if len(config.Servers) == 0 {
return dnsResult{}, fmt.Errorf("no DNS servers in resolv.conf")
}
client := &dns.Client{Timeout: 5 * time.Second}
server := net.JoinHostPort(config.Servers[0], config.Port)
var ips []net.IP
var minTTL uint32 = 0
var firstErr error
// Query for A records (IPv4)
msgA := &dns.Msg{}
msgA.SetQuestion(hostname, dns.TypeA)
respA, _, err := client.Exchange(msgA, server)
if err != nil {
firstErr = err
} else if respA != nil && respA.Rcode == dns.RcodeSuccess {
for _, ans := range respA.Answer {
if a, ok := ans.(*dns.A); ok {
ips = append(ips, a.A)
ttl := ans.Header().Ttl
if minTTL == 0 || ttl < minTTL {
minTTL = ttl
}
}
}
}
// Query for AAAA records (IPv6)
msgAAAA := &dns.Msg{}
msgAAAA.SetQuestion(hostname, dns.TypeAAAA)
respAAAA, _, err := client.Exchange(msgAAAA, server)
if err != nil && firstErr == nil {
firstErr = err
} else if respAAAA != nil && respAAAA.Rcode == dns.RcodeSuccess {
for _, ans := range respAAAA.Answer {
if aaaa, ok := ans.(*dns.AAAA); ok {
ips = append(ips, aaaa.AAAA)
ttl := ans.Header().Ttl
if minTTL == 0 || ttl < minTTL {
minTTL = ttl
}
}
}
}
if len(ips) == 0 {
if firstErr != nil {
return dnsResult{}, firstErr
}
return dnsResult{}, fmt.Errorf("no A or AAAA records found for %s", hostname)
}
return dnsResult{IPs: ips, TTL: minTTL}, nil
}
// addDNATRulesForExternalName resolves the ExternalName DNS and creates DNAT rules
// for each resolved IP address. It also stores the resolved IPs and refresh timestamp
// in the config so they can be used for deletion and periodic re-resolution.
// for each resolved IP address. It also stores the resolved IPs, TTL, and refresh
// timestamp in the config so they can be used for deletion and periodic re-resolution.
func addDNATRulesForExternalName(nfr linuxfw.NetfilterRunner, serviceName string, cfg *ingressservices.Config) error {
if cfg == nil {
return fmt.Errorf("config is nil")
}
ips, err := net.LookupIP(cfg.ExternalName)
result, err := lookupIPWithTTL(cfg.ExternalName)
if err != nil {
return fmt.Errorf("error resolving ExternalName %q: %w", cfg.ExternalName, err)
}
if len(ips) == 0 {
if len(result.IPs) == 0 {
return fmt.Errorf("ExternalName %q resolved to no IP addresses", cfg.ExternalName)
}
log.Printf("resolved ExternalName %q to %d IP(s)", cfg.ExternalName, len(ips))
log.Printf("resolved ExternalName %q to %d IP(s), TTL=%ds", cfg.ExternalName, len(result.IPs), result.TTL)
cfg.LastDNSRefresh = time.Now().Unix()
cfg.DNSTTL = result.TTL
// Clear any previously resolved IPs and store new ones
cfg.ResolvedIPs = nil
var errs []error
for _, ip := range ips {
for _, ip := range result.IPs {
destIP, ok := netip.AddrFromSlice(ip)
if !ok {
log.Printf("warning: could not parse resolved IP %v for %s", ip, cfg.ExternalName)

View File

@ -68,6 +68,9 @@ type Config struct {
// updated via DNS lookup. Used to determine when to re-resolve DNS for
// ExternalName services.
LastDNSRefresh int64 `json:"LastDNSRefresh,omitempty"`
// DNSTTL is the TTL (in seconds) from the DNS response. Used to determine
// when to re-resolve DNS. If zero, DNSRefreshInterval is used as fallback.
DNSTTL uint32 `json:"DNSTTL,omitempty"`
}
// IsExternalName returns true if this config is for an ExternalName service.
@ -111,7 +114,8 @@ func (c *Config) EqualIgnoringResolved(other *Config) bool {
}
// DNSRefreshNeeded returns true if this ExternalName config needs DNS re-resolution.
// Returns false for non-ExternalName configs.
// Returns false for non-ExternalName configs. Uses the DNS TTL if available,
// capped at DNSRefreshInterval.
func (c *Config) DNSRefreshNeeded(now time.Time) bool {
if c == nil || !c.IsExternalName() {
return false
@ -119,8 +123,15 @@ func (c *Config) DNSRefreshNeeded(now time.Time) bool {
if c.LastDNSRefresh == 0 {
return true
}
interval := DNSRefreshInterval
if c.DNSTTL > 0 {
ttl := time.Duration(c.DNSTTL) * time.Second
if ttl < interval {
interval = ttl
}
}
lastRefresh := time.Unix(c.LastDNSRefresh, 0)
return now.Sub(lastRefresh) >= DNSRefreshInterval
return now.Sub(lastRefresh) >= interval
}
// Mapping describes a rule that forwards traffic from Tailscale Service IP to a

View File

@ -283,6 +283,52 @@ func TestConfigDNSRefreshNeeded(t *testing.T) {
},
expected: false,
},
// TTL-aware refresh tests
{
name: "TTL shorter than default interval - needs refresh",
cfg: &Config{
ExternalName: "example.com",
LastDNSRefresh: now.Add(-60 * time.Second).Unix(),
DNSTTL: 30, // 30 seconds TTL
},
expected: true, // 60s elapsed > 30s TTL
},
{
name: "TTL shorter than default interval - no refresh needed",
cfg: &Config{
ExternalName: "example.com",
LastDNSRefresh: now.Add(-20 * time.Second).Unix(),
DNSTTL: 30, // 30 seconds TTL
},
expected: false, // 20s elapsed < 30s TTL
},
{
name: "TTL longer than max interval - uses max",
cfg: &Config{
ExternalName: "example.com",
LastDNSRefresh: now.Add(-11 * time.Minute).Unix(),
DNSTTL: 3600, // 1 hour TTL (longer than 10 min max)
},
expected: true, // 11 min elapsed > 10 min max
},
{
name: "TTL longer than max interval - no refresh needed",
cfg: &Config{
ExternalName: "example.com",
LastDNSRefresh: now.Add(-5 * time.Minute).Unix(),
DNSTTL: 3600, // 1 hour TTL (longer than 10 min max)
},
expected: false, // 5 min elapsed < 10 min max (TTL capped)
},
{
name: "zero TTL uses default interval",
cfg: &Config{
ExternalName: "example.com",
LastDNSRefresh: now.Add(-5 * time.Minute).Unix(),
DNSTTL: 0, // zero TTL, should use default
},
expected: false, // 5 min < 10 min default
},
}
for _, tt := range tests {