Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions docs/WEB_COVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/proxy/socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
167 changes: 167 additions & 0 deletions internal/proxy/socks5_udp_target_error_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 12 additions & 1 deletion internal/transport/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions internal/tunnel/web_client_udp_health_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
15 changes: 14 additions & 1 deletion internal/tunnel/web_udp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading