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
21 changes: 21 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ dependency-boundary check permits only the exact fork version above, rejects
local replacement or copied/vendored source trees, and scans tracked and
untracked Go source for the known disallowed proxy application-module import.

## Local TCP frontend lifecycle

HTTP CONNECT and SOCKS5 CONNECT keep the accepted connection's lifetime
separate from the destination dial timeout. After setup succeeds, ordinary
upload EOF only half-closes the destination writer and permits a delayed
response. Explicit socket closure, including forced frontend shutdown, closes
the owned destination even when the upload pump has already finished. The
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.

## Native TCP flow

1. The local frontend authenticates the local user when configured and parses
Expand Down Expand Up @@ -107,6 +118,11 @@ connection and wake waiters to select a replacement. Reconnects and the new H2
connection selected after GOAWAY run the full bootstrap again. QUIC migration or
NAT rebinding that remains the same `*quic.Conn` retains authentication state.

Stopping an opening cancellation watcher is not the connection handoff. H2
and H3 recheck the caller's cancellation and deadline under their final
ownership lock before publishing a TCP stream. Canceling an already handed-off
caller context does not close the healthy stream or its shared connection.

The cover is either a local static directory or a reverse proxy to one fixed,
operator-authorized HTTP(S) origin. Ordinary combined H1/H2/H3 cover responses
advertise the bound H3 service through `Alt-Svc`; raw upgraded 101 responses do
Expand Down Expand Up @@ -181,6 +197,11 @@ logical PacketConn, but the H3 implementation creates one authenticated request
stream lazily for each normalized target and reuses that stream for later
datagrams to the same target.

A successful target open that races with PacketConn closure remains a pending
owner until its client stream, physical-session reservation and UDP admission
slot have been released. Only then is opening completion published to Close
and other waiters; cleanup runs outside the packet mutex.

The SOCKS frontend also isolates lazy H3 target opening, but only for transports
that explicitly implement `transport.PacketConcurrentSender`. Canonical target
aliases share one FIFO lane; the WebClient and close-once wrappers preserve
Expand Down
3 changes: 3 additions & 0 deletions internal/proxy/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@ func (s *HTTPServer) serveConnect(w http.ResponseWriter, r *http.Request) {
return
}
defer client.Close()
// Hijacked response writes and buffered-prefix replay are already tunnel
// work: bind their lifetime before either can block, not only the relay.
defer watchRelayOwner(r.Context(), client, upstream)()

if _, err := buffered.WriteString("HTTP/1.1 200 Connection Established\r\n\r\n"); err != nil {
return
Expand Down
26 changes: 26 additions & 0 deletions internal/proxy/relay.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package proxy

import (
"context"
"errors"
"io"
"net"
Expand Down Expand Up @@ -134,6 +135,31 @@ func relay(left, right net.Conn, idleTimeout time.Duration) error {
return errors.Join(relayErrors...)
}

// watchRelayOwner binds full socket closure to the original physical/request
// lifetime, never its completed setup timeout. Register before replaying any
// buffered payload. The returned function stops an unused callback or joins all
// Close work if cancellation already started. Close calls run outside locks.
func watchRelayOwner(owner context.Context, left, right net.Conn) func() {
ownerClosed := make(chan struct{})
stopOwner := context.AfterFunc(owner, func() {
defer close(ownerClosed)
_ = right.Close()
_ = left.Close()
})
return func() {
if !stopOwner() {
<-ownerClosed
}
}
}

// relayWithOwner preserves ordinary half-close and joins both copy pumps as
// well as an already-started cancellation callback before returning.
func relayWithOwner(owner context.Context, left, right net.Conn, idleTimeout time.Duration) error {
defer watchRelayOwner(owner, left, right)()
return relay(left, right, idleTimeout)
}

// relayActivity makes read inactivity a property of the whole tunnel, not
// either direction independently. Downloads, uploads and server-push streams
// may legitimately have no reverse-direction application data for minutes.
Expand Down
Loading
Loading