diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0490cb..2b89150 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,23 @@ jobs: } Write-Output "Verified ${testName}: $passes passes, $skips skips" } + - name: Exercise bounded web listener retries and rollback on Windows + shell: pwsh + run: | + $retryTests = @('TestWebListenRetryRealUDPCollision', 'TestWebListenRetryPolicy', 'TestWebListenRetryCloseFailure') + $testOutput = & go test ./internal/tunnel -run ('^(' + ($retryTests -join '|') + ')$') -count=3 -timeout=90s -json + $testExit = $LASTEXITCODE + $testOutput | Write-Output + if ($testExit -ne 0) { exit $testExit } + $testEvents = @($testOutput | ForEach-Object { $_ | ConvertFrom-Json -ErrorAction Stop }) + foreach ($testName in $retryTests) { + $passes = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'pass' }).Count + $skips = @($testEvents | Where-Object { $_.Test -eq $testName -and $_.Action -eq 'skip' }).Count + if ($passes -ne 3 -or $skips -ne 0) { + throw "$testName requires exactly 3 passes and 0 skips; got $passes passes and $skips skips" + } + Write-Output "Verified ${testName}: $passes passes, $skips skips" + } - name: Exercise static cover path policy on Windows shell: pwsh run: | diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 731dcdd..dc80439 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -162,6 +162,12 @@ Web-cover presents one HTTPS origin over TCP and UDP on the same numeric port: | HTTP/2 | TCP/TLS 1.2 or 1.3 | configured cover | TLS 1.3-only standard `CONNECT` stream for TCP | | HTTP/3 | QUIC/UDP/TLS 1.3 | configured cover | standard `CONNECT` for TCP; RFC 9298 `CONNECT-UDP` with HTTP Datagrams for UDP | +When both effective listen ports are `0`, startup binds TCP first and uses its +selected port for UDP. If that port is already occupied by UDP, startup closes +TCP before trying a new pair, with at most eight total attempts. A fixed port +on either side disables this retry. Other bind errors and rollback failures +are returned immediately; the server never silently switches a fixed port. + HTTP/1.1 and HTTP/2 cover responses include an `Alt-Svc` value for the bound H3 port. Only H2 and H3 requests with method `CONNECT`, an empty path and query, and exactly one valid `Proxy-Authorization` value are eligible to become a TCP diff --git a/internal/tunnel/web_close_error_test.go b/internal/tunnel/web_close_error_test.go new file mode 100644 index 0000000..3e4fead --- /dev/null +++ b/internal/tunnel/web_close_error_test.go @@ -0,0 +1,94 @@ +package tunnel + +import ( + "errors" + "fmt" + "net" + "net/http" + "testing" +) + +type webCloseErrorWithCustomIs struct{ inner error } + +func (e *webCloseErrorWithCustomIs) Error() string { return "custom cleanup failure" } +func (e *webCloseErrorWithCustomIs) Unwrap() error { return e.inner } +func (e *webCloseErrorWithCustomIs) Is(target error) bool { + return target == net.ErrClosed +} + +type webCloseUnknownMultiError struct{ parts []error } + +func (e *webCloseUnknownMultiError) Error() string { return "unknown cleanup aggregate" } +func (e *webCloseUnknownMultiError) Unwrap() []error { return e.parts } + +// A valid error need not be comparable; normalization must not compare two +// arbitrary error interface values to detect that a child changed. +type webCloseSliceError []string + +func (e webCloseSliceError) Error() string { return "non-comparable cleanup failure" } + +func TestNormalizeWebServerCloseErrorTrees(t *testing.T) { + failure := errors.New("meaningful listener cleanup failure") + wrappedFailure := fmt.Errorf("listener close: %w", failure) + mixed := errors.Join(net.ErrClosed, failure, http.ErrServerClosed) + wrappedMixed := fmt.Errorf("listener close: %w", mixed) + customIs := &webCloseErrorWithCustomIs{inner: failure} + customIsLeaf := &webCloseErrorWithCustomIs{} + emptyAggregate := &webCloseUnknownMultiError{} + nilAggregate := &webCloseUnknownMultiError{parts: []error{nil}} + unknownAggregate := &webCloseUnknownMultiError{parts: []error{wrappedFailure}} + unmodifiedJoin := errors.Join(wrappedFailure, errors.New("another failure")) + for _, test := range []struct { + name string + input error + wantNil bool + wantIdentity bool + wantFailure bool + wantStripped bool + }{ + {name: "nil", wantNil: true}, + {name: "closed", input: net.ErrClosed, wantNil: true}, + {name: "server_closed", input: http.ErrServerClosed, wantNil: true}, + {name: "wrapped_closed", input: fmt.Errorf("listen: %w", net.ErrClosed), wantNil: true}, + {name: "all_benign", input: errors.Join(net.ErrClosed, fmt.Errorf("server: %w", http.ErrServerClosed)), wantNil: true}, + {name: "wrapped_all_benign", input: fmt.Errorf("close: %w", errors.Join(net.ErrClosed, http.ErrServerClosed)), wantNil: true}, + {name: "nested_all_benign", input: errors.Join(fmt.Errorf("listener: %w", errors.Join(net.ErrClosed, http.ErrServerClosed)), net.ErrClosed), wantNil: true}, + {name: "failure", input: failure, wantIdentity: true, wantFailure: true}, + {name: "wrapped_failure", input: wrappedFailure, wantIdentity: true, wantFailure: true}, + {name: "unmodified_join", input: unmodifiedJoin, wantIdentity: true, wantFailure: true}, + {name: "mixed", input: mixed, wantFailure: true, wantStripped: true}, + {name: "nested_mixed", input: errors.Join(net.ErrClosed, mixed), wantFailure: true, wantStripped: true}, + {name: "wrapped_mixed", input: wrappedMixed, wantIdentity: true, wantFailure: true}, + {name: "nested_wrapped_mixed", input: errors.Join(net.ErrClosed, wrappedMixed), wantFailure: true}, + {name: "custom_is", input: customIs, wantIdentity: true, wantFailure: true}, + {name: "custom_is_leaf", input: customIsLeaf, wantIdentity: true}, + {name: "empty_unknown_aggregate", input: emptyAggregate, wantIdentity: true}, + {name: "nil_unknown_aggregate", input: nilAggregate, wantIdentity: true}, + {name: "unknown_aggregate", input: unknownAggregate, wantIdentity: true, wantFailure: true}, + {name: "uncomparable_leaf", input: webCloseSliceError{"failure"}}, + {name: "uncomparable_in_join", input: errors.Join(net.ErrClosed, webCloseSliceError{"failure"}), wantStripped: true}, + } { + t.Run(test.name, func(t *testing.T) { + got := normalizeWebServerCloseError(test.input) + if test.wantNil { + if got != nil { + t.Fatalf("normalization = %v, want nil", got) + } + return + } + if got == nil { + t.Fatal("normalization discarded a meaningful or unknown error") + } + // Only known-comparable values request this identity assertion. + if test.wantIdentity && got != test.input { + t.Fatal("normalization replaced an unmodified error or diagnostic wrapper") + } + if test.wantFailure && !errors.Is(got, failure) { + t.Fatalf("normalization lost meaningful cleanup failure: %v", got) + } + if test.wantStripped && (errors.Is(got, net.ErrClosed) || errors.Is(got, http.ErrServerClosed)) { + t.Fatalf("direct joined error retained benign leaves: %v", got) + } + }) + } +} diff --git a/internal/tunnel/web_h2.go b/internal/tunnel/web_h2.go index 21d90a8..6660585 100644 --- a/internal/tunnel/web_h2.go +++ b/internal/tunnel/web_h2.go @@ -206,14 +206,8 @@ func (s *WebH2Server) Serve(ctx context.Context) error { func (s *WebH2Server) Close() error { s.closeOnce.Do(func() { ownerErr := s.tcpOwner.close() - serverErr := s.server.Close() - listenerErr := s.listener.Close() - if errors.Is(serverErr, http.ErrServerClosed) || errors.Is(serverErr, net.ErrClosed) { - serverErr = nil - } - if errors.Is(listenerErr, net.ErrClosed) { - listenerErr = nil - } + serverErr := normalizeWebServerCloseError(s.server.Close()) + listenerErr := normalizeWebServerCloseError(s.listener.Close()) s.closeErr = errors.Join(ownerErr, serverErr, listenerErr) }) return s.closeErr diff --git a/internal/tunnel/web_listen_retry_test.go b/internal/tunnel/web_listen_retry_test.go new file mode 100644 index 0000000..df52490 --- /dev/null +++ b/internal/tunnel/web_listen_retry_test.go @@ -0,0 +1,333 @@ +package tunnel + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "runtime" + "syscall" + "testing" +) + +func TestWebListenRetryRealUDPCollision(t *testing.T) { + serverTLS, _ := testTLSConfigs(t) + dialer := &webClientTestDialer{dial: func(context.Context, int64, string, string) (net.Conn, error) { + return nil, errors.New("listen retry must not dial a destination") + }} + t.Cleanup(func() { assertWebListenRetryDialerUntouched(t, dialer) }) + streamAdmission, err := NewStreamAdmission(7) + if err != nil { + t.Fatal(err) + } + config := WebServerConfig{ + TCPAddress: "127.0.0.1:0", UDPAddress: "127.0.0.1:0", Token: testToken, + TLSConfig: serverTLS, Dialer: dialer, StreamAdmission: streamAdmission, + Cover: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Alt-Svc", `h3=":1"`) + w.WriteHeader(http.StatusNoContent) + }), + } + reservedTCP, occupiedUDP := reserveWebTestTCPUDP(t) + occupiedAddress := occupiedUDP.LocalAddr().String() + if err := reservedTCP.Close(); err != nil { + t.Fatal(err) + } + + var sharedCore *serverCore + var sharedVerifier *webAuthVerifier + var sharedAdmission *webConnectionAdmission + var sharedCover http.Handler + checkShared := func(core *serverCore, verifier *webAuthVerifier, admission *webConnectionAdmission, cover http.Handler) { + t.Helper() + if core == nil || verifier == nil || admission == nil || cover == nil { + t.Fatal("constructor received nil shared state") + } + if sharedCore == nil { + sharedCore, sharedVerifier, sharedAdmission, sharedCover = core, verifier, admission, cover + } + if core != sharedCore || verifier != sharedVerifier || admission != sharedAdmission || cover != sharedCover { + t.Fatal("retry recreated shared core, replay verifier, admission, or cover") + } + if core.sem != streamAdmission.sem || core.dialer != dialer { + t.Fatal("retry replaced caller-owned stream admission or destination dialer") + } + } + var firstBindError error + var previousTCP string + h2Calls, h3Calls, rollbackChecks := 0, 0, 0 + server, err := listenWebWith(config, + func(attempt WebH2ServerConfig, core *serverCore, verifier *webAuthVerifier) (*WebH2Server, error) { + h2Calls++ + checkShared(core, verifier, attempt.connectionAdmission, attempt.Cover) + if attempt.Address != config.TCPAddress { + t.Fatalf("retry changed ephemeral TCP request: %q", attempt.Address) + } + if h2Calls > 1 { + // Probe once, before the next constructor: retrying this assertion + // could conceal a real rollback leak. + assertWebListenRetryTCPReleased(t, previousTCP) + rollbackChecks++ + } else { + // Keep the outer configuration ephemeral, but deterministically + // make the first real TCP bind choose our occupied UDP port. + attempt.Address = occupiedAddress + } + h2, err := listenWebH2WithCore(attempt, core, verifier) + if err == nil { + previousTCP = h2.Addr().String() + t.Cleanup(func() { _ = h2.Close() }) + } + return h2, err + }, + func(attempt WebH3ServerConfig, core *serverCore, verifier *webAuthVerifier) (*WebH3Server, error) { + h3Calls++ + checkShared(core, verifier, attempt.connectionAdmission, attempt.Cover) + if attempt.Address != previousTCP { + t.Fatalf("UDP did not follow the current bound TCP port: %q / %q", attempt.Address, previousTCP) + } + h3, err := listenWebH3WithCore(attempt, core, verifier) + if h3 != nil { + t.Cleanup(func() { _ = h3.Close() }) + } + if h3Calls == 1 { + if h3 != nil { + t.Fatal("occupied UDP endpoint unexpectedly bound") + } + requireWebListenAddressInUse(t, err, "udp", occupiedAddress) + firstBindError = err + } + return h3, err + }, + ) + if server != nil { + t.Cleanup(func() { _ = server.Close() }) + } + if err != nil || server == nil { + t.Fatalf("retry after real UDP collision: server=%t attempts=%d error=%v", server != nil, h2Calls, err) + } + if firstBindError == nil || h2Calls < 2 || h2Calls > webListenPortAttempts || h3Calls != h2Calls || rollbackChecks != h2Calls-1 { + t.Fatalf("collision/retry evidence: first=%v H2=%d H3=%d rollback=%d", firstBindError, h2Calls, h3Calls, rollbackChecks) + } + if server.TCPAddr().String() != server.UDPAddr().String() || server.UDPAddr().String() == occupiedAddress { + t.Fatalf("retry did not select one new TCP/UDP port: %s / %s", server.TCPAddr(), server.UDPAddr()) + } + for name, handler := range map[string]http.Handler{"h2": server.h2.server.Handler, "h3": server.h3.server.Handler} { + actual, ok := handler.(*webTunnelHandler) + if !ok || actual.core != sharedCore || actual.auth != sharedVerifier || actual.cover != sharedCover { + t.Fatalf("final %s handler lost shared state", name) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "https://cover.test/", nil)) + if got, want := recorder.Header().Get("Alt-Svc"), webH3AltSvcValue(server.UDPAddr()); got != want { + t.Fatalf("%s Alt-Svc = %q, want final UDP port %q", name, got, want) + } + } + if err := normalizeWebServerCloseError(server.Close()); err != nil { + t.Fatalf("final server Close: %v", err) + } + assertWebListenRetryDialerUntouched(t, dialer) +} + +func TestWebListenRetryPolicy(t *testing.T) { + if webListenPortAttempts != 8 { + t.Fatalf("bind attempt budget = %d, want 8", webListenPortAttempts) + } + nativeErrno, foreignErrno := syscall.EADDRINUSE, syscall.Errno(10048) + if runtime.GOOS == "windows" { + nativeErrno, foreignErrno = syscall.Errno(10048), syscall.EADDRINUSE + } + address := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 43217} + collision := &net.OpError{Op: "listen", Net: "udp", Addr: address, Err: nativeErrno} + ordinary := errors.New("non-collision constructor failure") + cases := []struct { + name, tcp, udp, normalizedTCP string + h3Error error + tcpError bool + attempts int + }{ + {"bounded_collision", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", collision, false, 8}, + {"wrapped_collision", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", fmt.Errorf("wrapped: %w", collision), false, 8}, + {"inherited_udp_zero", "127.0.0.1:0", "", "127.0.0.1:0", collision, false, 8}, + {"inherited_tcp_zero", "", "127.0.0.1:0", "127.0.0.1:0", collision, false, 8}, + {"numeric_zero_00", "127.0.0.1:00", "127.0.0.1:00", "127.0.0.1:00", collision, false, 8}, + {"tcp_zero_udp_fixed", "127.0.0.1:0", "127.0.0.1:43217", "127.0.0.1:43217", collision, false, 1}, + {"tcp_fixed_udp_zero", "127.0.0.1:43217", "127.0.0.1:0", "127.0.0.1:43217", collision, false, 1}, + {"both_fixed", "127.0.0.1:43217", "127.0.0.1:43217", "127.0.0.1:43217", collision, false, 1}, + {"tcp_constructor_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", collision, true, 1}, + {"ordinary_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", ordinary, false, 1}, + {"text_only", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", errors.New("listen udp: address already in use"), false, 1}, + {"bare_errno", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", nativeErrno, false, 1}, + {"tcp_op_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", &net.OpError{Op: "listen", Net: "tcp", Err: nativeErrno}, false, 1}, + {"udp_dial_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", &net.OpError{Op: "dial", Net: "udp", Err: nativeErrno}, false, 1}, + {"permission_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", &net.OpError{Op: "listen", Net: "udp", Err: syscall.EACCES}, false, 1}, + {"foreign_os_errno", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", &net.OpError{Op: "listen", Net: "udp", Err: foreignErrno}, false, 1}, + {"joined_error", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", errors.Join(collision, ordinary), false, 1}, + {"wrapped_join", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", fmt.Errorf("wrapped: %w", errors.Join(collision, ordinary)), false, 1}, + {"joined_op_cause", "127.0.0.1:0", "127.0.0.1:0", "127.0.0.1:0", &net.OpError{Op: "listen", Net: "udp", Err: errors.Join(nativeErrno, ordinary)}, false, 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dialer := &webClientTestDialer{} + config := WebServerConfig{ + TCPAddress: tc.tcp, UDPAddress: tc.udp, Token: testToken, + Cover: http.NotFoundHandler(), Dialer: dialer, + } + var listeners []*webListenRetryStubListener + h2Calls, h3Calls := 0, 0 + server, err := listenWebWith(config, + func(attempt WebH2ServerConfig, _ *serverCore, _ *webAuthVerifier) (*WebH2Server, error) { + h2Calls++ + if attempt.Address != tc.normalizedTCP { + t.Fatalf("effective TCP address = %q, want %q", attempt.Address, tc.normalizedTCP) + } + if tc.tcpError { + // Even an error that looks like a UDP collision must never + // trigger retry when returned by the TCP constructor. + return nil, tc.h3Error + } + listener := &webListenRetryStubListener{address: &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 43217}} + listeners = append(listeners, listener) + h2 := &WebH2Server{listener: listener, server: &http.Server{}, tcpOwner: newWebTCPConnectionOwner()} + t.Cleanup(func() { _ = h2.Close() }) + return h2, nil + }, + func(attempt WebH3ServerConfig, _ *serverCore, _ *webAuthVerifier) (*WebH3Server, error) { + h3Calls++ + if attempt.Address != "127.0.0.1:43217" { + t.Fatalf("UDP address did not use bound TCP port: %q", attempt.Address) + } + return nil, tc.h3Error + }, + ) + if server != nil || err != tc.h3Error { + t.Fatalf("failure identity changed: server=%t exactError=%t error=%v", server != nil, err == tc.h3Error, err) + } + wantH3 := tc.attempts + if tc.tcpError { + wantH3 = 0 + } + if h2Calls != tc.attempts || h3Calls != wantH3 { + t.Fatalf("constructor calls H2/H3 = %d/%d, want %d/%d", h2Calls, h3Calls, tc.attempts, wantH3) + } + for index, listener := range listeners { + if listener.closes != 1 { + t.Fatalf("attempt %d TCP close count = %d, want 1", index+1, listener.closes) + } + } + assertWebListenRetryDialerUntouched(t, dialer) + }) + } +} + +func TestWebListenRetryCloseFailure(t *testing.T) { + serverTLS, _ := testTLSConfigs(t) + sentinel := errors.New("controlled TCP close failure") + for _, tc := range []struct { + name string + err error + }{ + {"sentinel", sentinel}, + {"joined_closed", errors.Join(net.ErrClosed, sentinel)}, + {"wrapped_joined_closed", fmt.Errorf("wrapped close: %w", errors.Join(net.ErrClosed, sentinel))}, + } { + t.Run(tc.name, func(t *testing.T) { + dialer := &webClientTestDialer{} + t.Cleanup(func() { assertWebListenRetryDialerUntouched(t, dialer) }) + reservedTCP, occupiedUDP := reserveWebTestTCPUDP(t) + occupiedAddress := occupiedUDP.LocalAddr().String() + if err := reservedTCP.Close(); err != nil { + t.Fatal(err) + } + config := WebServerConfig{ + TCPAddress: "127.0.0.1:0", UDPAddress: "127.0.0.1:0", Token: testToken, + TLSConfig: serverTLS, Cover: http.NotFoundHandler(), Dialer: dialer, + } + h2Calls, h3Calls := 0, 0 + var bindError error + server, err := listenWebWith(config, + func(attempt WebH2ServerConfig, core *serverCore, verifier *webAuthVerifier) (*WebH2Server, error) { + h2Calls++ + if h2Calls > 1 { + t.Fatal("retry concealed a TCP rollback close failure") + } + attempt.Address = occupiedAddress + h2, err := listenWebH2WithCore(attempt, core, verifier) + if err == nil { + h2.listener = &webListenRetryCloseErrorListener{Listener: h2.listener, closeError: tc.err} + t.Cleanup(func() { _ = h2.Close() }) + } + return h2, err + }, + func(attempt WebH3ServerConfig, core *serverCore, verifier *webAuthVerifier) (*WebH3Server, error) { + h3Calls++ + h3, err := listenWebH3WithCore(attempt, core, verifier) + if h3 != nil { + _ = h3.Close() + t.Fatal("occupied UDP endpoint unexpectedly bound") + } + requireWebListenAddressInUse(t, err, "udp", occupiedAddress) + bindError = err + return nil, err + }, + ) + if server != nil { + _ = server.Close() + t.Fatal("close failure returned a server") + } + if h2Calls != 1 || h3Calls != 1 || bindError == nil || !errors.Is(err, bindError) || !errors.Is(err, sentinel) { + t.Fatalf("close failure lost causes or retried: H2=%d H3=%d bind=%v result=%v", h2Calls, h3Calls, bindError, err) + } + // The injected close reports failure only after closing the real + // listener. Confirm that rollback happened, without retrying the bind. + assertWebListenRetryTCPReleased(t, occupiedAddress) + assertWebListenRetryDialerUntouched(t, dialer) + }) + } +} + +func assertWebListenRetryTCPReleased(t *testing.T, address string) { + t.Helper() + probe, err := net.Listen("tcp", address) + if err != nil { + t.Fatalf("TCP listener was not released before retry: %v", err) + } + if err := probe.Close(); err != nil { + t.Fatalf("close rollback probe: %v", err) + } +} + +func assertWebListenRetryDialerUntouched(t *testing.T, dialer *webClientTestDialer) { + t.Helper() + if got := dialer.calls.Load(); got != 0 { + t.Errorf("listener setup made %d destination dials", got) + } + if got := dialer.closeCalls.Load(); got != 0 { + t.Errorf("listener setup closed caller-owned dialer %d times", got) + } +} + +// These policy-only listeners never bind a socket. The real collision and +// rollback behavior is exercised separately above with OS listeners. +type webListenRetryStubListener struct { + address net.Addr + closes int +} + +func (l *webListenRetryStubListener) Accept() (net.Conn, error) { return nil, net.ErrClosed } +func (l *webListenRetryStubListener) Addr() net.Addr { return l.address } +func (l *webListenRetryStubListener) Close() error { + l.closes++ + return nil +} + +type webListenRetryCloseErrorListener struct { + net.Listener + closeError error +} + +func (l *webListenRetryCloseErrorListener) Close() error { + return errors.Join(l.Listener.Close(), l.closeError) +} diff --git a/internal/tunnel/web_server.go b/internal/tunnel/web_server.go index 568bc52..db25a13 100644 --- a/internal/tunnel/web_server.go +++ b/internal/tunnel/web_server.go @@ -8,14 +8,18 @@ import ( "io" "net" "net/http" + "runtime" "strconv" "sync" + "syscall" "time" "github.com/apernet/quic-go" "github.com/cppla/autocar/internal/transport" ) +const webListenPortAttempts = 8 + // WebServerConfig configures one public web origin on a shared numeric port: // HTTPS over TCP (HTTP/2 and HTTP/1.1) and HTTP/3 over UDP. TCPAddress is // required unless UDPAddress is set, in which case it is also used for TCP. @@ -70,8 +74,17 @@ type WebServer struct { // ListenWeb binds both sides of the web-cover origin. TCP is deliberately // bound before UDP so a :0 configuration can use exactly the same numeric port -// for both protocols. +// for both protocols. If that ephemeral port is already occupied by UDP, it +// closes TCP and tries another pair, with a bounded number of attempts. Fixed +// ports and errors other than UDP address-in-use are never retried. func ListenWeb(config WebServerConfig) (*WebServer, error) { + return listenWebWith(config, listenWebH2WithCore, listenWebH3WithCore) +} + +func listenWebWith(config WebServerConfig, + listenH2 func(WebH2ServerConfig, *serverCore, *webAuthVerifier) (*WebH2Server, error), + listenH3 func(WebH3ServerConfig, *serverCore, *webAuthVerifier) (*WebH3Server, error), +) (*WebServer, error) { var err error config.TCPAddress, config.UDPAddress, err = normalizeWebListenAddresses(config.TCPAddress, config.UDPAddress) if err != nil { @@ -114,65 +127,105 @@ func ListenWeb(config WebServerConfig) (*WebServer, error) { // last moment, so a cover origin cannot accidentally advertise a stale or // unrelated alternative service. publicCover := &webAltSvcCover{next: config.Cover} - h2, err := listenWebH2WithCore(WebH2ServerConfig{ - Address: config.TCPAddress, - Token: config.Token, - TLSConfig: config.TLSConfig, - Cover: publicCover, - Dialer: config.Dialer, - HandshakeTimeout: config.HandshakeTimeout, - DialTimeout: config.DialTimeout, - DestinationWriteTimeout: config.DestinationWriteTimeout, - MaxConcurrentStreams: config.MaxConcurrentStreams, - StreamAdmission: config.StreamAdmission, - ReplayEntries: config.ReplayEntries, - MaxConnections: config.MaxConnections, - MaxClientConnections: config.MaxClientConnections, - MaxHeaderBytes: config.MaxHeaderBytes, - connectionAdmission: connectionAdmission, - }, core, verifier) - if err != nil { - return nil, err + attempts := 1 + // Use the normalized TCP port: :0 plus a fixed UDP port is a fixed-port + // request, not permission to choose another public endpoint. + if webEphemeralListenAddress(config.TCPAddress) { + attempts = webListenPortAttempts + } + for attempt := 1; ; attempt++ { + h2, err := listenH2(WebH2ServerConfig{ + Address: config.TCPAddress, + Token: config.Token, + TLSConfig: config.TLSConfig, + Cover: publicCover, + Dialer: config.Dialer, + HandshakeTimeout: config.HandshakeTimeout, + DialTimeout: config.DialTimeout, + DestinationWriteTimeout: config.DestinationWriteTimeout, + MaxConcurrentStreams: config.MaxConcurrentStreams, + StreamAdmission: config.StreamAdmission, + ReplayEntries: config.ReplayEntries, + MaxConnections: config.MaxConnections, + MaxClientConnections: config.MaxClientConnections, + MaxHeaderBytes: config.MaxHeaderBytes, + connectionAdmission: connectionAdmission, + }, core, verifier) + if err != nil { + return nil, err + } + + udpAddress, err := webUDPAddressForTCP(config.UDPAddress, h2.Addr()) + if err != nil { + _ = h2.Close() + return nil, err + } + h3, err := listenH3(WebH3ServerConfig{ + Address: udpAddress, + Token: config.Token, + TLSConfig: config.TLSConfig, + QUICConfig: config.QUICConfig, + Dialer: config.Dialer, + Cover: publicCover, + HandshakeTimeout: config.HandshakeTimeout, + DialTimeout: config.DialTimeout, + DestinationWriteTimeout: config.DestinationWriteTimeout, + MaxConcurrentStreams: config.MaxConcurrentStreams, + StreamAdmission: config.StreamAdmission, + MaxHeaderBytes: config.MaxHeaderBytes, + ReplayEntries: config.ReplayEntries, + MaxConnections: config.MaxConnections, + MaxClientConnections: config.MaxClientConnections, + connectionAdmission: connectionAdmission, + UDPResolver: config.UDPResolver, + MaxUDPSessions: config.MaxUDPSessions, + MaxClientUDPSessions: config.MaxClientUDPSessions, + MaxUDPDestinations: config.MaxUDPDestinations, + UDPReceiveQueue: config.UDPReceiveQueue, + }, core, verifier) + if err != nil { + closeErr := normalizeWebServerCloseError(h2.Close()) + if closeErr != nil { + return nil, errors.Join(err, fmt.Errorf("tunnel: close web-cover TCP listener after UDP bind failure: %w", closeErr)) + } + if attempt < attempts && webUDPListenAddressInUse(err) { + continue + } + return nil, err + } + + publicCover.value = webH3AltSvcValue(h3.Addr()) + return &WebServer{h2: h2, h3: h3}, nil } +} - udpAddress, err := webUDPAddressForTCP(config.UDPAddress, h2.Addr()) +func webEphemeralListenAddress(address string) bool { + _, portText, err := net.SplitHostPort(address) if err != nil { - _ = h2.Close() - return nil, err + return false } - h3, err := listenWebH3WithCore(WebH3ServerConfig{ - Address: udpAddress, - Token: config.Token, - TLSConfig: config.TLSConfig, - QUICConfig: config.QUICConfig, - Dialer: config.Dialer, - Cover: publicCover, - HandshakeTimeout: config.HandshakeTimeout, - DialTimeout: config.DialTimeout, - DestinationWriteTimeout: config.DestinationWriteTimeout, - MaxConcurrentStreams: config.MaxConcurrentStreams, - StreamAdmission: config.StreamAdmission, - MaxHeaderBytes: config.MaxHeaderBytes, - ReplayEntries: config.ReplayEntries, - MaxConnections: config.MaxConnections, - MaxClientConnections: config.MaxClientConnections, - connectionAdmission: connectionAdmission, - UDPResolver: config.UDPResolver, - MaxUDPSessions: config.MaxUDPSessions, - MaxClientUDPSessions: config.MaxClientUDPSessions, - MaxUDPDestinations: config.MaxUDPDestinations, - UDPReceiveQueue: config.UDPReceiveQueue, - }, core, verifier) - if err != nil { - closeErr := normalizeWebServerCloseError(h2.Close()) - if closeErr != nil { - return nil, errors.Join(err, fmt.Errorf("tunnel: close web-cover TCP listener after UDP bind failure: %w", closeErr)) + port, err := net.LookupPort("tcp", portText) + return err == nil && port == 0 +} + +func webUDPListenAddressInUse(err error) bool { + // Do not conceal a second failure joined to an otherwise retryable bind + // error. Only an unambiguous UDP listen failure is eligible. + for current := err; current != nil; current = errors.Unwrap(current) { + if _, joined := current.(interface{ Unwrap() []error }); joined { + return false } - return nil, err } - - publicCover.value = webH3AltSvcValue(h3.Addr()) - return &WebServer{h2: h2, h3: h3}, nil + var opErr *net.OpError + if !errors.As(err, &opErr) || opErr.Op != "listen" || opErr.Net != "udp" { + return false + } + if runtime.GOOS == "windows" { + // Windows bind returns WSAEADDRINUSE, not the synthetic POSIX errno. + const winsockAddressInUse syscall.Errno = 10048 + return errors.Is(opErr.Err, winsockAddressInUse) + } + return errors.Is(opErr.Err, syscall.EADDRINUSE) } func normalizeWebListenAddresses(tcpAddress, udpAddress string) (string, string, error) { @@ -402,21 +455,48 @@ func (w *webAltSvcResponseWriter) ReadFrom(r io.Reader) (int64, error) { func (w *webAltSvcResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } func normalizeWebServerCloseError(err error) error { + normalized, _ := normalizeWebServerCloseErrorTree(err) + return normalized +} + +// The bool reports an actual rewrite without comparing arbitrary error +// values, whose concrete types need not be comparable. Do not use errors.Is: +// finding one benign leaf (or a custom Is match) does not make a tree benign. +func normalizeWebServerCloseErrorTree(err error) (error, bool) { if err == nil { - return nil + return nil, false + } + if err == net.ErrClosed || err == http.ErrServerClosed { + return nil, true } if joined, ok := err.(interface{ Unwrap() []error }); ok { parts := joined.Unwrap() normalized := make([]error, 0, len(parts)) + changed := false for _, part := range parts { - if part = normalizeWebServerCloseError(part); part != nil { + var rewritten bool + part, rewritten = normalizeWebServerCloseErrorTree(part) + changed = changed || rewritten + if part != nil { normalized = append(normalized, part) } } - return errors.Join(normalized...) + if !changed { + // In particular, an unknown multi-error with no actual children + // is not evidence of successful cleanup. + return err, false + } + return errors.Join(normalized...), true } - if errors.Is(err, net.ErrClosed) || errors.Is(err, http.ErrServerClosed) { - return nil + if wrapped, ok := err.(interface{ Unwrap() error }); ok { + if inner := wrapped.Unwrap(); inner != nil { + if normalized, _ := normalizeWebServerCloseErrorTree(inner); normalized == nil { + return nil, true + } + } } - return err + // A wrapper around a mixed tree cannot be reconstructed generically. Keep + // its identity and diagnostics, including any benign siblings, rather than + // hiding the meaningful cleanup failure. + return err, false }