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>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
36c52ef383
commit
f3a117e813
@@ -1291,9 +1291,9 @@ func (h *Handler) serveDial(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Dial via Tailscale using the resolved IP:port to avoid a TOCTOU
|
||||
// race with DNS re-resolution.
|
||||
outConn, err := h.b.Dialer().UserDial(r.Context(), network, ipp.String())
|
||||
// Dial via Tailscale with the original hostname so UserDial can
|
||||
// resolve all addresses and race across families (happy eyeballs).
|
||||
outConn, err := h.b.Dialer().UserDial(r.Context(), network, addr)
|
||||
if err != nil {
|
||||
http.Error(w, "dial failure: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
|
||||
+13
-80
@@ -14,6 +14,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -24,7 +25,6 @@ import (
|
||||
"tailscale.com/types/logger"
|
||||
"tailscale.com/util/cloudenv"
|
||||
"tailscale.com/util/singleflight"
|
||||
"tailscale.com/util/slicesx"
|
||||
"tailscale.com/util/testenv"
|
||||
)
|
||||
|
||||
@@ -552,16 +552,6 @@ const fallbackDelay = 300 * time.Millisecond
|
||||
// raceDial tries to dial port on each ip in ips, starting a new race
|
||||
// dial every fallbackDelay apart, returning whichever completes first.
|
||||
func (dc *dialCall) raceDial(ctx context.Context, ips []netip.Addr) (net.Conn, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
type res struct {
|
||||
c net.Conn
|
||||
err error
|
||||
}
|
||||
resc := make(chan res) // must be unbuffered
|
||||
failBoost := make(chan struct{}) // best effort send on dial failure
|
||||
|
||||
// Remove IPs that we tried & failed to dial previously
|
||||
// (such as when we're being called after a dnsfallback lookup and get
|
||||
// the same results)
|
||||
@@ -569,77 +559,20 @@ func (dc *dialCall) raceDial(ctx context.Context, ips []netip.Addr) (net.Conn, e
|
||||
if len(ips) == 0 {
|
||||
return nil, errors.New("no IPs")
|
||||
}
|
||||
|
||||
// Partition candidate list and then merge such that an IPv6 address is
|
||||
// in the first spot if present, and then addresses are interleaved.
|
||||
// This ensures that we're trying an IPv6 address first, then
|
||||
// alternating between v4 and v6 in case one of the two networks is
|
||||
// broken.
|
||||
var iv4, iv6 []netip.Addr
|
||||
for _, ip := range ips {
|
||||
if ip.Is6() {
|
||||
iv6 = append(iv6, ip)
|
||||
} else {
|
||||
iv4 = append(iv4, ip)
|
||||
}
|
||||
port, err := strconv.ParseUint(dc.port, 10, 16)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid port %q: %w", dc.port, err)
|
||||
}
|
||||
ips = slicesx.Interleave(iv6, iv4)
|
||||
|
||||
go func() {
|
||||
for i, ip := range ips {
|
||||
if i != 0 {
|
||||
timer := time.NewTimer(fallbackDelay)
|
||||
select {
|
||||
case <-timer.C:
|
||||
case <-failBoost:
|
||||
timer.Stop()
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
go func(ip netip.Addr) {
|
||||
c, err := dc.dialOne(ctx, ip)
|
||||
if err != nil {
|
||||
// Best effort wake-up a pending dial.
|
||||
// e.g. IPv4 dials failing quickly on an IPv6-only system.
|
||||
// In that case we don't want to wait 300ms per IPv4 before
|
||||
// we get to the IPv6 addresses.
|
||||
select {
|
||||
case failBoost <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
select {
|
||||
case resc <- res{c, err}:
|
||||
case <-ctx.Done():
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
}(ip)
|
||||
}
|
||||
}()
|
||||
|
||||
var firstErr error
|
||||
var fails int
|
||||
for {
|
||||
select {
|
||||
case r := <-resc:
|
||||
if r.c != nil {
|
||||
return r.c, nil
|
||||
}
|
||||
fails++
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
if fails == len(ips) {
|
||||
return nil, firstErr
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
addrs := make([]netip.AddrPort, len(ips))
|
||||
for i, ip := range ips {
|
||||
addrs[i] = netip.AddrPortFrom(ip, uint16(port))
|
||||
}
|
||||
return netx.RaceDial(ctx, addrs, func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
c, err := dc.d.fwd(ctx, network, address)
|
||||
ipp, _ := netip.ParseAddrPort(address)
|
||||
dc.noteDialResult(ipp.Addr(), err)
|
||||
return c, err
|
||||
}, fallbackDelay)
|
||||
}
|
||||
|
||||
// TLSDialer is like Dialer but returns a func suitable for using with net/http.Transport.DialTLSContext.
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Tailscale Inc & contributors
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package netx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"tailscale.com/util/slicesx"
|
||||
)
|
||||
|
||||
// RaceDial races TCP connect attempts across addrs using a
|
||||
// happy-eyeballs-style staggered approach: a new dial is started every
|
||||
// fallbackDelay, and the first successful connection wins. Losers are
|
||||
// cancelled and their connections closed. If all dials fail, the first
|
||||
// error is returned.
|
||||
//
|
||||
// Addresses are interleaved v6-first so that IPv6 is preferred but both
|
||||
// families are tried promptly. The dial func is always called with
|
||||
// network "tcp".
|
||||
func RaceDial(ctx context.Context, addrs []netip.AddrPort, dial DialFunc, fallbackDelay time.Duration) (net.Conn, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var v4, v6 []netip.AddrPort
|
||||
for _, a := range addrs {
|
||||
if a.Addr().Is6() {
|
||||
v6 = append(v6, a)
|
||||
} else {
|
||||
v4 = append(v4, a)
|
||||
}
|
||||
}
|
||||
ordered := slicesx.Interleave(v6, v4)
|
||||
|
||||
type result struct {
|
||||
c net.Conn
|
||||
err error
|
||||
}
|
||||
resc := make(chan result) // unbuffered: senders sync with collector
|
||||
failBoost := make(chan struct{}, 1) // wake the launcher when a dial fails fast
|
||||
|
||||
go func() {
|
||||
for i, addr := range ordered {
|
||||
if i > 0 {
|
||||
t := time.NewTimer(fallbackDelay)
|
||||
select {
|
||||
case <-t.C:
|
||||
case <-failBoost:
|
||||
t.Stop()
|
||||
case <-ctx.Done():
|
||||
t.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
c, err := dial(ctx, "tcp", addr.String())
|
||||
if err != nil {
|
||||
select {
|
||||
case failBoost <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
select {
|
||||
case resc <- result{c, err}:
|
||||
case <-ctx.Done():
|
||||
if c != nil {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
var firstErr error
|
||||
var nFailed int
|
||||
for {
|
||||
select {
|
||||
case r := <-resc:
|
||||
if r.err == nil {
|
||||
return r.c, nil
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = r.err
|
||||
}
|
||||
nFailed++
|
||||
if nFailed >= len(ordered) {
|
||||
return nil, firstErr
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
+99
-11
@@ -12,6 +12,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
"tailscale.com/envknob"
|
||||
"tailscale.com/feature"
|
||||
"tailscale.com/feature/buildfeatures"
|
||||
"tailscale.com/net/dnscache"
|
||||
@@ -358,18 +360,27 @@ func (d *Dialer) SetNetMap(nm *netmap.NetworkMap) {
|
||||
d.dns = m
|
||||
}
|
||||
|
||||
// userDialResolve resolves addr as if a user initiating the dial. (e.g. from a
|
||||
// SOCKS or HTTP outbound proxy)
|
||||
func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (netip.AddrPort, error) {
|
||||
// userDialResolveAll resolves addr as if a user initiating the dial.
|
||||
// (e.g. from a SOCKS or HTTP outbound proxy.)
|
||||
//
|
||||
// It returns all candidate addresses so that the caller can apply
|
||||
// happy eyeballs across address families. The returned slice is
|
||||
// non-empty on a nil-error return.
|
||||
func (d *Dialer) userDialResolveAll(ctx context.Context, network, addr string) ([]netip.AddrPort, error) {
|
||||
d.mu.Lock()
|
||||
dns := d.dns
|
||||
exitDNSDoH := d.exitDNSDoHBase
|
||||
d.mu.Unlock()
|
||||
|
||||
// MagicDNS or otherwise baked into the NetworkMap? Try that first.
|
||||
// dns.resolveMemory returns a single address; tailnet names have
|
||||
// one IP each, so there's nothing to race.
|
||||
ipp, err := dns.resolveMemory(ctx, network, addr)
|
||||
if err != errUnresolved {
|
||||
return ipp, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []netip.AddrPort{ipp}, nil
|
||||
}
|
||||
|
||||
// Otherwise, hit the network.
|
||||
@@ -379,7 +390,7 @@ func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (net
|
||||
host, port, err := splitHostPort(addr)
|
||||
if err != nil {
|
||||
// addr is malformed.
|
||||
return netip.AddrPort{}, err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var r net.Resolver
|
||||
@@ -396,16 +407,53 @@ func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (net
|
||||
}
|
||||
|
||||
ips, err := r.LookupIP(ctx, ipNetOfNetwork(network), host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]netip.AddrPort, 0, len(ips))
|
||||
for _, stdIP := range ips {
|
||||
ip, ok := netip.AddrFromSlice(stdIP)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, netip.AddrPortFrom(ip.Unmap(), port))
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("DNS lookup returned no results for %q", host)
|
||||
}
|
||||
if debugPreferIPv6() {
|
||||
slices.SortStableFunc(out, func(a, b netip.AddrPort) int {
|
||||
a6 := a.Addr().Is6()
|
||||
b6 := b.Addr().Is6()
|
||||
if a6 == b6 {
|
||||
return 0
|
||||
}
|
||||
if a6 {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// userDialResolve resolves addr and returns the first candidate.
|
||||
// It is for callers that don't perform happy-eyeballs (notably
|
||||
// [Dialer.UserDialPlan], which only needs to classify one IP).
|
||||
func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (netip.AddrPort, error) {
|
||||
ipps, err := d.userDialResolveAll(ctx, network, addr)
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, err
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return netip.AddrPort{}, fmt.Errorf("DNS lookup returned no results for %q", host)
|
||||
}
|
||||
ip, _ := netip.AddrFromSlice(ips[0])
|
||||
return netip.AddrPortFrom(ip.Unmap(), port), nil
|
||||
return ipps[0], nil
|
||||
}
|
||||
|
||||
// debugPreferIPv6 forces userDialResolveAll to sort AAAA results before
|
||||
// A results, reproducing the failure mode where a client on an IPv6-capable
|
||||
// host picks an unreachable AAAA address through an IPv4-only exit node.
|
||||
// Used by TestExitNodeV4Only to exercise the happy-eyeballs fallback.
|
||||
var debugPreferIPv6 = envknob.RegisterBool("TS_DEBUG_PREFER_IPV6_USERDIAL")
|
||||
|
||||
// ipNetOfNetwork returns "ip", "ip4", or "ip6" corresponding
|
||||
// to the input value of "tcp", "tcp4", "udp6" etc network
|
||||
// names.
|
||||
@@ -479,11 +527,33 @@ func (d *Dialer) SystemDial(ctx context.Context, network, addr string) (net.Conn
|
||||
|
||||
// UserDial connects to the provided network address as if a user were
|
||||
// initiating the dial. (e.g. from a SOCKS or HTTP outbound proxy)
|
||||
//
|
||||
// For TCP, if the name resolves to multiple addresses, UserDial races
|
||||
// connect attempts across address families with a happy-eyeballs delay
|
||||
// and returns the first one that succeeds. This lets dual-stack names
|
||||
// work via an exit node whose egress is single-family without the
|
||||
// caller needing to know which family the exit node can reach.
|
||||
func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
ipp, err := d.userDialResolve(ctx, network, addr)
|
||||
ipps, err := d.userDialResolveAll(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Happy eyeballs is a no-op (and undefined) for UDP; there's no
|
||||
// connect to race.
|
||||
if len(ipps) == 1 || strings.HasPrefix(network, "udp") {
|
||||
return d.dialOneUser(ctx, network, ipps[0])
|
||||
}
|
||||
// Family filtering for "tcp4"/"tcp6" is already handled by
|
||||
// userDialResolveAll (via ipNetOfNetwork), so ipps only contains
|
||||
// addresses of the requested family by this point.
|
||||
return d.raceDialUser(ctx, ipps)
|
||||
}
|
||||
|
||||
// dialOneUser dials ipp using the appropriate transport for a user
|
||||
// dial (netstack, peer dialer, system dialer, or std dialer) based
|
||||
// on what kind of address ipp is.
|
||||
func (d *Dialer) dialOneUser(ctx context.Context, network string, ipp netip.AddrPort) (net.Conn, error) {
|
||||
if d.UseNetstackForIP != nil && d.UseNetstackForIP(ipp.Addr()) {
|
||||
if d.NetstackDialTCP == nil || d.NetstackDialUDP == nil {
|
||||
return nil, errors.New("Dialer not initialized correctly")
|
||||
@@ -515,6 +585,24 @@ func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn,
|
||||
return stdDialer.DialContext(ctx, network, ipp.String())
|
||||
}
|
||||
|
||||
// userDialFallbackDelay is the happy-eyeballs gap between starting
|
||||
// successive connect attempts. 300ms matches Go's net.Dialer default
|
||||
// and the value used by net/dnscache.
|
||||
const userDialFallbackDelay = 300 * time.Millisecond
|
||||
|
||||
// raceDialUser races connect attempts across ipps with a happy-eyeballs
|
||||
// fallback delay, returning the first to succeed. Losers are cancelled
|
||||
// and any conns they produce are closed. If all fail, the first error
|
||||
// is returned.
|
||||
func (d *Dialer) raceDialUser(ctx context.Context, ipps []netip.AddrPort) (net.Conn, error) {
|
||||
return netx.RaceDial(ctx, ipps,
|
||||
func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return d.dialOneUser(ctx, network, netip.MustParseAddrPort(address))
|
||||
},
|
||||
userDialFallbackDelay,
|
||||
)
|
||||
}
|
||||
|
||||
// UserDialPlan resolves addr and reports whether the dialer would
|
||||
// handle it via Tailscale. If viaTailscale is false, the resolved
|
||||
// address is not a Tailscale route and the caller may dial it directly.
|
||||
|
||||
@@ -5,8 +5,12 @@ package tsdial
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gaissmai/bart"
|
||||
)
|
||||
@@ -95,3 +99,136 @@ func TestUserDialPlan(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
@@ -545,6 +545,65 @@ func TestExitNode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExitNodeV4Only verifies that when an exit node is on an
|
||||
// IPv4-only network, a client can still connect through it to a
|
||||
// webserver whose DNS name has both A and AAAA records. This
|
||||
// exercises the happy-eyeballs race dial in net/tsdial.UserDial:
|
||||
// the AAAA connect attempt fails (exit node has no IPv6 egress),
|
||||
// but the A attempt succeeds.
|
||||
//
|
||||
// Fixes tailscale/tailscale#13257 and #19792.
|
||||
func TestExitNodeV4Only(t *testing.T) {
|
||||
env := vmtest.New(t)
|
||||
|
||||
// Exit node network: IPv4 only (no IPv6 prefix → CanV6()=false).
|
||||
// It advertises both 0.0.0.0/0 and ::/0 (required by tailscale up)
|
||||
// but the network has no IPv6 WAN, so v6 traffic will be dropped.
|
||||
exitNet := env.AddNetwork("2.0.0.1", "192.168.2.1/24", vnet.EasyNAT)
|
||||
// Client network: dual-stack so Go's net.Resolver prefers AAAA.
|
||||
clientNet := env.AddNetwork("1.0.0.1", "2000:1::1/64", "192.168.1.1/24", vnet.EasyNAT)
|
||||
// Web server network: use the FakeDualStackWeb VIP's v4 as WAN.
|
||||
webNet := env.AddNetwork("5.0.0.100", "192.168.5.1/24", vnet.One2OneNAT)
|
||||
|
||||
client := env.AddNode("client", clientNet,
|
||||
vmtest.OS(vmtest.Gokrazy),
|
||||
// Force AAAA addresses first in userDialResolveAll results so
|
||||
// the old single-IP code path would pick an unreachable v6 addr.
|
||||
vnet.TailscaledEnv{Key: "TS_DEBUG_PREFER_IPV6_USERDIAL", Value: "1"})
|
||||
exit := env.AddNode("exit", exitNet,
|
||||
vmtest.OS(vmtest.Gokrazy),
|
||||
vmtest.AdvertiseRoutes("0.0.0.0/0,::/0"))
|
||||
env.AddNode("webserver", webNet,
|
||||
vmtest.OS(vmtest.Gokrazy),
|
||||
vmtest.DontJoinTailnet(),
|
||||
vmtest.WebServer(8080))
|
||||
|
||||
approveStep := env.AddStep("Approve exit-node routes")
|
||||
fetchStep := env.AddStep("HTTP GET via exit node using dual-stack DNS name")
|
||||
|
||||
env.Start()
|
||||
|
||||
approveStep.Begin()
|
||||
env.ApproveRoutes(exit, "0.0.0.0/0", "::/0")
|
||||
approveStep.End(nil)
|
||||
|
||||
fetchStep.Begin()
|
||||
env.SetExitNode(client, exit)
|
||||
// Use the VIP hostname so DNS returns both A (5.0.0.100) and AAAA
|
||||
// (2052::5:100). The exit node's network has no IPv6 WAN, so the
|
||||
// AAAA connect attempt will fail and the dialer must fall back to
|
||||
// the A record via happy eyeballs.
|
||||
body := env.HTTPGet(client, "http://dualstack-web.example.com:8080/")
|
||||
t.Logf("response: %s", body)
|
||||
if !strings.Contains(body, "Hello world I am webserver") {
|
||||
fetchStep.Fatalf("unexpected webserver response: %q", body)
|
||||
}
|
||||
if !strings.Contains(body, "from 2.0.0.1") {
|
||||
fetchStep.Fatalf("expected traffic from exit node WAN (2.0.0.1), got: %q", body)
|
||||
}
|
||||
fetchStep.End(nil)
|
||||
}
|
||||
|
||||
// TestDiscoKeyChange verifies that when one node's disco key rotates without
|
||||
// its WireGuard node key changing, peers detect the change, tear down stale
|
||||
// WireGuard session state for that peer, and re-establish the tunnel in both
|
||||
|
||||
@@ -21,6 +21,11 @@ var (
|
||||
fakeSyslog = newVIP("syslog.tailscale", 9)
|
||||
fakeCloudInit = newVIP("cloud-init.tailscale", 5) // serves cloud-init metadata/userdata per node
|
||||
fakeFiles = newVIP("files.tailscale", 6) // serves binary files (tta, tailscale, tailscaled) to VMs
|
||||
|
||||
// FakeDualStackWeb is a dual-stack webserver VIP used by
|
||||
// TestExitNodeV4Only to verify that traffic works through an
|
||||
// IPv4-only exit node even when DNS returns both A and AAAA.
|
||||
FakeDualStackWeb = newVIP("dualstack-web.example.com", "5.0.0.100", "2052::5:100")
|
||||
)
|
||||
|
||||
type virtualIP struct {
|
||||
|
||||
Reference in New Issue
Block a user