Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 34 additions & 6 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
Expand All @@ -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,
},
Expand Down
22 changes: 22 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"testing"
Expand Down Expand Up @@ -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()

Expand Down
Loading