WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -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