// Package netresolve provides bounded DNS lookups for the public DynDNS services. package netresolve import ( "context" "errors" "fmt" "net" "net/netip" "strings" "time" "github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics" ) type lookup interface { LookupNetIP(context.Context, string, string) ([]netip.Addr, error) } type Resolver struct { system lookup fallbacks []lookup timeout time.Duration } func New() *Resolver { r := &Resolver{system: net.DefaultResolver, timeout: 2 * time.Second} // Literal addresses keep this recovery path independent of Docker/Pi-hole // DNS. Both providers can also be reached from IPv6-only networks. for _, server := range []string{"1.1.1.1", "9.9.9.9", "2606:4700:4700::1111", "2620:fe::fe"} { r.fallbacks = append(r.fallbacks, &directResolver{server: server, resolver: &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(server, "53")) }, }}) } return r } type directResolver struct { server string resolver *net.Resolver } func (r *directResolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { started := time.Now() ips, err := r.resolver.LookupNetIP(ctx, network, host) // A canceled losing query is normal when another resolver already succeeded. if !errors.Is(ctx.Err(), context.Canceled) { diagnostics.Record(diagnostics.Event{Code: "dns_fallback_query", Target: diagnostics.Target(host), Family: network, DNSResolver: r.server, Failure: diagnostics.Failure(err), DurationMS: time.Since(started).Milliseconds()}) } return ips, err } func publicService(host string) bool { switch strings.ToLower(strings.TrimSuffix(host, ".")) { case "api.ipify.org", "api6.ipify.org", "ifconfig.co", "api.hetzner.cloud": return true } return false } func (r *Resolver) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) { lookupCtx, cancel := context.WithTimeout(ctx, r.timeout) addresses, err := r.system.LookupNetIP(lookupCtx, network, host) cancel() if err == nil || ctx.Err() != nil || !publicService(host) || !temporaryDNSFailure(err) { return addresses, err } diagnostics.Record(diagnostics.Event{Code: "dns_fallback", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) // Do not cache failures or prefer a previous fallback permanently. Every // lookup checks the configured resolver again after connectivity returns. fallbackCtx, stop := context.WithTimeout(ctx, r.timeout) defer stop() type result struct { addresses []netip.Addr err error } results := make(chan result, len(r.fallbacks)) for _, fallback := range r.fallbacks { go func() { // An absolute name avoids sending local DNS search suffixes outside. addresses, err := fallback.LookupNetIP(fallbackCtx, network, strings.TrimSuffix(host, ".")+".") results <- result{addresses, err} }() } for range r.fallbacks { select { case result := <-results: if result.err == nil && len(result.addresses) > 0 { diagnostics.Record(diagnostics.Event{Code: "dns_fallback_ok", Target: diagnostics.Target(host), Family: network}) return result.addresses, nil } case <-fallbackCtx.Done(): if ctx.Err() != nil { return nil, ctx.Err() } diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback timed out", err) } } diagnostics.Record(diagnostics.Event{Code: "dns_failed", Target: diagnostics.Target(host), Family: network, Failure: diagnostics.Failure(err)}) return nil, fmt.Errorf("system DNS failed (%w); public DNS fallback unavailable", err) } func temporaryDNSFailure(err error) bool { var dnsErr *net.DNSError if errors.As(err, &dnsErr) { return !dnsErr.IsNotFound && (dnsErr.IsTimeout || dnsErr.IsTemporary) } return errors.Is(err, context.DeadlineExceeded) } // DialContext preserves ordinary resolution for custom API URLs and local hosts. func (r *Resolver) DialContext(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil || !publicService(host) { return (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext(ctx, network, address) } family := "ip" if strings.HasSuffix(network, "4") { family = "ip4" } else if strings.HasSuffix(network, "6") { family = "ip6" } addresses, err := r.LookupNetIP(ctx, family, host) if err != nil { return nil, err } return DialAddresses(ctx, network, addresses, port) } // DialAddresses connects only to the already resolved addresses. Sharing the // connect budget allows later addresses a chance even if the first one stalls. func DialAddresses(ctx context.Context, network string, addresses []netip.Addr, port string) (net.Conn, error) { ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() deadline, _ := ctx.Deadline() var lastErr error for index, address := range addresses { if err := ctx.Err(); err != nil { return nil, err } attemptCtx, stop := context.WithTimeout(ctx, time.Until(deadline)/time.Duration(len(addresses)-index)) conn, err := (&net.Dialer{KeepAlive: 30 * time.Second}).DialContext(attemptCtx, network, net.JoinHostPort(address.String(), port)) stop() if err == nil { return conn, nil } lastErr = err } if lastErr == nil { lastErr = errors.New("host has no addresses") } return nil, lastErr }