From 7d3ad8305037d717bd749ff2af4814baf86f4030 Mon Sep 17 00:00:00 2001 From: John Maguire Date: Wed, 21 Oct 2026 17:21:05 -0400 Subject: [PATCH] Dial pinned api-backup addresses when api.defined.net fails to resolve A host whose DNS points into a Nebula network that is down (expired OIDC session, lost network access) cannot resolve api.defined.net, so it can never reach the API to recover. On a DNS error the dialer retries against the static addresses behind api-backup.defined.net. TLS still verifies against the hostname. --- client.go | 40 ++++++++++++++++++++++++++++++++++------ client_test.go | 22 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/client.go b/client.go index 8373e52..74b3b47 100644 --- a/client.go +++ b/client.go @@ -30,6 +30,38 @@ type Client struct { streamingClient *http.Client } +// fallbackAddrs holds the pinned addresses behind api-backup.defined.net. +// They are dialed only when the system resolver cannot resolve the host, so a +// client whose DNS points into a Nebula network that is down (expired OIDC, +// lost network access) can still reach the API to recover. TLS still +// verifies against the hostname, so a fallback address cannot impersonate +// the API. +var fallbackAddrs = map[string][]string{ + "api.defined.net": {"209.46.53.8", "209.46.53.81", "209.46.53.153"}, +} + +// dialWithFallback returns a DialContext that retries DNS failures for hosts +// in fallback against their pinned addresses. +func dialWithFallback(fallback map[string][]string) func(ctx context.Context, network, addr string) (net.Conn, error) { + d := &net.Dialer{Timeout: 10 * time.Second} + return func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := d.DialContext(ctx, network, addr) + host, port, splitErr := net.SplitHostPort(addr) + var dnsErr *net.DNSError + if err == nil || splitErr != nil || !errors.As(err, &dnsErr) { + return conn, err + } + for _, ip := range fallback[host] { + conn, fbErr := d.DialContext(ctx, network, net.JoinHostPort(ip, port)) + if fbErr == nil { + return conn, nil + } + err = fmt.Errorf("%w; fallback %s: %w", err, ip, fbErr) + } + return nil, err + } +} + // NewClient returns new Client configured with the given useragent. // It also supports reading Proxy information from the environment. func NewClient(useragent string, dnServer string) *Client { @@ -40,9 +72,7 @@ func NewClient(useragent string, dnServer string) *Client { T: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 10 * time.Second, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, + DialContext: dialWithFallback(fallbackAddrs), }, useragent: useragent, }, @@ -53,9 +83,7 @@ func NewClient(useragent string, dnServer string) *Client { T: &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 10 * time.Second, - DialContext: (&net.Dialer{ - Timeout: 10 * time.Second, - }).DialContext, + DialContext: dialWithFallback(fallbackAddrs), }, useragent: useragent, }, diff --git a/client_test.go b/client_test.go index 37fc0b0..24be7c1 100644 --- a/client_test.go +++ b/client_test.go @@ -12,6 +12,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "net/http/httptest" "testing" @@ -1193,6 +1194,27 @@ func TestOverrideTimeout(t *testing.T) { require.ErrorIs(t, err, context.DeadlineExceeded) } +func TestDialWithFallback(t *testing.T) { + t.Parallel() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(ts.Close) + _, port, err := net.SplitHostPort(ts.Listener.Addr().String()) + require.NoError(t, err) + + // .invalid never resolves (RFC 2606), so the pinned address must be used + dial := dialWithFallback(map[string][]string{"api.invalid": {"127.0.0.1"}}) + conn, err := dial(context.Background(), "tcp", "api.invalid:"+port) + require.NoError(t, err) + assert.Equal(t, ts.Listener.Addr().String(), conn.RemoteAddr().String()) + conn.Close() + + // hosts without a fallback surface the DNS error unchanged + var dnsErr *net.DNSError + _, err = dial(context.Background(), "tcp", "other.invalid:"+port) + require.ErrorAs(t, err, &dnsErr) +} + func TestGetOidcPollCode(t *testing.T) { t.Parallel()