When tailscaled is running in userspace-networking mode behind an
exit node (e.g. as a SOCKS5 proxy), it resolves a hostname and then
dials a single resolved IP through the tunnel. If the name has both
A and AAAA, Go's net.Resolver merges them and we pick ips[0], which
on an IPv6-native host is usually AAAA. If the exit node has no IPv6
egress (or vice versa), the dial fails silently through the tunnel
and the user sees a hang.
Resolve all candidates and race connect attempts across address
families with a 300ms happy-eyeballs delay, matching Go's net.Dialer
default and the existing pattern in net/dnscache (commit ee0a03b14).
First success wins; losers are cancelled and any conns they produce
are closed. A failBoost channel wakes the launcher when a connect
fails fast (e.g. ICMP "no route" via the tunnel) so we don't sit on
the 300ms timer when the answer is already known.
userDialResolve is refactored into userDialResolveAll (returns the
full candidate list) plus a thin single-IP wrapper for callers like
UserDialPlan that don't race. UserDial's per-IP dispatch (netstack
vs peer dialer vs SystemDial vs std) is extracted to dialOneUser so
each candidate can route correctly on its own merits.
Also fix serveDial in localapi to pass the original hostname to
UserDial rather than a pre-resolved IP, so the race can fire.
This fix is single-ended: it works against any exit node, including
old ones, with no protocol changes. The trade-off versus filtering
on the exit-node side via PeerAPI DoH is that every dial through an
unreachable-family exit node costs one failed connect attempt per
cache window, rather than zero, which is acceptable given the
simplicity.
Fixes #19792
Fixes #13257
Change-Id: I9d7645d0034caf3ee22ecdd8070798353f77e94b
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
134 lines
3.4 KiB
Go
134 lines
3.4 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package netx
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net"
|
|
"net/netip"
|
|
"testing"
|
|
"testing/synctest"
|
|
"time"
|
|
)
|
|
|
|
type fakeConn struct{ net.Conn }
|
|
|
|
func (fakeConn) Close() error { return nil }
|
|
|
|
var (
|
|
v4Addr1 = netip.MustParseAddrPort("192.0.2.1:443")
|
|
v4Addr2 = netip.MustParseAddrPort("192.0.2.2:443")
|
|
v6Addr1 = netip.MustParseAddrPort("[2001:db8::1]:443")
|
|
v6Addr2 = netip.MustParseAddrPort("[2001:db8::2]:443")
|
|
)
|
|
|
|
func TestRaceDialFirstWins(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
addrs := []netip.AddrPort{v6Addr1, v4Addr1, v6Addr2}
|
|
t0 := time.Now()
|
|
conn, err := RaceDial(context.Background(), addrs,
|
|
func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
return fakeConn{}, nil
|
|
},
|
|
300*time.Millisecond,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conn == nil {
|
|
t.Fatal("expected non-nil conn")
|
|
}
|
|
conn.Close()
|
|
if d := time.Since(t0); d != 0 {
|
|
t.Fatalf("took %v; first dial wins immediately so no time should pass", d)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRaceDialAllFail(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
addrs := []netip.AddrPort{v4Addr1, v6Addr1}
|
|
want := errors.New("dial failed")
|
|
t0 := time.Now()
|
|
_, err := RaceDial(context.Background(), addrs,
|
|
func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
return nil, want
|
|
},
|
|
300*time.Millisecond,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if !errors.Is(err, want) {
|
|
t.Fatalf("got %v; want %v", err, want)
|
|
}
|
|
if d := time.Since(t0); d != 0 {
|
|
t.Fatalf("took %v; failBoost should skip all delays", d)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRaceDialCancelledContext(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
t0 := time.Now()
|
|
_, err := RaceDial(ctx, []netip.AddrPort{v4Addr1},
|
|
func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
<-ctx.Done()
|
|
return nil, ctx.Err()
|
|
},
|
|
300*time.Millisecond,
|
|
)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("got %v; want context.Canceled", err)
|
|
}
|
|
if d := time.Since(t0); d != 0 {
|
|
t.Fatalf("took %v; pre-cancelled context should resolve immediately", d)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRaceDialInterleaving(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
var order []string
|
|
addrs := []netip.AddrPort{v4Addr1, v4Addr2, v6Addr1, v6Addr2}
|
|
t0 := time.Now()
|
|
RaceDial(context.Background(), addrs,
|
|
func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
order = append(order, address)
|
|
return nil, errors.New("fail")
|
|
},
|
|
300*time.Millisecond,
|
|
)
|
|
if len(order) != 4 {
|
|
t.Fatalf("expected 4 dials, got %d", len(order))
|
|
}
|
|
ipp, _ := netip.ParseAddrPort(order[0])
|
|
if !ipp.Addr().Is6() {
|
|
t.Errorf("first dial should be v6, got %v", order[0])
|
|
}
|
|
if d := time.Since(t0); d != 0 {
|
|
t.Fatalf("took %v; failBoost should skip all delays", d)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRaceDialFailBoost(t *testing.T) {
|
|
synctest.Test(t, func(t *testing.T) {
|
|
addrs := []netip.AddrPort{v6Addr1, v4Addr1, v6Addr2}
|
|
t0 := time.Now()
|
|
RaceDial(context.Background(), addrs,
|
|
func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
return nil, errors.New("fail")
|
|
},
|
|
time.Hour, // absurdly long; failBoost bypasses it
|
|
)
|
|
if d := time.Since(t0); d >= time.Second {
|
|
t.Fatalf("took %v; failBoost should have bypassed the hour-long delay", d)
|
|
}
|
|
})
|
|
}
|