fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
// 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
}