diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index ab5eb55..c466cb5 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -315,6 +315,13 @@ its UDP socket and releases admission. Capsule data fallback and CONNECT-UDP over H2 are not implemented; negotiated H3 HTTP Datagram and Extended CONNECT settings are required. +Target failure is not association failure: an authenticated HTTP `502`/`503` +response when opening one target, or a local target/session admission limit, +marks only that Send as unavailable. The SOCKS5 frontend drops that datagram +without retrying and keeps other target streams alive. Authentication, +connection, cancellation and unclassified failures remain terminal. No wire +format, admission bound or credential verification rule is relaxed. + ### Web automatic fallback `web-auto` first opens the standard CONNECT stream over H3. A transport failure @@ -326,8 +333,10 @@ configured duration is a base randomized independently by +/-20% after each failure; after that interval exactly one concurrent flow probes H3. SOCKS5 UDP in `web-auto` always uses H3 CONNECT-UDP. It never enters the H2 -fallback or changes the TCP fallback circuit; it fails when the H3 path or HTTP -Datagram negotiation is unavailable. Explicit `h3` supports CONNECT-UDP, while +fallback; it fails when the H3 path or HTTP Datagram negotiation is unavailable. +A newly authenticated CONNECT-UDP response can restore H3 path health (a target +rejection does not count as a successful target connection); a cached-target +enqueue cannot clear the TCP cooldown. Explicit `h3` supports CONNECT-UDP, while explicit `h2` does not advertise UDP. This is a reliability policy, not a wire downgrade: each new physical H2 or H3 diff --git a/docs/WEB_COVER.md b/docs/WEB_COVER.md index 099d290..a18d2f6 100644 --- a/docs/WEB_COVER.md +++ b/docs/WEB_COVER.md @@ -219,6 +219,16 @@ are bounded. The relay applies the UDP destination policy, resolves once, and freezes the session to one successfully opened numeric endpoint; replies from other sources cannot enter that target stream. +A signed target rejection (`502`) or admission rejection (`503`), and local +target/session capacity limits, fail only that datagram send. The SOCKS5 frontend +drops the packet and retains the association and its other live targets; it +does not retry the failed packet. Later packets can use a target after capacity +is released. Typed causes remain available to direct PacketConn callers through +`errors.Is` / `errors.As`, with `transport.ErrPacketTargetUnavailable` marking +this narrow recoverable case. Authentication, cancellation, connection and +unknown errors remain terminal. A successful local enqueue still does not +prove delivery or refresh path health. + The current maximum UDP payload is 1,150 bytes. This leaves room in the mandatory 1,200-byte QUIC path for QUIC and DATAGRAM framing, the HTTP quarter-stream ID, and Context ID `0`. A larger logical payload would not be diff --git a/internal/proxy/socks5.go b/internal/proxy/socks5.go index 2c16bde..ecfd46a 100644 --- a/internal/proxy/socks5.go +++ b/internal/proxy/socks5.go @@ -431,9 +431,9 @@ func runSOCKSUDPAssociation( continue } if err := upstream.Send(payload, target); err != nil { - if errors.Is(err, transport.ErrPacketQueueFull) { - // QUIC DATAGRAM is unreliable. Local queue pressure drops this - // packet, not the authenticated UDP association. + if errors.Is(err, transport.ErrPacketQueueFull) || errors.Is(err, transport.ErrPacketTargetUnavailable) { + // Datagram delivery is best effort. Queue pressure or a rejected + // target drops this packet, not other targets on the association. signalActivity() continue } diff --git a/internal/proxy/socks5_udp_target_error_test.go b/internal/proxy/socks5_udp_target_error_test.go new file mode 100644 index 0000000..a15bac9 --- /dev/null +++ b/internal/proxy/socks5_udp_target_error_test.go @@ -0,0 +1,167 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/transport" +) + +func TestSOCKS5UDPAssociateSurvivesWrappedTargetError(t *testing.T) { + upstream := newTargetErrorPacketConn(fmt.Errorf("lazy target open: %w", errors.Join( + transport.ErrPacketTargetUnavailable, errors.New("fixture target refusal"), + ))) + _, client, relay := startTargetErrorAssociation(t, upstream) + assertTargetErrorAssociationEcho(t, upstream, client, relay, "before target failure") + writeTargetErrorDatagram(t, client, relay, "unavailable.invalid:53", "drop only this packet") + select { + case <-upstream.rejected: + case <-time.After(time.Second): + t.Fatal("unavailable target did not reach the upstream") + } + + assertTargetErrorAssociationEcho(t, upstream, client, relay, "after target failure") + if got := upstream.rejections.Load(); got != 1 { + t.Fatalf("failed packet was retried %d times, want one attempt", got) + } + select { + case <-upstream.closed: + t.Fatal("recoverable target error closed the upstream association") + default: + } +} + +func TestSOCKS5UDPAssociateSendFailureRemainsTerminal(t *testing.T) { + for _, test := range []struct { + name string + err error + }{ + {name: "unknown", err: errors.New("fixture upstream failure")}, + {name: "canceled", err: fmt.Errorf("send interrupted: %w", context.Canceled)}, + {name: "deadline", err: fmt.Errorf("send interrupted: %w", context.DeadlineExceeded)}, + {name: "closed", err: fmt.Errorf("send interrupted: %w", net.ErrClosed)}, + } { + t.Run(test.name, func(t *testing.T) { + upstream := newTargetErrorPacketConn(test.err) + control, client, relay := startTargetErrorAssociation(t, upstream) + writeTargetErrorDatagram(t, client, relay, "unavailable.invalid:53", "terminal packet") + select { + case <-upstream.closed: + case <-time.After(time.Second): + t.Fatal("terminal send error left the upstream association open") + } + if err := control.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + if n, err := control.Read(make([]byte, 1)); n != 0 || err == nil { + t.Fatalf("terminal send error did not close the control connection: n=%d err=%v", n, err) + } else if timeout, ok := err.(net.Error); ok && timeout.Timeout() { + t.Fatal("control connection only timed out instead of closing") + } + if got := upstream.rejections.Load(); got != 1 { + t.Fatalf("terminal packet attempts=%d, want one", got) + } + }) + } +} + +type targetErrorPacketConn struct { + *recordingPacketConn + err error + rejected chan struct{} + rejectOnce sync.Once + rejections atomic.Int32 +} + +func newTargetErrorPacketConn(err error) *targetErrorPacketConn { + return &targetErrorPacketConn{ + recordingPacketConn: newRecordingPacketConn(), + err: err, + rejected: make(chan struct{}), + } +} + +func (c *targetErrorPacketConn) Send(payload []byte, address string) error { + if address == "unavailable.invalid:53" { + c.rejections.Add(1) + c.rejectOnce.Do(func() { close(c.rejected) }) + return c.err + } + return c.recordingPacketConn.Send(payload, address) +} + +func startTargetErrorAssociation(t *testing.T, upstream transport.PacketConn) (net.Conn, *net.UDPConn, *net.UDPAddr) { + t.Helper() + dialer := testPacketDialer{ + Dialer: directDialer(), + dialPacket: func(context.Context) (transport.PacketConn, error) { + return upstream, nil + }, + } + server, address, stop := startSOCKS5(t, Config{Dialer: dialer}) + t.Cleanup(func() { stop(server) }) + control := dialTCP(t, address) + t.Cleanup(func() { _ = control.Close() }) + socksGreeting(t, control, nil) + mustWrite(t, control, ipv4SOCKSRequest(socksCommandUDP, net.IPv4zero, 0)) + reply, relay := readSOCKSReplyAddress(t, control) + if reply != socksReplySucceeded { + t.Fatalf("UDP association reply=%d, want success", reply) + } + client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if err := client.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + return control, client, relay +} + +func writeTargetErrorDatagram(t *testing.T, client *net.UDPConn, relay *net.UDPAddr, target, payload string) { + t.Helper() + packet, err := buildSOCKSUDPDatagram([]byte(payload), target) + if err != nil { + t.Fatal(err) + } + if _, err := client.WriteToUDP(packet, relay); err != nil { + t.Fatal(err) + } +} + +func assertTargetErrorAssociationEcho(t *testing.T, upstream *targetErrorPacketConn, client *net.UDPConn, relay *net.UDPAddr, payload string) { + t.Helper() + const target = "192.0.2.7:5353" + writeTargetErrorDatagram(t, client, relay, target, payload) + select { + case sent := <-upstream.sends: + if string(sent.payload) != payload || sent.address != target { + t.Fatalf("upstream packet=%q to %q, want %q to %q", sent.payload, sent.address, payload, target) + } + case <-time.After(time.Second): + t.Fatal("valid datagram did not reach upstream on the same association") + } + upstream.incoming <- packetRecord{payload: []byte(payload), address: target} + if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + buffer := make([]byte, 256) + n, source, err := client.ReadFromUDP(buffer) + if err != nil { + t.Fatal(err) + } + if !source.IP.Equal(relay.IP) || source.Port != relay.Port { + t.Fatalf("response source=%v, want relay %v", source, relay) + } + got, address, err := parseSOCKSUDPDatagram(buffer[:n]) + if err != nil || string(got) != payload || address != target { + t.Fatalf("response=%q from %q, error=%v", got, address, err) + } +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index ea94402..cf30d1a 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -11,9 +11,17 @@ import ( // ErrPacketQueueFull means a best-effort datagram was not accepted because a // bounded local transport queue is full. Datagram frontends may drop that -// packet and keep the association alive; other send errors are terminal. +// packet and keep the association alive. var ErrPacketQueueFull = errors.New("transport: packet send queue is full") +// ErrPacketTargetUnavailable means a datagram was not accepted because its +// target was rejected or a target/session admission limit was reached. It is +// specific to one Send, not failure of the whole multi-target association. +// Datagram frontends may drop that packet and continue using the association. +// Implementations retain the underlying cause for typed diagnostics. Unknown, +// authentication, cancellation and connection errors must not use this marker. +var ErrPacketTargetUnavailable = errors.New("transport: packet target unavailable") + // Dialer creates remote TCP connections through an authenticated tunnel. type Dialer interface { DialContext(ctx context.Context, network, address string) (net.Conn, error) @@ -29,6 +37,9 @@ type PacketDialer interface { // PacketConn carries independent datagrams through an authenticated tunnel. // Send consumes payload before returning. Close must unblock a concurrent // Receive call so proxy shutdown cannot leak goroutines. +// Send errors other than ErrPacketQueueFull and ErrPacketTargetUnavailable are +// terminal for a frontend association. Neither recoverable error implies that +// the packet was delivered; callers must not transparently retry it. type PacketConn interface { Send(payload []byte, address string) error Receive() (payload []byte, address string, err error) diff --git a/internal/tunnel/web_client_udp_health_test.go b/internal/tunnel/web_client_udp_health_test.go index 4df29b1..9a49b79 100644 --- a/internal/tunnel/web_client_udp_health_test.go +++ b/internal/tunnel/web_client_udp_health_test.go @@ -138,6 +138,9 @@ func TestWebClientH3UDPOnlyNewAuthenticatedResponseRecoversCircuit(t *testing.T) if !errors.As(err, &rejection) { t.Fatalf("expected authenticated H3 target rejection, got %v", err) } + if !errors.Is(err, transport.ErrPacketTargetUnavailable) { + t.Fatalf("authenticated target failure was terminal for the packet association: %v", err) + } client.mu.Lock() recovered = client.primaryFailedAt.IsZero() && client.primaryStateID > generation client.mu.Unlock() @@ -175,8 +178,8 @@ func TestWebClientH3UDPAuthenticationFailureDoesNotRecoverCircuit(t *testing.T) t.Fatal(err) } t.Cleanup(func() { _ = packet.Close() }) - if err := packet.Send([]byte("wrong credential"), "missing.example:53"); err == nil { - t.Fatal("incorrect credentials were accepted") + if err := packet.Send([]byte("wrong credential"), "missing.example:53"); err == nil || errors.Is(err, transport.ErrPacketTargetUnavailable) { + t.Fatalf("incorrect credentials accepted or mislabeled as a recoverable target failure: %v", err) } assertWebPrimaryFailureGeneration(t, client, generation) } diff --git a/internal/tunnel/web_udp.go b/internal/tunnel/web_udp.go index 3eb1dc2..9c2c176 100644 --- a/internal/tunnel/web_udp.go +++ b/internal/tunnel/web_udp.go @@ -488,11 +488,24 @@ func (p *webUDPPacketConn) Send(payload []byte, address string) error { } session, err := p.session(canonical) if err != nil { - return err + return webUDPTargetSendError(err) } return session.send(payload) } +// CONNECT-UDP opens a stream lazily for each target. A signed target rejection +// or a local admission limit does not invalidate other targets on this logical +// PacketConn. Keep the original cause (including authentication-proven status) +// visible, without retrying or hiding real transport/authentication failures. +func webUDPTargetSendError(err error) error { + var connectErr *WebConnectError + if errors.Is(err, ErrUDPDestinationCapacity) || errors.Is(err, ErrUDPSessionCapacity) || + (errors.As(err, &connectErr) && (connectErr.StatusCode == http.StatusBadGateway || connectErr.StatusCode == http.StatusServiceUnavailable)) { + return fmt.Errorf("%w: %w", transport.ErrPacketTargetUnavailable, err) + } + return err +} + func (p *webUDPPacketConn) session(target string) (*webUDPClientSession, error) { for { p.mu.Lock() diff --git a/internal/tunnel/web_udp_socks_isolation_test.go b/internal/tunnel/web_udp_socks_isolation_test.go new file mode 100644 index 0000000..9530bb3 --- /dev/null +++ b/internal/tunnel/web_udp_socks_isolation_test.go @@ -0,0 +1,191 @@ +package tunnel + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "net" + "net/http" + "net/netip" + "strconv" + "testing" + "time" + + "github.com/cppla/autocar/internal/proxy" + "github.com/cppla/autocar/internal/transport" +) + +func TestWebH3SOCKSUDPAssociationSurvivesRejectedTarget(t *testing.T) { + for _, status := range []int{http.StatusBadGateway, http.StatusServiceUnavailable} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + echo := startWebUDPEcho(t) + good := "good.example:" + strconv.Itoa(int(echo.Port())) + resolver := newWebUDPTestResolver(map[string]netip.AddrPort{good: echo}) + limit := 4 + if status == http.StatusServiceUnavailable { + // The healthy target occupies the only server admission slot. + // A second target must be rejected before its resolver is called. + limit = 1 + } + _, client := startWebUDPTestPair(t, resolver, WebH3ServerConfig{ + MaxUDPSessions: limit, MaxClientUDPSessions: limit, + }, WebH3ClientConfig{MaxUDPSessions: 4, MaxUDPDestinations: 4}) + observed := &webSOCKSUDPErrorDialer{WebH3Client: client, errors: make(chan error, 1)} + control, udp, relay := startWebSOCKSUDPIsolationClient(t, observed) + packet := webSOCKSUDPDomainPacket("good.example", echo.Port(), []byte("before rejection")) + webSOCKSUDPIsolationEcho(t, udp, relay, packet) + physical := webH3SelectedSession(t, client) + + bad := webSOCKSUDPDomainPacket("missing.example", 19001, []byte("reject only this target")) + if _, err := udp.WriteToUDP(bad, relay); err != nil { + t.Fatal(err) + } + select { + case err := <-observed.errors: + var rejected *WebConnectError + if !errors.As(err, &rejected) || rejected.StatusCode != status { + t.Fatalf("target rejection=%v, want authenticated %d", err, status) + } + case <-time.After(3 * time.Second): + t.Fatal("target rejection did not reach the real SOCKS frontend") + } + // A target rejection must not close the association's existing TCP + // control channel. There is no application data on this channel. + _ = control.SetReadDeadline(time.Now().Add(50 * time.Millisecond)) + var one [1]byte + _, err := control.Read(one[:]) + var timeout net.Error + if !errors.As(err, &timeout) || !timeout.Timeout() { + t.Fatalf("target %d ended the SOCKS association: control Read=%v", status, err) + } + _ = control.SetReadDeadline(time.Time{}) + packet = webSOCKSUDPDomainPacket("good.example", echo.Port(), []byte("after rejection")) + webSOCKSUDPIsolationEcho(t, udp, relay, packet) + if got := webH3SelectedSession(t, client); got != physical || physical.conn.Context().Err() != nil { + t.Fatal("healthy traffic did not reuse the original physical H3 connection") + } + if resolver.count(good) != 1 || len(client.udpSlots) != 1 { + t.Fatal("healthy target was reopened or rejected target leaked admission") + } + wantBadResolutions := 1 + if status == http.StatusServiceUnavailable { + wantBadResolutions = 0 + } + if got := resolver.count("missing.example:19001"); got != wantBadResolutions { + t.Fatalf("rejected target resolver calls=%d, want %d without retry", got, wantBadResolutions) + } + }) + } +} + +// Observe real Send errors without changing their classification, retries, +// payload limit, or PacketConn lifetime. +type webSOCKSUDPErrorDialer struct { + *WebH3Client + errors chan error +} + +func (d *webSOCKSUDPErrorDialer) DialPacket(ctx context.Context) (transport.PacketConn, error) { + packet, err := d.WebH3Client.DialPacket(ctx) + if err != nil { + return nil, err + } + return &webSOCKSUDPErrorPacket{PacketConn: packet, errors: d.errors}, nil +} + +type webSOCKSUDPErrorPacket struct { + transport.PacketConn + errors chan error +} + +func (p *webSOCKSUDPErrorPacket) Send(payload []byte, address string) error { + err := p.PacketConn.Send(payload, address) + if err != nil { + select { + case p.errors <- err: + default: + } + } + return err +} + +func (p *webSOCKSUDPErrorPacket) MaxPayloadSize() int { + return p.PacketConn.(transport.PacketPayloadSizer).MaxPayloadSize() +} + +func startWebSOCKSUDPIsolationClient(t *testing.T, dialer transport.Dialer) (net.Conn, *net.UDPConn, *net.UDPAddr) { + t.Helper() + server, err := proxy.NewSOCKS5Server(proxy.Config{Dialer: dialer, IdleTimeout: 10 * time.Second}) + if err != nil { + t.Fatal(err) + } + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(listener) }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = server.Shutdown(ctx) + _ = listener.Close() + select { + case err := <-serveDone: + if err != nil { + t.Error(err) + } + case <-time.After(time.Second): + t.Error("SOCKS server did not stop") + } + }) + control, err := net.DialTimeout("tcp4", listener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = control.Close() }) + _ = control.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := control.Write([]byte{5, 1, 0}); err != nil { + t.Fatal(err) + } + var greeting [2]byte + if _, err := io.ReadFull(control, greeting[:]); err != nil || greeting != [2]byte{5, 0} { + t.Fatalf("SOCKS greeting=%v err=%v", greeting, err) + } + if _, err := control.Write([]byte{5, 3, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil { + t.Fatal(err) + } + var reply [10]byte + if _, err := io.ReadFull(control, reply[:]); err != nil || !bytes.Equal(reply[:4], []byte{5, 0, 0, 1}) { + t.Fatalf("SOCKS associate=%v err=%v", reply, err) + } + _ = control.SetDeadline(time.Time{}) + relay := &net.UDPAddr{IP: net.IP(reply[4:8]), Port: int(binary.BigEndian.Uint16(reply[8:]))} + udp, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = udp.Close() }) + return control, udp, relay +} + +func webSOCKSUDPIsolationEcho(t *testing.T, udp *net.UDPConn, relay *net.UDPAddr, packet []byte) { + t.Helper() + _ = udp.SetDeadline(time.Now().Add(3 * time.Second)) + if _, err := udp.WriteToUDP(packet, relay); err != nil { + t.Fatal(err) + } + var response [256]byte + n, source, err := udp.ReadFromUDP(response[:]) + if err != nil || !bytes.Equal(response[:n], packet) || source.String() != relay.String() { + t.Fatalf("SOCKS UDP echo=%x source=%v err=%v", response[:n], source, err) + } +} + +func webSOCKSUDPDomainPacket(host string, port uint16, payload []byte) []byte { + packet := append([]byte{0, 0, 0, 3, byte(len(host))}, host...) + packet = binary.BigEndian.AppendUint16(packet, port) + return append(packet, payload...) +} diff --git a/internal/tunnel/web_udp_target_error_test.go b/internal/tunnel/web_udp_target_error_test.go new file mode 100644 index 0000000..bf1585e --- /dev/null +++ b/internal/tunnel/web_udp_target_error_test.go @@ -0,0 +1,83 @@ +package tunnel + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/netip" + "testing" + + "github.com/cppla/autocar/internal/transport" +) + +func TestWebUDPRejectedFirstTargetPreservesBootstrapAndAdmission(t *testing.T) { + echo := startWebUDPEcho(t) + target := echo.String() + resolver := newWebUDPTestResolver(map[string]netip.AddrPort{target: echo}) + server, client := startWebUDPTestPair(t, resolver, WebH3ServerConfig{ + MaxUDPSessions: 1, MaxClientUDPSessions: 1, + }, WebH3ClientConfig{MaxUDPSessions: 1, MaxUDPDestinations: 1}) + packet, err := client.DialPacket(context.Background()) + if err != nil { + t.Fatal(err) + } + defer packet.Close() + // The very first target establishes connection authentication but fails + // resolution. Neither its global reservation nor its per-packet pending + // slot may survive the failure, even at capacity one. + err = packet.Send([]byte("not delivered"), "missing.example:53") + var rejection *WebConnectError + if !errors.Is(err, transport.ErrPacketTargetUnavailable) || !errors.As(err, &rejection) || rejection.StatusCode != http.StatusBadGateway { + t.Fatalf("first target error = %v, want recoverable signed 502", err) + } + assertWebUDPEcho(t, packet, []byte("same association after rejected bootstrap target"), target) + if resolver.count("missing.example:53") != 1 || resolver.count(target) != 1 { + t.Fatal("failed send retried or healthy target was not opened once") + } + if err := packet.Close(); err != nil { + t.Fatal(err) + } + handler := server.server.Handler.(*webTunnelHandler) + eventuallyWebUDP(t, func() bool { return len(client.udpSlots) == 0 && len(handler.udp.slots) == 0 }, "UDP admission leaked after target rejection and close") +} + +func TestWebUDPTargetSendErrorScopeAndCause(t *testing.T) { + for _, test := range []struct { + name string + err error + drop bool + }{ + {"destination_capacity", ErrUDPDestinationCapacity, true}, + {"session_capacity", ErrUDPSessionCapacity, true}, + {"signed_destination_failure", &WebConnectError{Transport: webAuthTransportH3, StatusCode: http.StatusBadGateway}, true}, + {"signed_admission_failure", &WebConnectError{Transport: webAuthTransportH3, StatusCode: http.StatusServiceUnavailable}, true}, + {"bad_request", &WebConnectError{Transport: webAuthTransportH3, StatusCode: http.StatusBadRequest}, false}, + {"unauthorized", &WebConnectError{Transport: webAuthTransportH3, StatusCode: http.StatusUnauthorized}, false}, + {"unknown_signed_status", &WebConnectError{Transport: webAuthTransportH3, StatusCode: http.StatusInternalServerError}, false}, + {"closed", net.ErrClosed, false}, + {"canceled", context.Canceled, false}, + {"deadline", context.DeadlineExceeded, false}, + {"unsigned_status_text", errors.New("HTTP status 502"), false}, + {"authentication", errors.New("server authentication failed"), false}, + } { + t.Run(test.name, func(t *testing.T) { + wrapped := fmt.Errorf("opening target: %w", test.err) + got := webUDPTargetSendError(wrapped) + if errors.Is(got, transport.ErrPacketTargetUnavailable) != test.drop { + t.Fatalf("recoverable=%t, want %t: %v", errors.Is(got, transport.ErrPacketTargetUnavailable), test.drop, got) + } + if !errors.Is(got, test.err) { + t.Fatal("underlying cause was lost") + } + var original, retained *WebConnectError + if errors.As(wrapped, &original) && (!errors.As(got, &retained) || retained != original) { + t.Fatal("authenticated status identity was lost") + } + if !test.drop && got != wrapped { + t.Fatal("terminal error identity changed") + } + }) + } +} diff --git a/internal/tunnel/web_udp_test.go b/internal/tunnel/web_udp_test.go index 681ce5f..e89b05b 100644 --- a/internal/tunnel/web_udp_test.go +++ b/internal/tunnel/web_udp_test.go @@ -559,9 +559,10 @@ func TestWebH3ConnectUDPCapacityAndCloseUnblocksReceive(t *testing.T) { t.Fatal(err) } assertWebUDPEcho(t, packet, []byte("capacity"), firstTarget) - if err := packet.Send([]byte("rejected"), secondTarget); !errors.Is(err, ErrUDPDestinationCapacity) { + if err := packet.Send([]byte("rejected"), secondTarget); !errors.Is(err, ErrUDPDestinationCapacity) || !errors.Is(err, transport.ErrPacketTargetUnavailable) { t.Fatalf("second target error = %v, want %v", err, ErrUDPDestinationCapacity) } + assertWebUDPEcho(t, packet, []byte("existing target survives local limit"), firstTarget) receiveDone := make(chan error, 1) go func() { @@ -600,9 +601,14 @@ func TestWebH3ConnectUDPClientGlobalSessionLimit(t *testing.T) { } defer second.Close() assertWebUDPEcho(t, first, []byte("reserved"), target) - if err := second.Send([]byte("over capacity"), target); !errors.Is(err, ErrUDPSessionCapacity) { + if err := second.Send([]byte("over capacity"), target); !errors.Is(err, ErrUDPSessionCapacity) || !errors.Is(err, transport.ErrPacketTargetUnavailable) { t.Fatalf("second PacketConn Send = %v, want %v", err, ErrUDPSessionCapacity) } + assertWebUDPEcho(t, first, []byte("existing association survives global limit"), target) + if err := first.Close(); err != nil { + t.Fatal(err) + } + assertWebUDPEcho(t, second, []byte("reuse after capacity released"), target) } func TestWebH3ConnectUDPConfigValidation(t *testing.T) {