fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
81def77e3c
37 files changed
+2415
-250
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package netresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type lookupFunc func(context.Context, string, string) ([]netip.Addr, error)
|
||||
|
||||
func (f lookupFunc) LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error) {
|
||||
return f(ctx, network, host)
|
||||
}
|
||||
|
||||
func TestDNSOutageFallbackAndReturnToSystem(t *testing.T) {
|
||||
t.Parallel()
|
||||
var healthy atomic.Bool
|
||||
var fallbacks atomic.Int32
|
||||
r := &Resolver{timeout: time.Second}
|
||||
r.system = lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
if network != "ip4" || host != "api.ipify.org" {
|
||||
t.Errorf("unexpected query %s %s", network, host)
|
||||
}
|
||||
if healthy.Load() {
|
||||
return []netip.Addr{netip.MustParseAddr("1.0.0.1")}, nil
|
||||
}
|
||||
return nil, &net.DNSError{Err: "server misbehaving", IsTemporary: true}
|
||||
})
|
||||
r.fallbacks = []lookup{lookupFunc(func(_ context.Context, network, host string) ([]netip.Addr, error) {
|
||||
fallbacks.Add(1)
|
||||
if network != "ip4" || host != "api.ipify.org." {
|
||||
t.Errorf("fallback must use absolute family-specific query: %s %s", network, host)
|
||||
}
|
||||
return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil
|
||||
})}
|
||||
for _, recovered := range []bool{false, true} {
|
||||
healthy.Store(recovered)
|
||||
ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org")
|
||||
want := "1.1.1.1"
|
||||
if recovered {
|
||||
want = "1.0.0.1"
|
||||
}
|
||||
if err != nil || len(ips) != 1 || ips[0].String() != want {
|
||||
t.Fatalf("DNS recovery: %v %v", ips, err)
|
||||
}
|
||||
}
|
||||
if fallbacks.Load() != 1 {
|
||||
t.Fatal("system recovery did not stop fallback use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlowDNSCannotBlockWorkingFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
slow := lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { <-ctx.Done(); return nil, ctx.Err() })
|
||||
r := &Resolver{system: slow, timeout: 20 * time.Millisecond, fallbacks: []lookup{slow, lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil
|
||||
})}}
|
||||
started := time.Now()
|
||||
ips, err := r.LookupNetIP(context.Background(), "ip4", "api.ipify.org")
|
||||
if err != nil || len(ips) != 1 || time.Since(started) > time.Second {
|
||||
t.Fatalf("blocked fallback: %v %v", ips, err)
|
||||
}
|
||||
r.fallbacks = []lookup{slow}
|
||||
if _, err = r.LookupNetIP(context.Background(), "ip4", "api.ipify.org"); err == nil {
|
||||
t.Fatal("total outage was hidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSFallbackDoesNotOverrideNegativeAnswersOrLocalNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
host string
|
||||
err error
|
||||
}{
|
||||
{"api.ipify.org", &net.DNSError{IsNotFound: true}},
|
||||
{"api.ipify.org", errors.New("permanent DNS error")},
|
||||
{"pi.hole", &net.DNSError{IsTemporary: true}},
|
||||
{"private.example", &net.DNSError{IsTemporary: true}},
|
||||
} {
|
||||
r := &Resolver{timeout: time.Second, system: lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) { return nil, test.err }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
t.Error("unexpected external query")
|
||||
return nil, nil
|
||||
})}}
|
||||
if _, err := r.LookupNetIP(context.Background(), "ip4", test.host); !errors.Is(err, test.err) {
|
||||
t.Fatalf("original error lost: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledDNSDoesNotStartFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
r := &Resolver{timeout: time.Second, system: lookupFunc(func(ctx context.Context, _, _ string) ([]netip.Addr, error) { return nil, ctx.Err() }), fallbacks: []lookup{lookupFunc(func(context.Context, string, string) ([]netip.Addr, error) {
|
||||
t.Error("fallback after cancellation")
|
||||
return nil, nil
|
||||
})}}
|
||||
if _, err := r.LookupNetIP(ctx, "ip6", "api6.ipify.org"); !errors.Is(err, context.Canceled) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialAddressesTriesNextAddress(t *testing.T) {
|
||||
t.Parallel()
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer listener.Close()
|
||||
_, port, _ := net.SplitHostPort(listener.Addr().String())
|
||||
conn, err := DialAddresses(context.Background(), "tcp4", []netip.Addr{netip.MustParseAddr("127.0.0.2"), netip.MustParseAddr("127.0.0.1")}, port)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
Reference in new issue
Block a user