Files
tailscale/net/tsdial/tsdial_test.go
Brad Fitzpatrick f3a117e813 net/tsdial: run happy eyeballs across A and AAAA in UserDial
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>
2026-05-20 18:35:55 -07:00

235 lines
6.3 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
package tsdial
import (
"context"
"errors"
"net"
"net/netip"
"sync/atomic"
"testing"
"time"
"github.com/gaissmai/bart"
)
func TestUserDialPlan(t *testing.T) {
tests := []struct {
name string
addr string
routes map[netip.Prefix]bool // nil means no routes configured
useNetstackFor func(netip.Addr) bool // nil means not set
wantVia bool
wantAddr netip.AddrPort
}{
{
name: "loopback_no_routes",
addr: "127.0.0.1:8080",
wantVia: false,
wantAddr: netip.MustParseAddrPort("127.0.0.1:8080"),
},
{
name: "loopback_v6_no_routes",
addr: "[::1]:8080",
wantVia: false,
wantAddr: netip.MustParseAddrPort("[::1]:8080"),
},
{
name: "tailscale_ip_in_routes",
addr: "100.64.1.1:22",
routes: map[netip.Prefix]bool{
netip.MustParsePrefix("100.64.0.0/10"): true,
},
wantVia: true,
wantAddr: netip.MustParseAddrPort("100.64.1.1:22"),
},
{
name: "non_tailscale_ip_in_local_routes",
addr: "10.0.0.5:80",
routes: map[netip.Prefix]bool{
netip.MustParsePrefix("100.64.0.0/10"): true,
netip.MustParsePrefix("10.0.0.0/8"): false, // local route
},
wantVia: false,
wantAddr: netip.MustParseAddrPort("10.0.0.5:80"),
},
{
name: "loopback_with_routes_configured",
addr: "127.0.0.1:3000",
routes: map[netip.Prefix]bool{
netip.MustParsePrefix("100.64.0.0/10"): true,
},
wantVia: false,
wantAddr: netip.MustParseAddrPort("127.0.0.1:3000"),
},
{
name: "netstack_for_ip",
addr: "100.100.100.100:53",
useNetstackFor: func(ip netip.Addr) bool {
return ip == netip.MustParseAddr("100.100.100.100")
},
wantVia: true,
wantAddr: netip.MustParseAddrPort("100.100.100.100:53"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d := &Dialer{}
if tt.routes != nil {
rt := &bart.Table[bool]{}
for pfx, v := range tt.routes {
rt.Insert(pfx, v)
}
d.routes.Store(rt)
}
d.UseNetstackForIP = tt.useNetstackFor
ipp, viaTailscale, err := d.UserDialPlan(context.Background(), "tcp", tt.addr)
if err != nil {
t.Fatalf("UserDialPlan: %v", err)
}
if viaTailscale != tt.wantVia {
t.Errorf("viaTailscale = %v, want %v", viaTailscale, tt.wantVia)
}
if ipp != tt.wantAddr {
t.Errorf("addr = %v, want %v", ipp, tt.wantAddr)
}
})
}
}
// TestRaceDialUserFallback covers the core happy-eyeballs scenario:
// the first family (e.g. AAAA via an IPv4-only exit node) fails to
// connect, and the second family succeeds. The fallback delay should
// not be required because the failing dial wakes the launcher via
// failBoost.
func TestRaceDialUserFallback(t *testing.T) {
v6 := netip.MustParseAddrPort("[2001:db8::1]:80")
v4 := netip.MustParseAddrPort("192.0.2.1:80")
var v4Calls, v6Calls atomic.Int32
d := &Dialer{
UseNetstackForIP: func(netip.Addr) bool { return true },
NetstackDialTCP: func(ctx context.Context, ipp netip.AddrPort) (net.Conn, error) {
if ipp.Addr().Is6() {
v6Calls.Add(1)
return nil, errors.New("simulated v6 unreachable")
}
v4Calls.Add(1)
c, _ := net.Pipe()
return c, nil
},
NetstackDialUDP: func(context.Context, netip.AddrPort) (net.Conn, error) {
t.Fatal("UDP dialer should not be called for TCP race")
return nil, nil
},
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
t0 := time.Now()
c, err := d.raceDialUser(ctx, []netip.AddrPort{v6, v4})
elapsed := time.Since(t0)
if err != nil {
t.Fatalf("raceDialUser: %v", err)
}
defer c.Close()
if v6Calls.Load() != 1 {
t.Errorf("v6 dial attempts = %d, want 1", v6Calls.Load())
}
if v4Calls.Load() != 1 {
t.Errorf("v4 dial attempts = %d, want 1", v4Calls.Load())
}
// We allow up to the fallback delay; with failBoost the v4 attempt
// should kick off as soon as v6 fails, well under the timer.
if elapsed >= userDialFallbackDelay {
t.Errorf("race took %v; expected failBoost to short-circuit the %v delay",
elapsed, userDialFallbackDelay)
}
}
// TestRaceDialUserAllFail verifies that when every candidate fails,
// raceDialUser returns the first error rather than hanging.
func TestRaceDialUserAllFail(t *testing.T) {
ipps := []netip.AddrPort{
netip.MustParseAddrPort("[2001:db8::1]:80"),
netip.MustParseAddrPort("192.0.2.1:80"),
}
d := &Dialer{
UseNetstackForIP: func(netip.Addr) bool { return true },
NetstackDialTCP: func(_ context.Context, ipp netip.AddrPort) (net.Conn, error) {
return nil, errors.New("nope: " + ipp.String())
},
NetstackDialUDP: func(context.Context, netip.AddrPort) (net.Conn, error) { return nil, nil },
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err := d.raceDialUser(ctx, ipps)
if err == nil {
t.Fatal("raceDialUser returned nil error; want error")
}
}
// TestRaceDialUserCancelsLosers verifies that once one dial succeeds,
// any other in-flight dial is cancelled and any conn it eventually
// produces is closed (rather than leaked).
func TestRaceDialUserCancelsLosers(t *testing.T) {
v6 := netip.MustParseAddrPort("[2001:db8::1]:80")
v4 := netip.MustParseAddrPort("192.0.2.1:80")
// v6 blocks until its context is cancelled, then returns a conn we
// must verify is closed.
closed := make(chan struct{})
d := &Dialer{
UseNetstackForIP: func(netip.Addr) bool { return true },
NetstackDialTCP: func(ctx context.Context, ipp netip.AddrPort) (net.Conn, error) {
if ipp.Addr().Is6() {
<-ctx.Done()
a, b := net.Pipe()
go func() {
<-closed
b.Close()
}()
return &closingPipeConn{Conn: a, closed: closed}, nil
}
c, _ := net.Pipe()
return c, nil
},
NetstackDialUDP: func(context.Context, netip.AddrPort) (net.Conn, error) { return nil, nil },
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c, err := d.raceDialUser(ctx, []netip.AddrPort{v6, v4})
if err != nil {
t.Fatalf("raceDialUser: %v", err)
}
defer c.Close()
select {
case <-closed:
case <-time.After(2 * time.Second):
t.Fatal("loser conn was not closed within 2s")
}
}
type closingPipeConn struct {
net.Conn
closed chan struct{}
}
func (c *closingPipeConn) Close() error {
select {
case <-c.closed:
// already closed
default:
close(c.closed)
}
return c.Conn.Close()
}