diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 9d4977e48..0fcd059ba 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -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 diff --git a/net/dnscache/dnscache.go b/net/dnscache/dnscache.go index 4dc7c1757..6aac8e7f6 100644 --- a/net/dnscache/dnscache.go +++ b/net/dnscache/dnscache.go @@ -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. diff --git a/net/netx/racedial.go b/net/netx/racedial.go new file mode 100644 index 000000000..ce08baac0 --- /dev/null +++ b/net/netx/racedial.go @@ -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() + } + } +} diff --git a/net/netx/racedial_test.go b/net/netx/racedial_test.go new file mode 100644 index 000000000..5e91cc4f7 --- /dev/null +++ b/net/netx/racedial_test.go @@ -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) + } + }) +} diff --git a/net/tsdial/tsdial.go b/net/tsdial/tsdial.go index ca08810a3..d84d74f46 100644 --- a/net/tsdial/tsdial.go +++ b/net/tsdial/tsdial.go @@ -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. diff --git a/net/tsdial/tsdial_test.go b/net/tsdial/tsdial_test.go index 92960acbe..c8868ab36 100644 --- a/net/tsdial/tsdial_test.go +++ b/net/tsdial/tsdial_test.go @@ -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() +} diff --git a/tstest/natlab/vmtest/vmtest_test.go b/tstest/natlab/vmtest/vmtest_test.go index ad8c6f296..98b0ad670 100644 --- a/tstest/natlab/vmtest/vmtest_test.go +++ b/tstest/natlab/vmtest/vmtest_test.go @@ -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 diff --git a/tstest/natlab/vnet/vip.go b/tstest/natlab/vnet/vip.go index 07b64f54c..00c9b414b 100644 --- a/tstest/natlab/vnet/vip.go +++ b/tstest/natlab/vnet/vip.go @@ -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 {