diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 69c0ee0..2b09920 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -106,6 +106,15 @@ addresses and zones; the already configured TLS ServerName still owns certificat verification. Successful connections retain the library's single-use transport and socket cleanup behavior. +Relay name and service-port resolution use the shared physical attempt's client +context and dial budget, so stalled DNS does not keep AutoCAR's dial worker alive +after cancellation or its configured deadline. An individual caller still cancels +only its own wait, not the shared attempt. IPv4 preference, bracketed IPv6 +preference, zones, empty-host handling and service-port parsing retain the standard +UDP resolver behavior. Context cancellation can end the lookup wait before the +standard library's shared DNS work or OS resolver finishes; client closure does +not promise to join those internal resolver workers. + The native TLS client closes its setup registration gate before cancellation and waits for in-progress TCP/TLS/protocol setup to dispose of unreturned connections. Concurrent client `Close` calls share the completion barrier. An application diff --git a/internal/tunnel/native_quic_addr_context_test.go b/internal/tunnel/native_quic_addr_context_test.go new file mode 100644 index 0000000..b0b3571 --- /dev/null +++ b/internal/tunnel/native_quic_addr_context_test.go @@ -0,0 +1,140 @@ +package tunnel + +import ( + "bytes" + "context" + "errors" + "net" + "testing" + "time" +) + +func TestSelectNativeQUICAddress(t *testing.T) { + v4 := net.IPAddr{IP: net.IP{192, 0, 2, 1}} + v4Other := net.IPAddr{IP: net.IP{192, 0, 2, 2}} + mapped := net.IPAddr{IP: net.ParseIP("::ffff:192.0.2.3")} + v6 := net.IPAddr{IP: net.ParseIP("2001:db8::1")} + v6Other := net.IPAddr{IP: net.ParseIP("2001:db8::2")} + zoned := net.IPAddr{IP: net.ParseIP("fe80::1"), Zone: "audit-zone"} + unspecified := net.IPAddr{IP: net.IPv6unspecified} + unspecifiedZoned := net.IPAddr{IP: net.IPv6unspecified, Zone: "audit-zone"} + v4Zero := net.IPAddr{IP: net.IPv4zero} + for _, tc := range []struct { + name string + address string + addresses []net.IPAddr + want net.IPAddr + }{ + {"dns_ipv6_first_prefers_ipv4", "relay.invalid:443", []net.IPAddr{v6, v4, v4Other}, v4}, + {"dns_ipv4_first_stays_ipv4", "relay.invalid:443", []net.IPAddr{v4, v6}, v4}, + {"bracketed_hostname_prefers_ipv6", "[relay.invalid]:443", []net.IPAddr{v4, v6, v6Other}, v6}, + {"dns_ipv6_only_uses_first", "relay.invalid:443", []net.IPAddr{v6, v6Other}, v6}, + {"bracketed_ipv4_only_uses_first", "[relay.invalid]:443", []net.IPAddr{v4, v4Other}, v4}, + {"mapped_ipv6_classified_as_ipv4", "relay.invalid:443", []net.IPAddr{v6, mapped, v4}, mapped}, + {"bracketed_mapped_then_ipv6", "[relay.invalid]:443", []net.IPAddr{mapped, v6}, v6}, + {"selected_ipv6_zone_preserved", "[relay.invalid]:443", []net.IPAddr{v4, zoned}, zoned}, + {"single_unspecified_dns_uses_ipv4_zero", "relay.invalid:443", []net.IPAddr{unspecified}, v4Zero}, + {"single_unspecified_bracket_keeps_ipv6", "[::]:443", []net.IPAddr{unspecified}, unspecified}, + {"single_unspecified_zone_dns_uses_ipv4_zero", "relay.invalid:443", []net.IPAddr{unspecifiedZoned}, v4Zero}, + {"single_unspecified_bracket_zone_preserved", "[::%audit-zone]:443", []net.IPAddr{unspecifiedZoned}, unspecifiedZoned}, + {"multiple_ipv6_do_not_add_ipv4_zero", "relay.invalid:443", []net.IPAddr{unspecified, v6}, unspecified}, + } { + t.Run(tc.name, func(t *testing.T) { + addresses := nativeQUICAddrContextCloneAddresses(tc.addresses) + before := nativeQUICAddrContextCloneAddresses(addresses) + got := selectNativeQUICAddress(tc.address, addresses) + nativeQUICAddrContextAssertAddress(t, "selection", got, tc.want) + for i := range addresses { + nativeQUICAddrContextAssertAddress(t, "unchanged input", addresses[i], before[i]) + } + }) + } + t.Run("backing_array_nonmutation", func(t *testing.T) { + // An append of the compatibility IPv4 address must not overwrite a + // resolver's spare capacity, even when the visible slice has length 1. + backing := []net.IPAddr{ + {IP: append(net.IP(nil), net.IPv6unspecified...), Zone: "audit-zone"}, + {IP: append(net.IP(nil), v6Other.IP...), Zone: "unselected-capacity"}, + } + before := nativeQUICAddrContextCloneAddresses(backing) + got := selectNativeQUICAddress("relay.invalid:443", backing[:1]) + nativeQUICAddrContextAssertAddress(t, "selection", got, v4Zero) + for i := range backing { + nativeQUICAddrContextAssertAddress(t, "unchanged backing array", backing[i], before[i]) + } + }) +} + +func TestResolveNativeQUICAddressHonorsContext(t *testing.T) { + for _, tc := range []struct{ name, address string }{ + {"canceled_ipv4", "127.0.0.1:443"}, + {"canceled_ipv6_zone", "[fe80::1%audit-zone]:443"}, + {"canceled_empty_host", ":443"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(nil) + cause := errors.New("private native relay-resolution cancellation") + cancel(cause) + got, err := resolveNativeQUICAddress(ctx, tc.address) + if got != "" || err != cause { + t.Fatalf("canceled resolution = (%q, %v), want empty address and exact caller cause", got, err) + } + }) + } + t.Run("expired_standard_context", func(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + got, err := resolveNativeQUICAddress(ctx, "127.0.0.1:443") + if got != "" || err != context.DeadlineExceeded { + t.Fatalf("expired resolution = (%q, %v), want empty address and DeadlineExceeded", got, err) + } + }) + t.Run("elapsed_deadline_before_publication", func(t *testing.T) { + // Model only the timer-publication window: Deadline is fixed and + // elapsed, while the inherited Err/Done still report a live parent. + // The literal address makes this a no-DNS boundary control. + ctx := nativeQUICAddrContextUnpublishedDeadline{ + Context: context.Background(), deadline: time.Now().Add(-time.Second), + } + if ctx.Err() != nil || ctx.Done() != nil || context.Cause(ctx) != nil { + t.Fatal("fixture published cancellation before the resolver call") + } + got, err := resolveNativeQUICAddress(ctx, "127.0.0.1:443") + if got != "" || err != context.DeadlineExceeded { + t.Fatalf("elapsed resolution = (%q, %v), want empty address and DeadlineExceeded", got, err) + } + }) + t.Run("nil_context", func(t *testing.T) { + got, err := resolveNativeQUICAddress(nil, "127.0.0.1:443") + if got != "" || err == nil || err.Error() != "tunnel: nil relay resolution context" { + t.Fatalf("nil context resolution = (%q, %v), want explicit nil-context error", got, err) + } + }) +} + +type nativeQUICAddrContextUnpublishedDeadline struct { + context.Context + deadline time.Time +} + +func (c nativeQUICAddrContextUnpublishedDeadline) Deadline() (time.Time, bool) { + return c.deadline, true +} + +func nativeQUICAddrContextCloneAddresses(addresses []net.IPAddr) []net.IPAddr { + cloned := make([]net.IPAddr, len(addresses)) + for i, address := range addresses { + cloned[i] = net.IPAddr{IP: append(net.IP(nil), address.IP...), Zone: address.Zone} + } + return cloned +} + +func nativeQUICAddrContextAssertAddress(t *testing.T, label string, got, want net.IPAddr) { + t.Helper() + // IP.Equal intentionally folds IPv4 and IPv4-mapped IPv6 together; + // exact bytes also prove the resolver's representation is preserved. + if !bytes.Equal(got.IP, want.IP) || got.Zone != want.Zone { + t.Errorf("%s = (IP %x, zone %q), want (IP %x, zone %q)", label, []byte(got.IP), got.Zone, []byte(want.IP), want.Zone) + } +} diff --git a/internal/tunnel/native_quic_addr_test.go b/internal/tunnel/native_quic_addr_test.go index 74e2049..5f0da5e 100644 --- a/internal/tunnel/native_quic_addr_test.go +++ b/internal/tunnel/native_quic_addr_test.go @@ -31,7 +31,7 @@ func TestResolveNativeQUICAddress(t *testing.T) { {name: "empty_host_zero_port", address: ":0", want: ":0", empty: true}, } { t.Run(tc.name, func(t *testing.T) { - got, err := resolveNativeQUICAddress(tc.address) + got, err := resolveNativeQUICAddress(context.Background(), tc.address) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func TestResolveNativeQUICAddress(t *testing.T) { {"missing_ipv6_bracket", "[::1:443"}, } { t.Run(tc.name, func(t *testing.T) { - got, err := resolveNativeQUICAddress(tc.address) + got, err := resolveNativeQUICAddress(context.Background(), tc.address) if err == nil || got != "" { t.Errorf("malformed address resolved: got=%q err=%v", got, err) } diff --git a/internal/tunnel/native_quic_dns_cancel_test.go b/internal/tunnel/native_quic_dns_cancel_test.go new file mode 100644 index 0000000..0313cfe --- /dev/null +++ b/internal/tunnel/native_quic_dns_cancel_test.go @@ -0,0 +1,368 @@ +package tunnel + +import ( + "context" + "crypto/tls" + "errors" + "net" + "os" + "os/exec" + "strings" + "sync" + "testing" + "time" + + "golang.org/x/net/dns/dnsmessage" +) + +const nativeQUICDNSCancelChild = "AUTOCAR_NATIVE_QUIC_DNS_CANCEL_CHILD" + +func TestNativeQUICCloseCancelsDNSLookup(t *testing.T) { + nativeQUICDNSCancelIsolated(t, "close", "TestNativeQUICCloseCancelsDNSLookup") +} + +func TestNativeQUICDialBudgetIncludesDNSLookup(t *testing.T) { + nativeQUICDNSCancelIsolated(t, "budget", "TestNativeQUICDialBudgetIncludesDNSLookup") +} + +// Resolver replacement is confined to an isolated test process. The parent +// never mutates net.DefaultResolver, and every actual DNS socket is loopback. +func nativeQUICDNSCancelIsolated(t *testing.T, mode, top string) { + t.Helper() + if os.Getenv(nativeQUICDNSCancelChild) == mode { + nativeQUICDNSCancelChildTest(t, mode) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^"+top+"$", + "-test.count=1", "-test.v", "-test.timeout=10s") + cmd.Env = append(os.Environ(), nativeQUICDNSCancelChild+"="+mode) + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { + t.Logf("isolated child | %s", line) + } + if ctx.Err() != nil { + t.Fatalf("isolated DNS probe exceeded its outer budget: %v", ctx.Err()) + } + if err != nil { + t.Fatalf("isolated DNS probe failed: %v", err) + } +} + +type nativeQUICDNSQuestion struct { + id uint16 + questions []dnsmessage.Question + peer *net.UDPAddr +} + +// Embedding UDPConn preserves its PacketConn capability so the real resolver +// exchanges ordinary UDP DNS messages. Close records actual socket disposal; +// it deliberately does not add a context-cancellation watcher. +type nativeQUICDNSConn struct { + *net.UDPConn + once sync.Once + done chan struct{} + err error +} + +func (c *nativeQUICDNSConn) Close() error { + c.once.Do(func() { + c.err = c.UDPConn.Close() + close(c.done) + }) + return c.err +} + +type nativeQUICDNSFixture struct { + peer *net.UDPConn + peerDone chan struct{} + querySeen chan struct{} + mu sync.Mutex + released bool + stopping bool + questions []nativeQUICDNSQuestion + conns []*nativeQUICDNSConn + attempt *quicDialAttempt + queries int + responses int + problem error + client *Client +} + +func (f *nativeQUICDNSFixture) serve() { + defer close(f.peerDone) + buf := make([]byte, 4096) + for { + n, peer, err := f.peer.ReadFromUDP(buf) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + f.recordProblem(err) + } + return + } + var message dnsmessage.Message + if err := message.Unpack(buf[:n]); err != nil || message.Header.Response || len(message.Questions) != 1 { + f.recordProblem(errors.New("unexpected DNS query framing")) + continue + } + q := nativeQUICDNSQuestion{id: message.Header.ID, questions: message.Questions, peer: peer} + f.mu.Lock() + f.queries++ + released := f.released + if !released { + if len(f.questions) >= 32 { + f.problem = errors.New("unexpected DNS query count beyond fixture bound") + } else { + f.questions = append(f.questions, q) + } + } + f.mu.Unlock() + select { + case f.querySeen <- struct{}{}: + default: + } + if released { + f.respond(q) + } + } +} + +func (f *nativeQUICDNSFixture) recordProblem(err error) { + f.mu.Lock() + if f.problem == nil { + f.problem = err + } + f.mu.Unlock() +} + +func (f *nativeQUICDNSFixture) respond(q nativeQUICDNSQuestion) { + response := dnsmessage.Message{Header: dnsmessage.Header{ + ID: q.id, Response: true, Authoritative: true, RecursionAvailable: true, + RCode: dnsmessage.RCodeNameError, + }, Questions: q.questions} + payload, err := response.Pack() + if err == nil { + _, err = f.peer.WriteToUDP(payload, q.peer) + } + if err != nil { + f.recordProblem(err) + return + } + f.mu.Lock() + f.responses++ + f.mu.Unlock() +} + +func (f *nativeQUICDNSFixture) releaseResponses() { + f.mu.Lock() + f.released = true + questions := f.questions + f.questions = nil + f.mu.Unlock() + for _, q := range questions { + f.respond(q) + } +} + +func (f *nativeQUICDNSFixture) dial(ctx context.Context, network, _ string) (net.Conn, error) { + if network != "udp" && network != "udp4" && network != "udp6" { + f.recordProblem(errors.New("unexpected non-UDP resolver dial")) + return nil, errors.New("fixture supports UDP DNS only") + } + f.mu.Lock() + stopping := f.stopping + f.mu.Unlock() + if stopping { + return nil, net.ErrClosed + } + // The DNS service address passed by the resolver is deliberately ignored. + // This numeric owned endpoint never recursively performs DNS or leaves the + // process's loopback network. + conn, err := (&net.Dialer{}).DialContext(ctx, "udp4", f.peer.LocalAddr().String()) + if err != nil { + return nil, err + } + raw, ok := conn.(*net.UDPConn) + if !ok { + _ = conn.Close() + return nil, errors.New("fixture DNS connection is not UDP") + } + tracked := &nativeQUICDNSConn{UDPConn: raw, done: make(chan struct{})} + f.client.mu.Lock() + attempt := f.client.dialing + f.client.mu.Unlock() + f.mu.Lock() + if f.stopping { + f.mu.Unlock() + _ = tracked.Close() + return nil, net.ErrClosed + } + f.conns = append(f.conns, tracked) + if f.attempt == nil { + f.attempt = attempt + } + f.mu.Unlock() + return tracked, nil +} + +func nativeQUICDNSCancelChildTest(t *testing.T, mode string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + peer, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + // Close is intentionally tested before the long physical timeout. The + // budget case instead separates a short physical timer from its live caller. + dialBudget := 4 * time.Second + if mode == "budget" { + dialBudget = 150 * time.Millisecond + } + client, err := NewClient(ClientConfig{ServerAddress: "native-dns-owner.invalid.:443", + Token: testToken, TLSConfig: &tls.Config{ServerName: "native-dns-owner.invalid"}, + QUICDialTimeout: dialBudget}) + if err != nil { + _ = peer.Close() + t.Fatal(err) + } + f := &nativeQUICDNSFixture{peer: peer, peerDone: make(chan struct{}), + querySeen: make(chan struct{}, 1), client: client} + net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: f.dial} + go f.serve() + callerDone := make(chan struct{}) + callerResult := make(chan error, 1) + closeDone := make(chan struct{}) + closeResult := make(chan error, 1) + var closeOnce sync.Once + startClose := func() { + closeOnce.Do(func() { + go func() { + defer close(closeDone) + closeResult <- client.Close() + }() + }) + } + // Cleanup independently unblocks actual DNS I/O, then disposes every owned + // socket and joins our caller/Close/peer workers even after a failed oracle. + // Standard-library singleflight DNS goroutines are not claimed to be part + // of Client.Close's physical-dial WaitGroup. + t.Cleanup(func() { + f.releaseResponses() + startClose() + f.mu.Lock() + f.stopping = true + conns := append([]*nativeQUICDNSConn(nil), f.conns...) + f.mu.Unlock() + for _, conn := range conns { + _ = conn.Close() + } + cancel() + joined := true + for name, done := range map[string]<-chan struct{}{"caller": callerDone, "client Close": closeDone} { + joined = nativeQUICDNSJoin(t, name, done) && joined + } + select { + case err := <-closeResult: + if err != nil { + t.Errorf("cleanup client Close returned an error: %v", err) + } + default: // The main close oracle may already have consumed this result. + } + _ = peer.Close() + joined = nativeQUICDNSJoin(t, "DNS peer", f.peerDone) && joined + for _, conn := range conns { + joined = nativeQUICDNSJoin(t, "DNS socket Close", conn.done) && joined + } + f.mu.Lock() + problem, queries, responses := f.problem, f.queries, f.responses + f.mu.Unlock() + if problem != nil { + t.Errorf("fixture DNS I/O/framing failure: %v", problem) + } + t.Logf("cleanup all_owned_workers_joined=%t; actual DNS queries=%d cleanup responses=%d DNS sockets=%d", joined, queries, responses, len(conns)) + // Keep the replacement until this isolated process exits: a standard + // resolver singleflight worker may still exist after our owned joins. + // The parent global was never changed. + }) + go func() { + defer close(callerDone) + conn, err := client.DialContext(ctx, "tcp", "127.0.0.1:1") + if conn != nil { + _ = conn.Close() + callerResult <- errors.New("unexpected tunnel connection without a DNS response") + return + } + callerResult <- err + }() + select { + case <-f.querySeen: + case <-time.After(2 * time.Second): + t.Fatal("no actual loopback DNS query reached the blackhole peer") + } + f.mu.Lock() + attempt, responses := f.attempt, f.responses + f.mu.Unlock() + if attempt == nil || responses != 0 { + t.Fatalf("missing real physical attempt or premature DNS response: attempt=%t responses=%d", attempt != nil, responses) + } + t.Log("actual loopback DNS query received; no DNS response sent before the cancellation oracle") + if mode == "close" { + startClose() + select { + case <-client.ctx.Done(): + case <-time.After(500 * time.Millisecond): + t.Fatal("Close did not reach the actual client cancellation gate") + } + select { + case <-closeDone: + if err := <-closeResult; err != nil { + t.Errorf("client Close returned an error: %v", err) + } + select { + case <-attempt.done: + default: + t.Error("Client.Close returned before the real physical attempt published completion") + } + case <-time.After(500 * time.Millisecond): + t.Error("Client.Close remained blocked on DNS after canceling its owned physical attempt") + } + select { + case <-callerDone: + if err := <-callerResult; !errors.Is(err, net.ErrClosed) { + t.Errorf("closed-client caller error is not net.ErrClosed: %T %v", err, err) + } + case <-time.After(500 * time.Millisecond): + t.Error("public caller did not return after actual client cancellation") + } + } else { + select { + case <-callerDone: + if err := <-callerResult; !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("physical DNS timeout did not preserve deadline identity: %T %v", err, err) + } + if ctx.Err() != nil { + t.Error("caller context expired instead of the shorter physical dial budget") + } + select { + case <-attempt.done: + default: + t.Error("deadline result preceded actual physical-attempt completion") + } + case <-time.After(500 * time.Millisecond): + t.Error("default native DNS lookup outlived the 150ms physical dial budget") + } + } +} + +func nativeQUICDNSJoin(t *testing.T, name string, done <-chan struct{}) bool { + t.Helper() + select { + case <-done: + return true + case <-time.After(2 * time.Second): + t.Errorf("owned %s worker did not join after independent cleanup", name) + return false + } +} diff --git a/internal/tunnel/native_quic_dns_success_test.go b/internal/tunnel/native_quic_dns_success_test.go new file mode 100644 index 0000000..f788274 --- /dev/null +++ b/internal/tunnel/native_quic_dns_success_test.go @@ -0,0 +1,401 @@ +package tunnel + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/protocol" + "github.com/cppla/autocar/internal/transport" + "golang.org/x/net/dns/dnsmessage" +) + +const nativeQUICDNSSuccessChild = "AUTOCAR_NATIVE_QUIC_DNS_SUCCESS_CHILD" +const nativeQUICDNSSuccessName = "native-dns-success.invalid." + +func TestNativeQUICNamedRelayPreservesTLSIdentity(t *testing.T) { + if os.Getenv(nativeQUICDNSSuccessChild) != "1" { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], + "-test.run=^TestNativeQUICNamedRelayPreservesTLSIdentity$", "-test.count=1", "-test.v", "-test.timeout=10s") + cmd.Env = append(os.Environ(), nativeQUICDNSSuccessChild+"=1") + out, err := cmd.CombinedOutput() + for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { + t.Logf("isolated child | %s", line) + } + if ctx.Err() != nil || err != nil { + t.Fatalf("isolated named-relay flow failed: process=%v outer=%v", err, ctx.Err()) + } + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + serverTLS, clientTLS := nativeQUICDNSSuccessTLS(t) + target, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + peer, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + _ = target.Close() + t.Fatal(err) + } + f := &nativeQUICDNSSuccessFixture{peer: peer} + var destinationDials atomic.Int64 + server, err := ListenQUIC(QUICServerConfig{Address: "127.0.0.1:0", Token: testToken, + TLSConfig: serverTLS, HandshakeTimeout: 2 * time.Second, + Dialer: transport.DialFunc(func(dialCtx context.Context, network, address string) (net.Conn, error) { + destinationDials.Add(1) + if network != "tcp" || address != target.Addr().String() { + return nil, errors.New("unexpected non-owned destination") + } + return (&net.Dialer{}).DialContext(dialCtx, "tcp4", address) + })}) + if err != nil { + _ = target.Close() + _ = peer.Close() + t.Fatal(err) + } + _, port, err := net.SplitHostPort(server.Addr().String()) + if err != nil { + _ = server.Close() + _ = target.Close() + _ = peer.Close() + t.Fatal(err) + } + client, err := NewClient(ClientConfig{ServerAddress: net.JoinHostPort(nativeQUICDNSSuccessName, port), + Token: testToken, TLSConfig: clientTLS, QUICDialTimeout: 2 * time.Second, HandshakeTimeout: 2 * time.Second}) + if err != nil { + _ = server.Close() + _ = target.Close() + _ = peer.Close() + t.Fatal(err) + } + + // Only this exact-test child changes the global. Keep it installed until + // process exit, because resolver-owned background work is outside our joins. + net.DefaultResolver = &net.Resolver{PreferGo: true, Dial: f.dial} + dnsDone, echoDone, serveDone := make(chan struct{}), make(chan struct{}), make(chan struct{}) + echoResult, serveResult := make(chan error, 1), make(chan error, 1) + clientDone, serverDone := make(chan struct{}), make(chan struct{}) + clientResult, serverResult := make(chan error, 1), make(chan error, 1) + var clientOnce, serverOnce sync.Once + closeClient := func() { + clientOnce.Do(func() { + go func() { defer close(clientDone); clientResult <- client.Close() }() + }) + } + closeServer := func() { + serverOnce.Do(func() { + go func() { defer close(serverDone); serverResult <- server.Close() }() + }) + } + var stream net.Conn + var echoMu sync.Mutex + var echoConn *net.TCPConn + echoStopping := false + t.Cleanup(func() { + // Independently dispose exact sockets before joining production owners; + // failed handshakes or assertions must not strand the echo accept/read. + if stream != nil { + _ = stream.Close() + } + echoMu.Lock() + echoStopping = true + if echoConn != nil { + _ = echoConn.Close() + } + echoMu.Unlock() + _ = target.Close() + f.stop() + closeClient() + closeServer() + cancel() + joinCtx, stopJoin := context.WithTimeout(context.Background(), 2*time.Second) + defer stopJoin() + joined := true + for name, done := range map[string]<-chan struct{}{ + "client Close": clientDone, "server Close": serverDone, "server Serve": serveDone, + "DNS peer": dnsDone, "TCP echo": echoDone, + } { + select { + case <-done: + case <-joinCtx.Done(): + joined = false + t.Errorf("owned %s did not join", name) + } + } + for name, result := range map[string]<-chan error{ + "client Close": clientResult, "server Close": serverResult, "server Serve": serveResult, + } { + select { + case err := <-result: + if err != nil { + t.Errorf("%s: %v", name, err) + } + default: // A successful main-path Close check may consume its result. + } + } + f.mu.Lock() + problem, queries, answers, sockets := f.problem, f.queries, f.answers, len(f.conns) + f.mu.Unlock() + if problem != nil { + t.Errorf("DNS fixture: %v", problem) + } + t.Logf("cleanup all_owned_workers_joined=%t DNS_queries=%d A_answers=%d DNS_sockets=%d destination_dials=%d", + joined, queries, answers, sockets, destinationDials.Load()) + }) + go func() { defer close(dnsDone); f.serve() }() + go func() { defer close(serveDone); serveResult <- server.Serve(ctx) }() + const payload = "named relay verified TLS authenticated echo" + go func() { + defer close(echoDone) + conn, err := target.AcceptTCP() + if err != nil { + echoResult <- err + return + } + defer conn.Close() + echoMu.Lock() + if echoStopping { + echoMu.Unlock() + echoResult <- net.ErrClosed + return + } + echoConn = conn + echoMu.Unlock() + err = conn.SetDeadline(time.Now().Add(4 * time.Second)) + var body []byte + if err == nil { + body, err = io.ReadAll(conn) + } + if err == nil && string(body) != payload { + err = fmt.Errorf("actual upload mismatch: %q", body) + } + if err == nil { + var n int + n, err = io.WriteString(conn, "reply:"+payload) + if err == nil && n != len("reply:"+payload) { + err = io.ErrShortWrite + } + } + if err == nil { + err = conn.CloseWrite() + } + echoResult <- err + }() + + if clientTLS.ServerName != "" || clientTLS.InsecureSkipVerify || client.tlsConfig.ServerName != nativeQUICDNSSuccessName { + t.Fatal("fixture must use the configured relay name with no explicit or insecure TLS override") + } + stream, err = client.DialContext(ctx, "tcp", target.Addr().String()) + if err != nil { + t.Fatal(err) + } + if err := stream.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + if n, err := io.WriteString(stream, payload); err != nil || n != len(payload) { + t.Fatalf("upload n=%d err=%v", n, err) + } + closeWriter, ok := stream.(interface{ CloseWrite() error }) + if !ok { + t.Fatal("authenticated stream lost CloseWrite") + } + if err := closeWriter.CloseWrite(); err != nil { + t.Fatal(err) + } + reply, err := io.ReadAll(stream) + if err != nil || string(reply) != "reply:"+payload { + t.Fatalf("actual reply=%q err=%v", reply, err) + } + select { + case <-echoDone: + if err := <-echoResult; err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("actual echo worker did not complete") + } + client.mu.Lock() + physical := client.conn + client.mu.Unlock() + if physical == nil { + t.Fatal("authenticated flow did not retain the actual QUIC connection") + } + state := physical.ConnectionState() + if state.TLS.Version != tls.VersionTLS13 || state.TLS.NegotiatedProtocol != protocol.ALPN || + state.Used0RTT || len(state.TLS.VerifiedChains) == 0 || len(state.TLS.PeerCertificates) == 0 { + t.Fatalf("unverified physical security state: TLS=%#x ALPN=%q 0RTT=%t chains=%d", + state.TLS.Version, state.TLS.NegotiatedProtocol, state.Used0RTT, len(state.TLS.VerifiedChains)) + } + cert := state.TLS.PeerCertificates[0] + if len(cert.IPAddresses) != 0 || len(cert.DNSNames) != 1 || cert.DNSNames[0] != strings.TrimSuffix(nativeQUICDNSSuccessName, ".") { + t.Fatal("actual peer certificate must contain only the owned DNS identity and no IP SAN") + } + f.mu.Lock() + queries, answers := f.queries, f.answers + f.mu.Unlock() + if queries == 0 || answers == 0 || destinationDials.Load() != 1 || client.SelectedTransport() != "quic" { + t.Fatalf("incomplete DNS/authenticated path: queries=%d answers=%d destination_dials=%d path=%q", + queries, answers, destinationDials.Load(), client.SelectedTransport()) + } + _ = stream.Close() + closeClient() + select { + case <-clientDone: + if err := <-clientResult; err != nil { + t.Fatal(err) + } + case <-ctx.Done(): + t.Fatal("client Close did not join after the successful named-relay flow") + } + select { + case <-physical.Context().Done(): + case <-ctx.Done(): + t.Fatal("joined Close left the actual QUIC connection live") + } + t.Log("owned DNS A reply -> numeric QUIC -> verified DNS-only TLS certificate -> authenticated FIN/echo -> joined Close") +} + +type nativeQUICDNSSuccessFixture struct { + peer *net.UDPConn + mu sync.Mutex + stopping bool + conns []*net.UDPConn + queries, answers int + problem error +} + +func (f *nativeQUICDNSSuccessFixture) dial(ctx context.Context, network, _ string) (net.Conn, error) { + if network != "udp" && network != "udp4" && network != "udp6" { + return nil, errors.New("owned DNS fixture supports only UDP") + } + conn, err := (&net.Dialer{}).DialContext(ctx, "udp4", f.peer.LocalAddr().String()) + if err != nil { + return nil, err + } + raw, ok := conn.(*net.UDPConn) + if !ok { + _ = conn.Close() + return nil, errors.New("owned DNS dial did not return a UDP socket") + } + f.mu.Lock() + defer f.mu.Unlock() + if f.stopping { + _ = raw.Close() + return nil, net.ErrClosed + } + f.conns = append(f.conns, raw) + return raw, nil +} + +func (f *nativeQUICDNSSuccessFixture) serve() { + buf := make([]byte, 4096) + for { + n, from, err := f.peer.ReadFromUDP(buf) + if err != nil { + if !errors.Is(err, net.ErrClosed) { + f.note(err) + } + return + } + var query dnsmessage.Message + if err := query.Unpack(buf[:n]); err != nil || query.Header.Response || len(query.Questions) != 1 { + f.note(errors.New("invalid actual DNS query framing")) + continue + } + question := query.Questions[0] + if !strings.EqualFold(question.Name.String(), nativeQUICDNSSuccessName) || question.Class != dnsmessage.ClassINET || + (question.Type != dnsmessage.TypeA && question.Type != dnsmessage.TypeAAAA) { + f.note(errors.New("unexpected DNS question outside the owned relay name")) + continue + } + response := dnsmessage.Message{Header: dnsmessage.Header{ID: query.Header.ID, Response: true, + Authoritative: true, RecursionAvailable: true}, Questions: query.Questions} + if question.Type == dnsmessage.TypeA { + response.Answers = []dnsmessage.Resource{{Header: dnsmessage.ResourceHeader{ + Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET, TTL: 60, + }, Body: &dnsmessage.AResource{A: [4]byte{127, 0, 0, 1}}}} + } + payload, err := response.Pack() + if err == nil { + _, err = f.peer.WriteToUDP(payload, from) + } + if err != nil { + f.note(err) + continue + } + f.mu.Lock() + f.queries++ + if question.Type == dnsmessage.TypeA { + f.answers++ + } + f.mu.Unlock() + } +} + +func (f *nativeQUICDNSSuccessFixture) note(err error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.stopping && errors.Is(err, net.ErrClosed) { + return // An owned peer/socket Close may interrupt its final response. + } + if f.problem == nil { + f.problem = err + } +} + +func (f *nativeQUICDNSSuccessFixture) stop() { + f.mu.Lock() + f.stopping = true + conns := append([]*net.UDPConn(nil), f.conns...) + f.mu.Unlock() + for _, conn := range conns { + _ = conn.Close() + } + _ = f.peer.Close() +} + +func nativeQUICDNSSuccessTLS(t *testing.T) (*tls.Config, *tls.Config) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + now := time.Now() + template := &x509.Certificate{SerialNumber: big.NewInt(now.UnixNano()), + NotBefore: now.Add(-time.Minute), NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, IsCA: true, + DNSNames: []string{strings.TrimSuffix(nativeQUICDNSSuccessName, ".")}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AddCert(cert) + return &tls.Config{Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}}, + &tls.Config{RootCAs: pool} // ServerName intentionally unset; no IP SAN exists. +} diff --git a/internal/tunnel/quic.go b/internal/tunnel/quic.go index 1ff9e14..ee01214 100644 --- a/internal/tunnel/quic.go +++ b/internal/tunnel/quic.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net" + "strings" "sync" "time" @@ -618,7 +619,7 @@ func NewQUICClient(config ClientConfig) (*Client, error) { return NewClient(conf // already fixes the verifying TLS ServerName, so using a numeric endpoint here // changes neither certificate identity nor the single-use transport ownership. func dialNativeQUICAddr(ctx context.Context, address string, tlsConfig *tls.Config, config *quic.Config) (*quic.Conn, error) { - numericAddress, err := resolveNativeQUICAddress(address) + numericAddress, err := resolveNativeQUICAddress(ctx, address) if err != nil { return nil, err } @@ -628,11 +629,46 @@ func dialNativeQUICAddr(ctx context.Context, address string, tlsConfig *tls.Conf return quic.DialAddr(ctx, numericAddress, tlsConfig, config) } -func resolveNativeQUICAddress(address string) (string, error) { - resolved, err := net.ResolveUDPAddr("udp", address) +func resolveNativeQUICAddress(ctx context.Context, address string) (string, error) { + if ctx == nil { + return "", errors.New("tunnel: nil relay resolution context") + } + if err := contextError(ctx); err != nil { + return "", err + } + var host, service string + if address != "" { + var err error + host, service, err = net.SplitHostPort(address) + if err != nil { + return "", err + } + } + port, err := net.DefaultResolver.LookupPort(ctx, "udp", service) if err != nil { return "", err } + if err := contextError(ctx); err != nil { + return "", err + } + resolved := &net.UDPAddr{Port: port} + if host != "" { + // ResolveUDPAddr uses Background internally. Give DNS the same + // client-owned budget as the physical QUIC attempt instead; an + // individual caller still cancels only its wait on that shared attempt. + addresses, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return "", err + } + if err := contextError(ctx); err != nil { + return "", err + } + if len(addresses) == 0 { + return "", &net.AddrError{Err: "no suitable address found", Addr: host} + } + selected := selectNativeQUICAddress(address, addresses) + resolved.IP, resolved.Zone = selected.IP, selected.Zone + } // UDPAddr.String folds mapped IPv6 into IPv4, which cannot carry a zone // through a second resolution. AddrPort preserves both the family and zone. if endpoint := resolved.AddrPort(); endpoint.IsValid() { @@ -643,6 +679,22 @@ func resolveNativeQUICAddress(address string) (string, error) { return resolved.String(), nil } +// Match net.ResolveUDPAddr's single-address selection, including its legacy +// bracketed-host IPv6 preference and unspecified-IPv6 compatibility fallback. +// addresses is nonempty; never mutate the resolver's returned backing array. +func selectNativeQUICAddress(address string, addresses []net.IPAddr) net.IPAddr { + if len(addresses) == 1 && addresses[0].IP.Equal(net.IPv6unspecified) { + addresses = []net.IPAddr{addresses[0], {IP: net.IPv4zero}} + } + wantIPv6 := strings.Contains(address, "[") + for _, candidate := range addresses { + if (candidate.IP.To4() == nil) == wantIPv6 { + return candidate + } + } + return addresses[0] +} + func hardenedQUICClientConfig(input *quic.Config) *quic.Config { var cfg *quic.Config if input == nil {