diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 70f0bf1..3b054a6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -279,6 +279,13 @@ determines the SOCKS5 or HTTP failure response; a cleanup error does not replace it. A nil connection with no error is also rejected. This covers SOCKS5 TCP, HTTP CONNECT and ordinary HTTP forwarding, and is not included in v1.0.1. +The same source-build ownership rule applies to custom destination dialers on +the relay: native QUIC/TLS and authenticated H2/H3 close any connection returned +with an error and reject a nil connection with no error. Failure replies retain +their existing sanitized native status or authenticated HTTP 502; neither dial +nor cleanup details are exposed. Healthy forwarding and shared connections are +unchanged. This is not included in v1.0.1. + For SOCKS5 TCP and HTTP CONNECT tunnels, `--idle-timeout` (default `5m`) measures inactivity across both directions: an active download or upload does not need reverse-direction application traffic to stay open. A blocked write diff --git a/internal/tunnel/common.go b/internal/tunnel/common.go index 2f4fc74..94b4b8a 100644 --- a/internal/tunnel/common.go +++ b/internal/tunnel/common.go @@ -218,7 +218,11 @@ func (s *serverCore) handleStream( dialCtx, cancel := context.WithTimeout(ctx, s.dialTimeout) upstream, err := s.dialer.DialContext(dialCtx, req.Network.String(), req.Address) cancel() - if err != nil { + if err != nil || upstream == nil { + // A custom dialer may return an owned connection even on failure. + if upstream != nil { + _ = upstream.Close() + } // Do not expose resolver, topology or operating-system details to the // peer. The ingress only needs a stable typed failure. _ = protocol.WriteResponse(stream, protocol.Response{Status: protocol.StatusDialFailed, Message: "destination unavailable"}) diff --git a/internal/tunnel/destination_dial_error_ownership_test.go b/internal/tunnel/destination_dial_error_ownership_test.go new file mode 100644 index 0000000..a8eb203 --- /dev/null +++ b/internal/tunnel/destination_dial_error_ownership_test.go @@ -0,0 +1,360 @@ +package tunnel + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/protocol" + "github.com/cppla/autocar/internal/transport" +) + +type destinationDialOwnershipConn struct { + *net.TCPConn + closeErr error + closes atomic.Int64 +} + +func (c *destinationDialOwnershipConn) Close() error { + c.closes.Add(1) + _ = c.TCPConn.Close() + return c.closeErr +} + +// Only the configured destination Dialer returns (owned connection, error). +// The public transport, authentication, failure response and handler lifetimes +// are real. Reclamation is checked before client/server or endpoint cleanup. +func TestDestinationDialErrorOwnershipAuthenticatedTransports(t *testing.T) { + for _, mode := range []string{"quic", "tls", "h2", "h3"} { + t.Run(mode, func(t *testing.T) { + dialErr := errors.New("private destination ownership dial failure") + closeErr := errors.New("private destination ownership close failure") + owned, peer := destinationDialOwnershipPair(t, closeErr) + var dialCalls atomic.Int64 + type dialRequest struct{ network, address string } + request := make(chan dialRequest, 1) + outbound := transport.DialFunc(func(_ context.Context, network, address string) (net.Conn, error) { + dialCalls.Add(1) + select { + case request <- dialRequest{network, address}: + default: + } + return owned, dialErr + }) + client, awaitHandler, verified := destinationDialOwnershipClient(t, mode, outbound) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := client.DialContext(ctx, "tcp", "destination.invalid:443") + if conn != nil { + _ = conn.Close() + t.Fatalf("rejected destination returned client connection %T", conn) + } + if err == nil { + t.Fatal("destination failure unexpectedly succeeded") + } + if mode == "quic" || mode == "tls" { + var remoteErr *RemoteError + if !errors.As(err, &remoteErr) || remoteErr.Status != protocol.StatusDialFailed || remoteErr.Message != "destination unavailable" { + t.Fatalf("native rejection = %T %v, want sanitized typed destination failure", err, err) + } + } else { + var remoteErr *WebConnectError + if !errors.As(err, &remoteErr) || remoteErr.Transport != mode || remoteErr.StatusCode != http.StatusBadGateway { + t.Fatalf("web rejection = %T %v, want authenticated %s typed 502", err, err, mode) + } + if !verified() { + t.Fatal("destination rejection did not establish a verified web session") + } + } + for _, private := range []error{dialErr, closeErr} { + if errors.Is(err, private) || strings.Contains(err.Error(), private.Error()) { + t.Errorf("client error exposes private destination detail: %v", err) + } + } + awaitHandler() + if got := dialCalls.Load(); got != 1 { + t.Errorf("destination dial calls = %d, want one", got) + } + select { + case got := <-request: + if got.network != "tcp" || got.address != "destination.invalid:443" { + t.Errorf("destination request = %+v, want tcp/destination.invalid:443", got) + } + default: + t.Error("destination dial did not record its request") + } + if got := owned.closes.Load(); got != 1 { + t.Errorf("failed destination Close calls = %d, want exactly one before cleanup", got) + } + if err := peer.SetReadDeadline(time.Now().Add(200 * time.Millisecond)); err != nil { + t.Fatal(err) + } + var b [1]byte + n, readErr := peer.Read(b[:]) + if n != 0 || !errors.Is(readErr, io.EOF) { + t.Errorf("peer after handler return = %d/%v, want EOF before cleanup", n, readErr) + } + t.Logf("%s: typed rejection=%T; handler complete and admission released; destination Close=%d; peer Read=%d/%v before cleanup", mode, err, owned.closes.Load(), n, readErr) + + // This independent raw-endpoint positive control makes a baseline + // failure recoverable without manufacturing a counted frontend Close. + closes := owned.closes.Load() + _ = owned.TCPConn.Close() + if err := peer.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + n, readErr = peer.Read(b[:]) + if n != 0 || !errors.Is(readErr, io.EOF) || owned.closes.Load() != closes { + t.Fatalf("raw endpoint cleanup control = %d/%v, Close count=%d want=%d", n, readErr, owned.closes.Load(), closes) + } + t.Log("independent raw endpoint cleanup: real peer EOF, production Close count unchanged") + }) + } +} + +func destinationDialOwnershipPair(t *testing.T, closeErr error) (*destinationDialOwnershipConn, *net.TCPConn) { + t.Helper() + listener, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + type acceptedResult struct { + conn *net.TCPConn + err error + } + accepted := make(chan acceptedResult, 1) + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + conn, err := listener.AcceptTCP() + accepted <- acceptedResult{conn, err} + }() + t.Cleanup(func() { + _ = listener.Close() + destinationDialOwnershipWait(t, acceptDone, "destination accept cleanup") + // Also reclaim a connection accepted just as setup aborted. + select { + case result := <-accepted: + if result.conn != nil { + _ = result.conn.Close() + } + default: + } + }) + upstream, err := net.DialTCP("tcp", nil, listener.Addr().(*net.TCPAddr)) + if err != nil { + t.Fatal(err) + } + // Cleanup bypasses the observed wrapper so its count belongs to production. + t.Cleanup(func() { _ = upstream.Close() }) + var peer *net.TCPConn + select { + case result := <-accepted: + if result.err != nil { + t.Fatal(result.err) + } + peer = result.conn + case <-time.After(time.Second): + t.Fatal("destination accept did not complete") + } + t.Cleanup(func() { _ = peer.Close() }) + _ = listener.Close() + destinationDialOwnershipWait(t, acceptDone, "destination accept") + return &destinationDialOwnershipConn{TCPConn: upstream, closeErr: closeErr}, peer +} + +type destinationDialOwnershipServer interface { + Addr() net.Addr + Serve(context.Context) error + Close() error +} + +type destinationDialOwnershipDialer interface { + transport.Dialer + io.Closer +} + +func destinationDialOwnershipClient(t *testing.T, mode string, outbound transport.Dialer) (destinationDialOwnershipDialer, func(), func() bool) { + t.Helper() + serverTLS, clientTLS := testTLSConfigs(t) + admission, err := NewStreamAdmission(1) + if err != nil { + t.Fatal(err) + } + var server destinationDialOwnershipServer + var client destinationDialOwnershipDialer + var core *serverCore + var awaitHandler func() + verified := func() bool { return true } + var covers atomic.Int64 + cover := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + covers.Add(1) + http.NotFound(w, r) + }) + webCompletion := func(handler http.Handler) (http.Handler, func()) { + done := make(chan struct{}) + var once sync.Once + wrapped := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer once.Do(func() { close(done) }) + handler.ServeHTTP(w, r) + }) + return wrapped, func() { + destinationDialOwnershipWait(t, done, "authenticated web handler") + destinationDialOwnershipAwait(t, func() bool { return len(admission.sem) == 0 }, "web stream admission release") + if got := covers.Load(); got != 0 { + t.Errorf("destination failure entered cover handler %d times", got) + } + } + } + switch mode { + case "quic": + s, listenErr := ListenQUIC(QUICServerConfig{ + Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, + }) + if listenErr != nil { + t.Fatal(listenErr) + } + server, core = s, s.core + client, err = NewClient(ClientConfig{ServerAddress: s.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + awaitHandler = func() { + destinationDialOwnershipAwait(t, func() bool { return len(admission.sem) == 0 && len(s.streamSem) == 0 }, "native QUIC handler and stream admission release") + } + case "tls": + s, listenErr := ListenTLS(TLSServerConfig{ + Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, + }) + if listenErr != nil { + t.Fatal(listenErr) + } + server, core = s, s.core + client, err = NewTLSClient(TLSClientConfig{ServerAddress: s.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + awaitHandler = func() { + destinationDialOwnershipAwait(t, func() bool { return len(admission.sem) == 0 && len(s.connSem) == 0 }, "native TLS handler and stream admission release") + } + case "h2": + s, listenErr := ListenWebH2(WebH2ServerConfig{ + Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, Cover: cover, + }) + if listenErr != nil { + t.Fatal(listenErr) + } + server, core = s, s.server.Handler.(*webTunnelHandler).core + s.server.Handler, awaitHandler = webCompletion(s.server.Handler) + c, clientErr := NewWebH2Client(WebH2ClientConfig{ServerAddress: s.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + client, err = c, clientErr + verified = func() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.current != nil && c.current.authState == webH2ClientAuthReady + } + case "h3": + s, listenErr := ListenWebH3(WebH3ServerConfig{ + Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, Cover: cover, + }) + if listenErr != nil { + t.Fatal(listenErr) + } + server, core = s, s.server.Handler.(*webTunnelHandler).core + s.server.Handler, awaitHandler = webCompletion(s.server.Handler) + c, clientErr := NewWebH3Client(WebH3ClientConfig{ServerAddress: s.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + client, err = c, clientErr + verified = func() bool { + c.mu.Lock() + defer c.mu.Unlock() + session := c.conns[c.conn] + return session != nil && session.authState == webH3ClientAuthReady + } + default: + t.Fatalf("unknown destination ownership transport %q", mode) + } + if err != nil { + _ = server.Close() + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(ctx) }() + t.Cleanup(func() { + cleanupDone := make(chan []error, 1) + go func() { + clientErr := client.Close() + cancel() + cleanupDone <- []error{clientErr, server.Close()} + }() + select { + case closeErrors := <-cleanupDone: + for _, closeErr := range closeErrors { + if !destinationDialOwnershipOnlyClosed(closeErr) { + t.Errorf("%s client/server Close: %v", mode, closeErr) + } + } + case <-time.After(3 * time.Second): + t.Errorf("%s client/server Close cleanup did not join", mode) + } + select { + case serveErr := <-serveDone: + if serveErr != nil { + t.Errorf("%s Serve cleanup: %v", mode, serveErr) + } + t.Logf("%s client/server Serve cleanup joined", mode) + case <-time.After(3 * time.Second): + t.Errorf("%s Serve cleanup did not join", mode) + } + }) + if core.handshakeTimeout != defaultHandshakeTimeout || core.dialTimeout != defaultDialTimeout || core.destinationWriteTimeout != defaultDestinationWriteTimeout { + t.Fatal("ownership fixture changed the production setup/write budgets") + } + return client, awaitHandler, verified +} + +func destinationDialOwnershipAwait(t *testing.T, complete func() bool, what string) { + t.Helper() + timer := time.NewTimer(time.Second) + defer timer.Stop() + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + for !complete() { + select { + case <-tick.C: + case <-timer.C: + t.Fatalf("%s did not complete", what) + } + } +} + +func destinationDialOwnershipWait(t *testing.T, done <-chan struct{}, what string) { + t.Helper() + select { + case <-done: + case <-time.After(time.Second): + t.Errorf("%s did not join", what) + } +} + +// Ignore only closed-resource leaves, not an unrelated joined cleanup error. +func destinationDialOwnershipOnlyClosed(err error) bool { + if err == nil { + return true + } + if joined, ok := err.(interface{ Unwrap() []error }); ok { + for _, child := range joined.Unwrap() { + if !destinationDialOwnershipOnlyClosed(child) { + return false + } + } + return true + } + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + if child := wrapped.Unwrap(); child != nil { + return destinationDialOwnershipOnlyClosed(child) + } + } + return errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) +} diff --git a/internal/tunnel/destination_dial_result_preserve_test.go b/internal/tunnel/destination_dial_result_preserve_test.go new file mode 100644 index 0000000..f3f9b98 --- /dev/null +++ b/internal/tunnel/destination_dial_result_preserve_test.go @@ -0,0 +1,304 @@ +package tunnel + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync/atomic" + "testing" + "time" + + "github.com/cppla/autocar/internal/protocol" + "github.com/cppla/autocar/internal/transport" +) + +// A destination connection result belongs to the request, not this reusable +// Dialer. Observe Close separately so a failure cannot reclaim shared state. +type destinationPreserveDialer struct { + dial transport.DialFunc + calls atomic.Int64 + closes atomic.Int64 +} + +func (d *destinationPreserveDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) { + d.calls.Add(1) + return d.dial(ctx, network, address) +} + +func (d *destinationPreserveDialer) Close() error { + d.closes.Add(1) + return nil +} + +func TestDestinationDialNilResultFailsClosed(t *testing.T) { + for _, mode := range []string{"quic", "tls", "h2", "h3"} { + t.Run(mode, func(t *testing.T) { + outbound := &destinationPreserveDialer{dial: func(context.Context, string, string) (net.Conn, error) { + return nil, nil + }} + admission, err := NewStreamAdmission(1) + if err != nil { + t.Fatal(err) + } + client := newDestinationPreserveClient(t, mode, outbound, admission) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := client.DialContext(ctx, "tcp", "destination.invalid:443") + assertDestinationPreserveRejection(t, mode, conn, err) + awaitWebServerAdmission(t, admission, 0) + if calls, closes := outbound.calls.Load(), outbound.closes.Load(); calls != 1 || closes != 0 { + t.Fatalf("nil destination result calls/closes = %d/%d, want 1/0", calls, closes) + } + }) + } +} + +func TestDestinationDialFailurePreservesWebSession(t *testing.T) { + for _, mode := range []string{"h2", "h3"} { + t.Run(mode, func(t *testing.T) { + payload := bytes.Repeat([]byte("complete upload\x00\xff"), 512) + reply := bytes.Repeat([]byte("reverse response after upload EOF\x00\xff"), 4096) + target, targetResult := startDestinationPreserveTarget(t, payload, reply) + var first atomic.Bool + outbound := &destinationPreserveDialer{dial: func(ctx context.Context, network, address string) (net.Conn, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, network, address) + if err != nil { + return conn, err + } + if first.CompareAndSwap(false, true) { + return conn, errors.New("private destination failure after TCP dial") + } + return conn, nil + }} + admission, err := NewStreamAdmission(1) + if err != nil { + t.Fatal(err) + } + client := newDestinationPreserveClient(t, mode, outbound, admission) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := client.DialContext(ctx, "tcp", target) + assertDestinationPreserveRejection(t, mode, conn, err) + awaitWebServerAdmission(t, admission, 0) + before := destinationPreserveWebSession(t, client) + if got := outbound.closes.Load(); got != 0 { + t.Fatalf("failed bootstrap closed the shared destination Dialer %d times", got) + } + conn, err = client.DialContext(ctx, "tcp", target) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + t.Fatal(err) + } + if _, err := io.Copy(conn, bytes.NewReader(payload)); err != nil { + t.Fatal(err) + } + writer, ok := conn.(interface{ CloseWrite() error }) + if !ok { + t.Fatal("healthy stream lost its upload half-close capability") + } + if err := writer.CloseWrite(); err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(conn) + if err != nil || !bytes.Equal(got, reply) { + t.Fatalf("healthy reverse reply = %d/%d bytes, error=%v", len(got), len(reply), err) + } + select { + case err := <-targetResult: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("destination did not complete the failed-then-healthy requests") + } + _ = conn.Close() + awaitWebServerAdmission(t, admission, 0) + after := destinationPreserveWebSession(t, client) + if before != after { + t.Fatal("destination failure replaced the authenticated physical session or its short-ticket state") + } + if calls, closes := outbound.calls.Load(), outbound.closes.Load(); calls != 2 || closes != 0 { + t.Fatalf("failed-then-healthy destination calls/closes = %d/%d, want 2/0", calls, closes) + } + }) + } +} + +func assertDestinationPreserveRejection(t *testing.T, mode string, conn net.Conn, err error) { + t.Helper() + if conn != nil { + _ = conn.Close() + t.Fatalf("rejected destination returned a successful connection: %T", conn) + } + if mode == "quic" || mode == "tls" { + var remoteErr *RemoteError + if !errors.As(err, &remoteErr) || remoteErr.Status != protocol.StatusDialFailed || remoteErr.Message != "destination unavailable" { + t.Fatalf("destination rejection = %v, want sanitized typed native failure", err) + } + return + } + var remoteErr *WebConnectError + if !errors.As(err, &remoteErr) || remoteErr.Transport != mode || remoteErr.StatusCode != http.StatusBadGateway { + t.Fatalf("destination rejection = %v, want authenticated %s typed 502", err, mode) + } + if err.Error() != fmt.Sprintf("tunnel: %s cover CONNECT rejected with HTTP status 502", mode) { + t.Fatalf("destination rejection exposed private details: %v", err) + } +} + +type destinationPreserveSession struct { + physical any + auth *webSessionClientAuth +} + +func destinationPreserveWebSession(t *testing.T, client transport.Dialer) destinationPreserveSession { + t.Helper() + switch client := client.(type) { + case *WebH2Client: + client.mu.Lock() + defer client.mu.Unlock() + session := client.current + if session == nil || session.authState != webH2ClientAuthReady || session.auth == nil { + t.Fatal("authenticated H2 destination failure lost its ready session") + } + return destinationPreserveSession{physical: session, auth: session.auth} + case *WebH3Client: + client.mu.Lock() + defer client.mu.Unlock() + session := client.conns[client.conn] + if session == nil || session.authState != webH3ClientAuthReady || session.auth == nil { + t.Fatal("authenticated H3 destination failure lost its ready session") + } + return destinationPreserveSession{physical: session, auth: session.auth} + default: + t.Fatalf("unsupported web client %T", client) + return destinationPreserveSession{} + } +} + +// All transport and authentication budgets intentionally retain defaults. +func newDestinationPreserveClient(t *testing.T, mode string, outbound transport.Dialer, admission *StreamAdmission) transport.Dialer { + t.Helper() + serverTLS, clientTLS := testTLSConfigs(t) + var server destinationIntegrationServer + var err error + switch mode { + case "quic": + server, err = ListenQUIC(QUICServerConfig{Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission}) + case "tls": + server, err = ListenTLS(TLSServerConfig{Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission}) + case "h2": + server, err = ListenWebH2(WebH2ServerConfig{Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, Cover: http.NotFoundHandler()}) + case "h3": + server, err = ListenWebH3(WebH3ServerConfig{Address: "127.0.0.1:0", Token: testToken, TLSConfig: serverTLS, Dialer: outbound, StreamAdmission: admission, Cover: http.NotFoundHandler()}) + default: + t.Fatalf("unknown transport %q", mode) + } + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + serveDone := make(chan error, 1) + go func() { serveDone <- server.Serve(ctx) }() + t.Cleanup(func() { + cancel() + _ = server.Close() + select { + case err := <-serveDone: + if err != nil { + t.Errorf("%s server cleanup: %v", mode, err) + } + case <-time.After(time.Second): + t.Errorf("%s server cleanup did not join", mode) + } + }) + var client interface { + transport.Dialer + io.Closer + } + switch mode { + case "quic": + client, err = NewClient(ClientConfig{ServerAddress: server.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + case "tls": + client, err = NewTLSClient(TLSClientConfig{ServerAddress: server.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + case "h2": + client, err = NewWebH2Client(WebH2ClientConfig{ServerAddress: server.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + case "h3": + client, err = NewWebH3Client(WebH3ClientConfig{ServerAddress: server.Addr().String(), Token: testToken, TLSConfig: clientTLS}) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + return client +} + +// One real TCP target first observes reclamation of the failed result, then +// requires upload EOF before sending a complete binary reverse response. +func startDestinationPreserveTarget(t *testing.T, payload, reply []byte) (string, <-chan error) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + result := make(chan error, 1) + done := make(chan struct{}) + go func() { + defer close(done) + result <- func() error { + for attempt := 0; attempt < 2; attempt++ { + conn, err := listener.Accept() + if err != nil { + return err + } + stop := context.AfterFunc(ctx, func() { _ = conn.Close() }) + err = func() error { + defer conn.Close() + if err := conn.SetDeadline(time.Now().Add(3 * time.Second)); err != nil { + return err + } + got, err := io.ReadAll(conn) + if err != nil { + return fmt.Errorf("destination attempt %d read: %w", attempt, err) + } + if attempt == 0 { + if len(got) != 0 { + return fmt.Errorf("failed destination received %d upload bytes", len(got)) + } + return nil + } + if !bytes.Equal(got, payload) { + return fmt.Errorf("healthy destination upload = %d/%d bytes", len(got), len(payload)) + } + if _, err := io.Copy(conn, bytes.NewReader(reply)); err != nil { + return err + } + return conn.(*net.TCPConn).CloseWrite() + }() + stop() + if err != nil { + return err + } + } + return nil + }() + }() + t.Cleanup(func() { + cancel() + _ = listener.Close() + select { + case <-done: + case <-time.After(time.Second): + t.Error("failed-then-healthy destination cleanup did not join") + } + }) + return listener.Addr().String(), result +} diff --git a/internal/tunnel/web_handler.go b/internal/tunnel/web_handler.go index f2293ad..ee16fdf 100644 --- a/internal/tunnel/web_handler.go +++ b/internal/tunnel/web_handler.go @@ -65,7 +65,11 @@ func (h *webTunnelHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { dialCtx, cancel := context.WithTimeout(r.Context(), h.core.dialTimeout) upstream, err := h.core.dialer.DialContext(dialCtx, "tcp", r.Host) cancel() - if err != nil { + if err != nil || upstream == nil { + // Keep cleanup errors out of the authenticated failure response. + if upstream != nil { + _ = upstream.Close() + } h.writeAuthenticatedError(w, &authentication, http.StatusBadGateway) return }