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
14 changes: 14 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ relay joins both pumps and any started cancellation close callback before
returning; it never closes the shared transport dialer. HTTP CONNECT installs
this owner before replaying any bytes buffered during request parsing.

Both TCP frontends reject a custom dialer's successful socket if the setup
context is already canceled or expired when dialing returns. They close that
owned result and retain the setup error classification (HTTP 504 for a timeout
and the SOCKS timeout reply). This check precedes local timer cleanup; a timely
success remains usable after setup ends, including reusable HTTP origin sockets.
An error returned by the dialer itself is not replaced by a timer or Close error.

## Native TCP flow

1. The local frontend authenticates the local user when configured and parses
Expand Down Expand Up @@ -85,6 +92,13 @@ sibling flows. A per-connection pacer sees aggregate QUIC statistics, so
concurrent streams share one path estimate rather than each overestimating the
same bottleneck.

Native QUIC client closure shuts the physical-dial registration gate before
canceling pending attempts. It waits for each registered attempt to return and
close any late owned connection; concurrent `Close` callers share that completion
barrier. Canceling one caller still ends only its wait, not the shared physical
attempt. An operation that ignores cancellation can delay closure, and best-effort
observability callbacks remain outside this join guarantee.

## Web-cover TCP flow

The web-cover server binds TCP and UDP on the same numeric port. Its TCP side
Expand Down
20 changes: 11 additions & 9 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -383,16 +383,18 @@ func validateAbsoluteTarget(target *url.URL) error {
}

func (s *HTTPServer) dialContext(ctx context.Context, network, address string) (net.Conn, error) {
var (
conn net.Conn
err error
)
if s.cfg.dialTimeout <= 0 {
conn, err = s.cfg.dialer.DialContext(ctx, network, address)
} else {
dialCtx, cancel := context.WithTimeout(ctx, s.cfg.dialTimeout)
dialCtx := ctx
if s.cfg.dialTimeout > 0 {
var cancel context.CancelFunc
dialCtx, cancel = context.WithTimeout(ctx, s.cfg.dialTimeout)
defer cancel()
conn, err = s.cfg.dialer.DialContext(dialCtx, network, address)
}
conn, err := s.cfg.dialer.DialContext(dialCtx, network, address)
if err == nil {
// Reject an owned late result, but keep the dialer's own error when it
// failed. Check before the deferred timer cleanup so a timely result
// remains reusable after this setup context is detached.
err = dialCtx.Err()
}
if err != nil || conn == nil {
// Own a custom dialer's non-nil failed result before discarding it.
Expand Down
6 changes: 6 additions & 0 deletions internal/proxy/socks5.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ func (s *SOCKS5Server) serveConnect(client net.Conn, request socksRequest) {
if err == nil {
upstream, err = s.cfg.dialer.DialContext(ctx, "tcp", request.address)
}
if err == nil {
// A custom dialer can return a socket after setup was canceled or
// expired. Check before our own cancel, which must not invalidate a
// timely successful result or become the established relay's owner.
err = ctx.Err()
}
cancel()
if s.cfg.handshakeTimeout > 0 {
_ = client.SetWriteDeadline(time.Now().Add(s.cfg.handshakeTimeout))
Expand Down
124 changes: 124 additions & 0 deletions internal/proxy/tcp_dial_late_success_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package proxy

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"

"github.com/cppla/autocar/internal/transport"
)

// A custom dialer may finish establishing a socket after its caller's deadline.
// Return an actual TCP endpoint only after observing that deadline, rather than
// sleeping and hoping to race the timer. Cleanup closes the underlying socket
// without changing the independently observed production Close count.
func TestTCPDialLateSuccessRejectedByFrontends(t *testing.T) {
for _, frontend := range []string{"socks-connect", "http-connect", "http-forward"} {
t.Run(frontend, func(t *testing.T) {
var requests atomic.Int32
origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
_, _ = io.WriteString(w, "unexpected late request")
}))
t.Cleanup(origin.Close)
target := strings.TrimPrefix(origin.URL, "http://")
conn, err := net.DialTimeout("tcp", target, time.Second)
if err != nil {
t.Fatal(err)
}
owned := &tcpDialErrorOwnedConn{Conn: conn, closeError: errors.New("late-result close error")}
t.Cleanup(func() { _ = conn.Close() })
returned := make(chan error, 1)
var calls atomic.Int32
dialer := transport.DialFunc(func(ctx context.Context, _, _ string) (net.Conn, error) {
if calls.Add(1) != 1 {
return nil, errors.New("unexpected repeated fixture dial")
}
<-ctx.Done()
returned <- ctx.Err()
return owned, nil
})
cfg := Config{Dialer: dialer, DialTimeout: 25 * time.Millisecond, HandshakeTimeout: time.Second, IdleTimeout: time.Second, MaxConnections: 1}
var client net.Conn
var tracker *connTracker
rejected := true
if frontend == "socks-connect" {
server, address, stop := startSOCKS5(t, cfg)
t.Cleanup(func() { stop(server) })
tracker = server.lifecycle.tracker
client = dialTCP(t, address)
t.Cleanup(func() { _ = client.Close() })
socksGreeting(t, client, nil)
host, port := splitAddress(t, target)
mustWrite(t, client, domainSOCKSRequest(socksCommandConnect, host, port))
if got := readSOCKSReply(t, client); got != socksReplyTTLExpired {
rejected = false
t.Errorf("late success SOCKS reply = %d, want timeout reply %d", got, socksReplyTTLExpired)
}
} else {
server, proxyURL, stop := startHTTPProxy(t, cfg)
t.Cleanup(func() { stop(server) })
tracker = server.lifecycle.tracker
client = dialTCP(t, strings.TrimPrefix(proxyURL, "http://"))
t.Cleanup(func() { _ = client.Close() })
method, requestTarget := http.MethodConnect, target
if frontend == "http-forward" {
method, requestTarget = http.MethodGet, origin.URL+"/late"
}
mustWrite(t, client, []byte(fmt.Sprintf("%s %s HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n", method, requestTarget, target)))
response, err := http.ReadResponse(bufio.NewReader(client), &http.Request{Method: method})
if err != nil {
t.Fatal(err)
}
if response.StatusCode != http.StatusGatewayTimeout {
rejected = false
t.Errorf("late success HTTP status = %d, want 504", response.StatusCode)
}
// On the unfixed CONNECT path, the 200 body is the live tunnel;
// do not block reading it merely to report the failed assertion.
if method != http.MethodConnect || response.StatusCode != http.StatusOK {
if _, err := io.ReadAll(response.Body); err != nil {
t.Error("failure response body: ", err)
}
}
_ = response.Body.Close()
}
select {
case err := <-returned:
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("fixture dial context = %v, want deadline exceeded", err)
}
case <-time.After(time.Second):
t.Fatal("custom dial did not return after its deadline")
}
// Release a regressed successful relay before waiting for its slot.
_ = client.Close()
if !rejected {
_ = conn.Close()
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := tracker.wait(ctx); err != nil {
t.Fatal("late result retained the frontend slot: ", err)
}
if got := owned.closeCalls.Load(); got != 1 {
t.Errorf("late result Close calls = %d, want exactly one", got)
}
if got := calls.Load(); got != 1 {
t.Errorf("custom dial calls = %d, want one", got)
}
if got := requests.Load(); got != 0 {
t.Errorf("origin received %d requests on an expired dial result", got)
}
})
}
}
Loading
Loading