From 4667f8a1eb269d069e83baef1e5169a97213818b Mon Sep 17 00:00:00 2001 From: st0o0 <64534642+st0o0@users.noreply.github.com> Date: Thu, 11 Jun 2026 17:28:54 +0200 Subject: [PATCH] feat(client): add CONNECT support --- docs/api/client-options.md | 6 + docs/api/server.md | 20 +- docs/client/http3.md | 10 + docs/server/configuration.md | 17 +- ...oreAPISpec.ApproveCore.DotNet.verified.txt | 14 +- .../H11/ConnectTunnelSpec.cs | 202 ++++++++++++++++++ .../Semantics/AltSvc/AltSvcBidiStageSpec.cs | 42 ++++ .../Options/TransportBufferOptionsSpec.cs | 111 ++++++++++ .../Stages/Client/RequestEnricherProxySpec.cs | 106 +++++++++ src/TurboHTTP/Client/TurboClientOptions.cs | 6 +- src/TurboHTTP/Client/TurboHttpClient.cs | 8 +- .../Client/TurboHttpClientFactory.cs | 8 +- src/TurboHTTP/Server/EndpointResolver.cs | 4 +- .../Server/TransportBufferOptions.cs | 95 +++++--- .../Streams/FeaturePipelineBuilder.cs | 2 +- src/TurboHTTP/Streams/PipelineDescriptor.cs | 4 +- .../Streams/Stages/Client/RequestEnricher.cs | 22 ++ .../Stages/Features/AltSvcBidiStage.cs | 12 +- 18 files changed, 624 insertions(+), 65 deletions(-) create mode 100644 src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs create mode 100644 src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs create mode 100644 src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs diff --git a/docs/api/client-options.md b/docs/api/client-options.md index 6375113b3..e69b9132c 100644 --- a/docs/api/client-options.md +++ b/docs/api/client-options.md @@ -216,6 +216,12 @@ options.ClientCertificates = new X509CertificateCollection | `Proxy` | `null` | Custom proxy URI | | `DefaultProxyCredentials` | `null` | Credentials for proxy authentication | +Plain HTTP requests are relayed through the proxy directly; HTTPS requests are tunneled via `CONNECT` (with `Proxy-Authorization: Basic` when credentials are configured), and TLS is negotiated with the target through the tunnel. + +::: info HTTP/3 and proxies +QUIC cannot traverse an HTTP proxy. When a proxy applies to a request, HTTP/3 requests are downgraded to HTTP/2 (when the `VersionPolicy` is `RequestVersionOrLower`) or fail with `HttpRequestException` (`RequestVersionExact` / `RequestVersionOrHigher`). Alt-Svc HTTP/3 upgrades are also skipped for proxied hosts. Hosts matched by the proxy's bypass list are unaffected. +::: + ## Authentication Options | Property | Default | Description | diff --git a/docs/api/server.md b/docs/api/server.md index 21bda9aa4..c2219f067 100644 --- a/docs/api/server.md +++ b/docs/api/server.md @@ -139,20 +139,20 @@ public sealed class TurboListenOptions(IPAddress address, ushort port) ## Transport Buffer Options -Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `TurboListenOptions.Transport`; leaving it `null` uses protocol-optimized defaults. Assigning an instance replaces the protocol defaults entirely (no per-property fallback), so set `InputPauseThreshold` and `InputResumeThreshold` explicitly — they have no initializer. +Controls backpressure thresholds on the read/write pipes between the OS socket and the HTTP pipeline. Applied per-connection for TCP and per-stream for QUIC. Set via `TurboListenOptions.Transport`. Every property is nullable — properties left at `null` fall back to the protocol-optimized default individually, so you only need to set the thresholds you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`. ```csharp public sealed class TransportBufferOptions { - long InputPauseThreshold { get; set; } // bytes buffered on the read pipe before the OS socket is paused - long InputResumeThreshold { get; set; } // buffered byte count at which reading resumes (must be < pause threshold) - long OutputPauseThreshold { get; set; } // default: 64 * 1024 — bytes buffered on the write pipe before the HTTP pipeline is paused - long OutputResumeThreshold { get; set; } // default: 32 * 1024 - int MinimumSegmentSize { get; set; } // minimum pipe buffer segment size + long? InputPauseThreshold { get; set; } // bytes buffered on the read pipe before the OS socket is paused + long? InputResumeThreshold { get; set; } // buffered byte count at which reading resumes (must be <= pause threshold) + long? OutputPauseThreshold { get; set; } // bytes buffered on the write pipe before the HTTP pipeline is paused + long? OutputResumeThreshold { get; set; } // must be <= OutputPauseThreshold + int? MinimumSegmentSize { get; set; } // minimum pipe buffer segment size } ``` -Protocol-specific defaults when `Transport` is `null`: +Protocol-specific defaults applied for `null` properties (and when `Transport` itself is `null`): | Property | TCP (one pipe per connection) | QUIC (one pipe per stream) | |----------|------------------------------|----------------------------| @@ -165,13 +165,11 @@ Protocol-specific defaults when `Transport` is `null`: ```csharp options.Listen(IPAddress.Any, 8080, listen => { + // Only the input thresholds are overridden; everything else keeps the TCP defaults listen.Transport = new TransportBufferOptions { InputPauseThreshold = 2 * 1024 * 1024, - InputResumeThreshold = 1024 * 1024, - OutputPauseThreshold = 64 * 1024, - OutputResumeThreshold = 32 * 1024, - MinimumSegmentSize = 16 * 1024 + InputResumeThreshold = 1024 * 1024 }; }); ``` diff --git a/docs/client/http3.md b/docs/client/http3.md index ab716ec04..91e857f32 100644 --- a/docs/client/http3.md +++ b/docs/client/http3.md @@ -74,6 +74,16 @@ options.Http3.EnableAltSvcDiscovery = true; // default: false This is opt-in because not all environments support QUIC (firewalls may block UDP). Enable it when you know your network path supports QUIC and want automatic protocol upgrade. +## HTTP/3 and Forward Proxies + +QUIC cannot traverse an HTTP forward proxy (`CONNECT` tunnels carry TCP, not UDP). When a proxy is configured and applies to a request: + +- HTTP/3 requests with `HttpVersionPolicy.RequestVersionOrLower` (the default) are transparently downgraded to HTTP/2 and tunneled via `CONNECT`. +- HTTP/3 requests with `RequestVersionExact` or `RequestVersionOrHigher` fail with `HttpRequestException`. +- Alt-Svc HTTP/3 upgrades are skipped for proxied hosts. + +Hosts matched by the proxy's bypass list keep using HTTP/3 directly. + ## QPACK Header Compression HTTP/3 uses QPACK for header compression (the QUIC equivalent of HPACK in HTTP/2). TurboHTTP manages QPACK encoding and decoding automatically. Tune the dynamic table size if needed: diff --git a/docs/server/configuration.md b/docs/server/configuration.md index c87559f03..3524abe36 100644 --- a/docs/server/configuration.md +++ b/docs/server/configuration.md @@ -109,19 +109,20 @@ Access via `options.Http3`. ## Transport Buffers -Per-endpoint backpressure thresholds for the pipes between the OS socket and the HTTP pipeline. Set via `TurboListenOptions.Transport`; when left `null`, protocol-optimized defaults apply (TCP buffers one pipe per connection, QUIC one pipe per stream). +Per-endpoint backpressure thresholds for the pipes between the OS socket and the HTTP pipeline. Set via `TurboListenOptions.Transport`. All properties are nullable; each `null` property falls back to its protocol-optimized default individually (TCP buffers one pipe per connection, QUIC one pipe per stream), so you only set what you want to change. A resume threshold above its pause threshold fails endpoint resolution with `InvalidOperationException`. | Property | Type | TCP Default | QUIC Default | Description | |----------|------|-------------|--------------|-------------| -| `InputPauseThreshold` | `long` | 1 MiB | 64 KiB | Bytes buffered on the read pipe before the OS socket is paused | -| `InputResumeThreshold` | `long` | 512 KiB | 32 KiB | Buffered byte count at which reading resumes | -| `OutputPauseThreshold` | `long` | 64 KiB | 64 KiB | Bytes buffered on the write pipe before the HTTP pipeline is paused | -| `OutputResumeThreshold` | `long` | 32 KiB | 32 KiB | Buffered byte count at which writing resumes | -| `MinimumSegmentSize` | `int` | 16 KiB | 4 KiB | Minimum pipe buffer segment size | +| `InputPauseThreshold` | `long?` | 1 MiB | 64 KiB | Bytes buffered on the read pipe before the OS socket is paused | +| `InputResumeThreshold` | `long?` | 512 KiB | 32 KiB | Buffered byte count at which reading resumes | +| `OutputPauseThreshold` | `long?` | 64 KiB | 64 KiB | Bytes buffered on the write pipe before the HTTP pipeline is paused | +| `OutputResumeThreshold` | `long?` | 32 KiB | 32 KiB | Buffered byte count at which writing resumes | +| `MinimumSegmentSize` | `int?` | 16 KiB | 4 KiB | Minimum pipe buffer segment size | ```csharp options.Listen(IPAddress.Any, 8080, listen => { + // Only the input thresholds are overridden; everything else keeps the TCP defaults listen.Transport = new TransportBufferOptions { InputPauseThreshold = 2 * 1024 * 1024, @@ -130,10 +131,6 @@ options.Listen(IPAddress.Any, 8080, listen => }); ``` -::: warning -Assigning `Transport` replaces the protocol defaults entirely — there is no per-property fallback. `InputPauseThreshold` and `InputResumeThreshold` have no initializer, so always set both explicitly; the output thresholds and `MinimumSegmentSize` fall back to the class initializers (64 KiB / 32 KiB / 16 KiB) if omitted. -::: - See the [Server API reference](/api/server#transport-buffer-options) for details. ## Example: Full Configuration diff --git a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt index 4a7c98320..87cf548a5 100644 --- a/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt +++ b/src/TurboHTTP.API.Tests/verify/CoreAPISpec.ApproveCore.DotNet.verified.txt @@ -194,14 +194,16 @@ namespace TurboHTTP.Client } public class TurboRequestOptions : System.IEquatable { - public TurboRequestOptions(System.Uri? BaseAddress, System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders, System.Version DefaultRequestVersion, System.Net.Http.HttpVersionPolicy DefaultVersionPolicy, System.TimeSpan Timeout, System.Net.ICredentials? Credentials = null, bool PreAuthenticate = false) { } + public TurboRequestOptions(System.Uri? BaseAddress, System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders, System.Version DefaultRequestVersion, System.Net.Http.HttpVersionPolicy DefaultVersionPolicy, System.TimeSpan Timeout, System.Net.ICredentials? Credentials = null, bool PreAuthenticate = false, bool UseProxy = true, System.Net.IWebProxy? Proxy = null) { } public System.Uri? BaseAddress { get; init; } public System.Net.ICredentials? Credentials { get; init; } public System.Net.Http.Headers.HttpRequestHeaders DefaultRequestHeaders { get; init; } public System.Version DefaultRequestVersion { get; init; } public System.Net.Http.HttpVersionPolicy DefaultVersionPolicy { get; init; } public bool PreAuthenticate { get; init; } + public System.Net.IWebProxy? Proxy { get; init; } public System.TimeSpan Timeout { get; init; } + public bool UseProxy { get; init; } } } namespace TurboHTTP.Diagnostics @@ -434,11 +436,11 @@ namespace TurboHTTP.Server public sealed class TransportBufferOptions { public TransportBufferOptions() { } - public long InputPauseThreshold { get; set; } - public long InputResumeThreshold { get; set; } - public int MinimumSegmentSize { get; set; } - public long OutputPauseThreshold { get; set; } - public long OutputResumeThreshold { get; set; } + public long? InputPauseThreshold { get; set; } + public long? InputResumeThreshold { get; set; } + public int? MinimumSegmentSize { get; set; } + public long? OutputPauseThreshold { get; set; } + public long? OutputResumeThreshold { get; set; } } public sealed class TurboHttpsOptions { diff --git a/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs new file mode 100644 index 000000000..e79bea691 --- /dev/null +++ b/src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs @@ -0,0 +1,202 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using TurboHTTP.Client; +using TurboHTTP.IntegrationTests.End2End.Shared; +using TurboHTTP.Server; + +namespace TurboHTTP.IntegrationTests.End2End.H11; + +/// +/// Verifies HTTPS requests tunnel through a forward proxy via CONNECT: an in-process +/// CONNECT proxy terminates the handshake, relays the TLS bytes to the real TurboServer, +/// and records the CONNECT request line and headers for assertions. +/// +[Collection("H11")] +public sealed class ConnectTunnelSpec : End2EndSpecBase +{ + private ConnectProxy? _proxy; + + protected override Version ProtocolVersion => HttpVersion.Version11; + + protected override bool UseTls => true; + + protected override void ConfigureServer( + TurboServerOptions options, ushort port, System.Security.Cryptography.X509Certificates.X509Certificate2? cert) + { + // The base binds HTTP/1.1 without TLS; a CONNECT tunnel only makes sense for HTTPS. + options.ListenLocalhost(port, listen => + { + listen.UseHttps(cert!); + listen.Protocols = HttpProtocols.Http1; + }); + } + + protected override void ConfigureClientOptions(TurboClientOptions options) + { + _proxy = new ConnectProxy(); + _proxy.Start(); + + options.UseProxy = true; + options.Proxy = new FixedProxy(new Uri($"http://127.0.0.1:{_proxy.Port}")); + options.DefaultProxyCredentials = new NetworkCredential("tunnel-user", "tunnel-pass"); + } + + protected override void ConfigureEndpoints(WebApplication app) + { + app.MapGet("/tunneled", () => Results.Text("through-connect-tunnel")); + } + + public override async ValueTask DisposeAsync() + { + _proxy?.Dispose(); + await base.DisposeAsync(); + } + + [Fact(Timeout = 15000)] + public async Task Https_request_should_tunnel_through_connect_proxy() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/tunneled"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadAsStringAsync(CancellationToken); + Assert.Equal("through-connect-tunnel", body); + + Assert.True(_proxy!.ConnectCount >= 1, "Request did not tunnel through the CONNECT proxy"); + var server = new Uri(BaseUri); + Assert.Contains($"CONNECT {server.Host}:{server.Port} HTTP/1.1", _proxy.LastConnectRequest); + } + + [Fact(Timeout = 15000)] + public async Task Connect_request_should_carry_proxy_authorization() + { + var request = new HttpRequestMessage(HttpMethod.Get, $"{BaseUri}/tunneled"); + var response = await Client.SendAsync(request, CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var expected = Convert.ToBase64String(Encoding.UTF8.GetBytes("tunnel-user:tunnel-pass")); + Assert.Contains($"Proxy-Authorization: Basic {expected}", _proxy!.LastConnectRequest); + } + + /// An that always routes to a fixed proxy and never bypasses. + private sealed class FixedProxy(Uri proxy) : IWebProxy + { + public ICredentials? Credentials { get; set; } + public Uri GetProxy(Uri destination) => proxy; + public bool IsBypassed(Uri host) => false; + } + + private sealed class ConnectProxy : IDisposable + { + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private int _connectCount; + private volatile string _lastConnectRequest = string.Empty; + + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + public int ConnectCount => Volatile.Read(ref _connectCount); + public string LastConnectRequest => _lastConnectRequest; + + public void Start() + { + _listener.Start(); + _ = AcceptLoop(); + } + + private async Task AcceptLoop() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token); + _ = TunnelAsync(client); + } + } + catch (OperationCanceledException) + { + } + catch (ObjectDisposedException) + { + } + } + + private async Task TunnelAsync(TcpClient downstream) + { + try + { + using (downstream) + { + var ds = downstream.GetStream(); + + var headerBytes = await ReadConnectRequestAsync(ds); + var headerText = Encoding.ASCII.GetString(headerBytes); + _lastConnectRequest = headerText; + + var requestLine = headerText[..headerText.IndexOf('\r')].Split(' '); + if (requestLine.Length < 2 || requestLine[0] != "CONNECT") + { + await WriteAsciiAsync(ds, "HTTP/1.1 405 Method Not Allowed\r\n\r\n"); + return; + } + + Interlocked.Increment(ref _connectCount); + + var target = requestLine[1].Split(':'); + using var upstream = new TcpClient(); + await upstream.ConnectAsync(target[0], int.Parse(target[1]), _cts.Token); + + await WriteAsciiAsync(ds, "HTTP/1.1 200 Connection Established\r\n\r\n"); + + await using var us = upstream.GetStream(); + var toUpstream = ds.CopyToAsync(us, _cts.Token); + var toDownstream = us.CopyToAsync(ds, _cts.Token); + await Task.WhenAny(toUpstream, toDownstream); + } + } + catch + { + // Best-effort tunnel; the connection is torn down with the test. + } + } + + private async Task ReadConnectRequestAsync(NetworkStream stream) + { + var buffer = new byte[8 * 1024]; + var total = 0; + while (total < buffer.Length) + { + var read = await stream.ReadAsync(buffer.AsMemory(total, buffer.Length - total), _cts.Token); + if (read == 0) + { + break; + } + + total += read; + if (buffer.AsSpan(0, total).IndexOf("\r\n\r\n"u8) >= 0) + { + break; + } + } + + return buffer[..total]; + } + + private async Task WriteAsciiAsync(NetworkStream stream, string text) + { + await stream.WriteAsync(Encoding.ASCII.GetBytes(text), _cts.Token); + await stream.FlushAsync(_cts.Token); + } + + public void Dispose() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + } + } +} diff --git a/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs b/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs index 13e994b13..b80bdfd59 100644 --- a/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs +++ b/src/TurboHTTP.Tests/Protocol/Semantics/AltSvc/AltSvcBidiStageSpec.cs @@ -220,6 +220,48 @@ public async Task AltSvcBidiStage_should_not_upgrade_if_already_http3() Assert.Equal(HttpVersion.Version30, result.Version); } + [Trait("RFC", "RFC7838")] + [Fact(Timeout = 5000)] + public async Task AltSvcBidiStage_should_not_upgrade_when_proxy_applies() + { + var cache = new AltSvcCache(); + cache.Store("example.com", [new AltSvcEntry("h3", "", 443, 86400, false, DateTimeOffset.UtcNow.AddHours(1))]); + + var stage = new AltSvcBidiStage(cache, useProxy: true, proxy: new WebProxy("http://proxy.local:8080")); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + { + Version = HttpVersion.Version11 + }; + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.Equal(HttpVersion.Version11, result.Version); + } + + [Trait("RFC", "RFC7838")] + [Fact(Timeout = 5000)] + public async Task AltSvcBidiStage_should_upgrade_when_proxy_bypasses_host() + { + var cache = new AltSvcCache(); + cache.Store("example.com", [new AltSvcEntry("h3", "", 443, 86400, false, DateTimeOffset.UtcNow.AddHours(1))]); + + var proxy = new WebProxy("http://proxy.local:8080") + { + BypassList = [@"example\.com"] + }; + var stage = new AltSvcBidiStage(cache, useProxy: true, proxy: proxy); + var request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/") + { + Version = HttpVersion.Version11 + }; + + var results = await RunRequestAsync(stage, request); + + var result = Assert.Single(results); + Assert.Equal(HttpVersion.Version30, result.Version); + } + [Trait("RFC", "RFC7838")] [Fact(Timeout = 5000)] public async Task AltSvcBidiStage_should_handle_multiple_alt_svc_values() diff --git a/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs b/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs new file mode 100644 index 000000000..3b535b9ac --- /dev/null +++ b/src/TurboHTTP.Tests/Server/Options/TransportBufferOptionsSpec.cs @@ -0,0 +1,111 @@ +using System.Net; +using System.Security.Cryptography.X509Certificates; +using Servus.Akka.Transport; +using TurboHTTP.Server; + +namespace TurboHTTP.Tests.Server.Options; + +public sealed class TransportBufferOptionsSpec +{ + [Fact(Timeout = 5000)] + public void Tcp_partial_transport_override_should_fall_back_to_tcp_defaults_per_property() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5000, listen => + { + listen.Transport = new TransportBufferOptions + { + OutputPauseThreshold = 128 * 1024 + }; + }); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var tcp = Assert.IsType(binding.Options); + + Assert.Equal(128 * 1024, tcp.OutputPauseThreshold); + Assert.Equal(1024 * 1024, tcp.InputPauseThreshold); + Assert.Equal(512 * 1024, tcp.InputResumeThreshold); + Assert.Equal(32 * 1024, tcp.OutputResumeThreshold); + Assert.Equal(16 * 1024, tcp.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Quic_partial_transport_override_should_fall_back_to_quic_defaults_per_property() + { + using var cert = CreateSelfSignedCert(); + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5001, listen => + { + listen.Protocols = HttpProtocols.Http3; + listen.UseHttps(cert); + listen.Transport = new TransportBufferOptions + { + InputPauseThreshold = 256 * 1024 + }; + }); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var quic = Assert.IsType(binding.Options); + + Assert.Equal(256 * 1024, quic.InputPauseThreshold); + Assert.Equal(32 * 1024, quic.InputResumeThreshold); + Assert.Equal(64 * 1024, quic.OutputPauseThreshold); + Assert.Equal(32 * 1024, quic.OutputResumeThreshold); + Assert.Equal(4 * 1024, quic.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Null_transport_should_use_tcp_defaults() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5002); + + var binding = Assert.Single(new EndpointResolver().Resolve(options)); + var tcp = Assert.IsType(binding.Options); + + Assert.Equal(1024 * 1024, tcp.InputPauseThreshold); + Assert.Equal(512 * 1024, tcp.InputResumeThreshold); + Assert.Equal(64 * 1024, tcp.OutputPauseThreshold); + Assert.Equal(32 * 1024, tcp.OutputResumeThreshold); + Assert.Equal(16 * 1024, tcp.MinimumSegmentSize); + } + + [Fact(Timeout = 5000)] + public void Resolved_input_resume_above_pause_should_throw() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5003, listen => + { + listen.Transport = new TransportBufferOptions + { + InputResumeThreshold = 2 * 1024 * 1024 + }; + }); + + Assert.Throws(() => new EndpointResolver().Resolve(options)); + } + + [Fact(Timeout = 5000)] + public void Resolved_output_resume_above_pause_should_throw() + { + var options = new TurboServerOptions(); + options.Listen(IPAddress.Loopback, 5004, listen => + { + listen.Transport = new TransportBufferOptions + { + OutputResumeThreshold = 128 * 1024 + }; + }); + + Assert.Throws(() => new EndpointResolver().Resolve(options)); + } + + private static X509Certificate2 CreateSelfSignedCert() + { + using var rsa = System.Security.Cryptography.RSA.Create(2048); + var request = new CertificateRequest("CN=Test", rsa, + System.Security.Cryptography.HashAlgorithmName.SHA256, + System.Security.Cryptography.RSASignaturePadding.Pkcs1); + return request.CreateSelfSigned(DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddYears(1)); + } +} diff --git a/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs new file mode 100644 index 000000000..225c1d226 --- /dev/null +++ b/src/TurboHTTP.Tests/Streams/Stages/Client/RequestEnricherProxySpec.cs @@ -0,0 +1,106 @@ +using System.Net; +using TurboHTTP.Client; +using TurboHTTP.Streams.Stages.Client; + +namespace TurboHTTP.Tests.Streams.Stages.Client; + +public sealed class RequestEnricherProxySpec +{ + private static TurboRequestOptions Options(bool useProxy = true, IWebProxy? proxy = null) + { + return new TurboRequestOptions( + BaseAddress: null, + DefaultRequestHeaders: new HttpRequestMessage().Headers, + DefaultRequestVersion: HttpVersion.Version11, + DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower, + Timeout: TimeSpan.FromSeconds(30), + UseProxy: useProxy, + Proxy: proxy); + } + + private static HttpRequestMessage Http3Request(HttpVersionPolicy policy = HttpVersionPolicy.RequestVersionOrLower) + { + return new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource") + { + Version = HttpVersion.Version30, + VersionPolicy = policy + }; + } + + [Fact(Timeout = 5000)] + public void Enrich_should_downgrade_http3_to_http2_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version20, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_throw_for_http3_exact_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + Assert.Throws( + () => enricher.Enrich(Http3Request(HttpVersionPolicy.RequestVersionExact))); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_throw_for_http3_or_higher_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + + Assert.Throws( + () => enricher.Enrich(Http3Request(HttpVersionPolicy.RequestVersionOrHigher))); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_no_proxy_configured() + { + var enricher = new RequestEnricher(() => Options(proxy: null)); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_use_proxy_is_false() + { + var enricher = new RequestEnricher( + () => Options(useProxy: false, proxy: new WebProxy("http://proxy.local:8080"))); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_keep_http3_when_proxy_bypasses_host() + { + var proxy = new WebProxy("http://proxy.local:8080") + { + BypassList = [@"example\.com"] + }; + var enricher = new RequestEnricher(() => Options(proxy: proxy)); + + var result = enricher.Enrich(Http3Request()); + + Assert.Equal(HttpVersion.Version30, result.Version); + } + + [Fact(Timeout = 5000)] + public void Enrich_should_not_touch_http2_when_proxy_applies() + { + var enricher = new RequestEnricher(() => Options(proxy: new WebProxy("http://proxy.local:8080"))); + var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/resource") + { + Version = HttpVersion.Version20 + }; + + var result = enricher.Enrich(request); + + Assert.Equal(HttpVersion.Version20, result.Version); + } +} diff --git a/src/TurboHTTP/Client/TurboClientOptions.cs b/src/TurboHTTP/Client/TurboClientOptions.cs index 42ae8b675..3baecf732 100644 --- a/src/TurboHTTP/Client/TurboClientOptions.cs +++ b/src/TurboHTTP/Client/TurboClientOptions.cs @@ -17,6 +17,8 @@ namespace TurboHTTP.Client; /// The per-request timeout applied by . /// Optional credentials for server authentication. /// When , the Authorization header is sent proactively without waiting for a 401. +/// Whether requests are routed through when one is configured. +/// The forward proxy requests are routed through. HTTP/3 requests are downgraded to HTTP/2 when the proxy applies, since QUIC cannot traverse an HTTP proxy. public record TurboRequestOptions( Uri? BaseAddress, HttpRequestHeaders DefaultRequestHeaders, @@ -24,7 +26,9 @@ public record TurboRequestOptions( HttpVersionPolicy DefaultVersionPolicy, TimeSpan Timeout, ICredentials? Credentials = null, - bool PreAuthenticate = false); + bool PreAuthenticate = false, + bool UseProxy = true, + IWebProxy? Proxy = null); /// /// Top-level configuration for a named TurboHTTP client. diff --git a/src/TurboHTTP/Client/TurboHttpClient.cs b/src/TurboHTTP/Client/TurboHttpClient.cs index ba87110d5..bdb52315c 100644 --- a/src/TurboHTTP/Client/TurboHttpClient.cs +++ b/src/TurboHTTP/Client/TurboHttpClient.cs @@ -32,6 +32,8 @@ public sealed class TurboHttpClient : ITurboHttpClient private readonly ICredentials? _credentials; private readonly bool _preAuthenticate; + private readonly bool _useProxy; + private readonly IWebProxy? _proxy; /// public Uri? BaseAddress @@ -99,7 +101,9 @@ private void UpdateCachedOptions() _defaultVersionPolicy, _timeout, _credentials, - _preAuthenticate); + _preAuthenticate, + _useProxy, + _proxy); } internal TurboHttpClient( @@ -114,6 +118,8 @@ internal TurboHttpClient( _timeout = options.Timeout; _credentials = options.Credentials; _preAuthenticate = options.PreAuthenticate; + _useProxy = options.UseProxy; + _proxy = options.Proxy; foreach (var header in options.DefaultRequestHeaders) { _defaultHeadersHolder.Headers.TryAddWithoutValidation(header.Key, header.Value); diff --git a/src/TurboHTTP/Client/TurboHttpClientFactory.cs b/src/TurboHTTP/Client/TurboHttpClientFactory.cs index 7b8b7b746..d561d92d7 100644 --- a/src/TurboHTTP/Client/TurboHttpClientFactory.cs +++ b/src/TurboHTTP/Client/TurboHttpClientFactory.cs @@ -97,7 +97,9 @@ private PipelineDescriptor BuildPipeline(TurboClientOptions clientOptions, Turbo CachePolicy: descriptor.CachePolicy, Handlers: middlewares, AutomaticDecompression: descriptor.AutomaticDecompression, - AltSvcCache: altSvcCache); + AltSvcCache: altSvcCache, + UseProxy: clientOptions.UseProxy, + Proxy: clientOptions.Proxy); } private static TurboRequestOptions CreateRequestOptions(TurboClientOptions clientOptions) @@ -109,7 +111,9 @@ private static TurboRequestOptions CreateRequestOptions(TurboClientOptions clien DefaultVersionPolicy: HttpVersionPolicy.RequestVersionOrLower, Timeout: TimeSpan.FromSeconds(60), Credentials: clientOptions.Credentials, - PreAuthenticate: clientOptions.PreAuthenticate); + PreAuthenticate: clientOptions.PreAuthenticate, + UseProxy: clientOptions.UseProxy, + Proxy: clientOptions.Proxy); } private void ThrowIfDisposed() diff --git a/src/TurboHTTP/Server/EndpointResolver.cs b/src/TurboHTTP/Server/EndpointResolver.cs index 6868d7772..7713aeeaa 100644 --- a/src/TurboHTTP/Server/EndpointResolver.cs +++ b/src/TurboHTTP/Server/EndpointResolver.cs @@ -195,7 +195,7 @@ private static ListenerBinding CreateTcpBinding(TurboListenOptions listen, X509C var alpn = protocols.ToAlpnProtocols(); var httpsOptions = listen.HttpsOptions; - var transport = listen.Transport ?? TransportBufferOptions.TcpDefaults; + var transport = listen.Transport?.ResolveTcp() ?? TransportBufferOptions.TcpDefaults; var tcpOptions = new TcpListenerOptions { Host = listen.Address.ToString(), @@ -224,7 +224,7 @@ private static ListenerBinding CreateTcpBinding(TurboListenOptions listen, X509C private static ListenerBinding CreateQuicBinding(TurboListenOptions listen, X509Certificate2 certificate) { - var transport = listen.Transport ?? TransportBufferOptions.QuicDefaults; + var transport = listen.Transport?.ResolveQuic() ?? TransportBufferOptions.QuicDefaults; var quicOptions = new QuicListenerOptions { Host = listen.Address.ToString(), diff --git a/src/TurboHTTP/Server/TransportBufferOptions.cs b/src/TurboHTTP/Server/TransportBufferOptions.cs index 5d2eacc01..138e50e25 100644 --- a/src/TurboHTTP/Server/TransportBufferOptions.cs +++ b/src/TurboHTTP/Server/TransportBufferOptions.cs @@ -3,58 +3,95 @@ namespace TurboHTTP.Server; /// /// Controls backpressure thresholds on the read/write pipes between the OS socket /// and the HTTP pipeline. These are applied per-connection for TCP and per-stream -/// for QUIC. +/// for QUIC. Properties left at null fall back to the protocol-specific +/// default (TCP buffers one pipe per connection, QUIC one pipe per stream). /// public sealed class TransportBufferOptions { /// /// The number of bytes buffered on the inbound (read) pipe before the writer - /// pauses and signals backpressure to the OS. Default depends on the transport: - /// TCP = 1 MiB (one pipe per connection), QUIC = 64 KiB (one pipe per stream). + /// pauses and signals backpressure to the OS. null uses the transport + /// default: TCP = 1 MiB (one pipe per connection), QUIC = 64 KiB (one pipe per stream). /// - public long InputPauseThreshold { get; set; } + public long? InputPauseThreshold { get; set; } /// /// The buffered byte count at which the inbound pipe resumes accepting data - /// after a pause. Should be less than . - /// Default: TCP = 512 KiB, QUIC = 32 KiB. + /// after a pause. Must be less than or equal to . + /// null uses the transport default: TCP = 512 KiB, QUIC = 32 KiB. /// - public long InputResumeThreshold { get; set; } + public long? InputResumeThreshold { get; set; } /// /// The number of bytes buffered on the outbound (write) pipe before the writer - /// pauses and signals backpressure to the HTTP pipeline. Default: 64 KiB. + /// pauses and signals backpressure to the HTTP pipeline. + /// null uses the transport default of 64 KiB. /// - public long OutputPauseThreshold { get; set; } = 64 * 1024; + public long? OutputPauseThreshold { get; set; } /// /// The buffered byte count at which the outbound pipe resumes after a pause. - /// Default: 32 KiB. + /// Must be less than or equal to . + /// null uses the transport default of 32 KiB. /// - public long OutputResumeThreshold { get; set; } = 32 * 1024; + public long? OutputResumeThreshold { get; set; } /// /// The minimum size of each buffer segment allocated by the pipe's memory pool. /// Larger values reduce segment count but increase per-pipe memory. - /// Default: TCP = 16 KiB, QUIC = 4 KiB (one pipe per stream). + /// null uses the transport default: TCP = 16 KiB, QUIC = 4 KiB (one pipe per stream). /// - public int MinimumSegmentSize { get; set; } = 16 * 1024; + public int? MinimumSegmentSize { get; set; } - internal static TransportBufferOptions TcpDefaults => new() - { - InputPauseThreshold = 1024 * 1024, - InputResumeThreshold = 512 * 1024, - OutputPauseThreshold = 64 * 1024, - OutputResumeThreshold = 32 * 1024, - MinimumSegmentSize = 16 * 1024 - }; - - internal static TransportBufferOptions QuicDefaults => new() + internal ResolvedTransportBuffers ResolveTcp() => Resolve( + defaultInputPause: 1024 * 1024, + defaultInputResume: 512 * 1024, + defaultMinimumSegmentSize: 16 * 1024); + + internal ResolvedTransportBuffers ResolveQuic() => Resolve( + defaultInputPause: 64 * 1024, + defaultInputResume: 32 * 1024, + defaultMinimumSegmentSize: 4 * 1024); + + internal static ResolvedTransportBuffers TcpDefaults { get; } = new TransportBufferOptions().ResolveTcp(); + + internal static ResolvedTransportBuffers QuicDefaults { get; } = new TransportBufferOptions().ResolveQuic(); + + private ResolvedTransportBuffers Resolve(long defaultInputPause, long defaultInputResume, int defaultMinimumSegmentSize) { - InputPauseThreshold = 64 * 1024, - InputResumeThreshold = 32 * 1024, - OutputPauseThreshold = 64 * 1024, - OutputResumeThreshold = 32 * 1024, - MinimumSegmentSize = 4 * 1024 - }; + var resolved = new ResolvedTransportBuffers( + InputPauseThreshold: InputPauseThreshold ?? defaultInputPause, + InputResumeThreshold: InputResumeThreshold ?? defaultInputResume, + OutputPauseThreshold: OutputPauseThreshold ?? 64 * 1024, + OutputResumeThreshold: OutputResumeThreshold ?? 32 * 1024, + MinimumSegmentSize: MinimumSegmentSize ?? defaultMinimumSegmentSize); + + if (resolved.InputResumeThreshold > resolved.InputPauseThreshold) + { + throw new InvalidOperationException( + string.Concat( + "TransportBufferOptions: InputResumeThreshold (", resolved.InputResumeThreshold.ToString(), + ") must not exceed InputPauseThreshold (", resolved.InputPauseThreshold.ToString(), ").")); + } + + if (resolved.OutputResumeThreshold > resolved.OutputPauseThreshold) + { + throw new InvalidOperationException( + string.Concat( + "TransportBufferOptions: OutputResumeThreshold (", resolved.OutputResumeThreshold.ToString(), + ") must not exceed OutputPauseThreshold (", resolved.OutputPauseThreshold.ToString(), ").")); + } + + return resolved; + } } + +/// +/// Transport buffer thresholds with all defaults applied, ready to project onto listener options. +/// +internal readonly record struct ResolvedTransportBuffers( + long InputPauseThreshold, + long InputResumeThreshold, + long OutputPauseThreshold, + long OutputResumeThreshold, + int MinimumSegmentSize); diff --git a/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs b/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs index 0c766ca9f..7549e58e4 100644 --- a/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs +++ b/src/TurboHTTP/Streams/FeaturePipelineBuilder.cs @@ -45,7 +45,7 @@ internal static Flow Build( // and captures Alt-Svc headers from responses before other features process them. if (descriptor.AltSvcCache is not null) { - layers.Add(new AltSvcBidiStage(descriptor.AltSvcCache)); + layers.Add(new AltSvcBidiStage(descriptor.AltSvcCache, descriptor.UseProxy, descriptor.Proxy)); } if (descriptor.AutomaticDecompression || descriptor.CompressionPolicy is not null) diff --git a/src/TurboHTTP/Streams/PipelineDescriptor.cs b/src/TurboHTTP/Streams/PipelineDescriptor.cs index 4dea91f31..0dc8fc573 100644 --- a/src/TurboHTTP/Streams/PipelineDescriptor.cs +++ b/src/TurboHTTP/Streams/PipelineDescriptor.cs @@ -16,7 +16,9 @@ internal sealed record PipelineDescriptor( CachePolicy? CachePolicy, IReadOnlyList Handlers, bool AutomaticDecompression = true, - AltSvcCache? AltSvcCache = null) + AltSvcCache? AltSvcCache = null, + bool UseProxy = true, + System.Net.IWebProxy? Proxy = null) { public static readonly PipelineDescriptor Empty = new( RedirectPolicy: null, diff --git a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs index 3ba8ad16b..bd10b0f5d 100644 --- a/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs +++ b/src/TurboHTTP/Streams/Stages/Client/RequestEnricher.cs @@ -47,6 +47,21 @@ public HttpRequestMessage Enrich(HttpRequestMessage request) request.VersionPolicy = options.DefaultVersionPolicy; } + // Rule 2c: HTTP/3 cannot traverse an HTTP forward proxy — QUIC would silently bypass it. + // Downgrade to HTTP/2 (TLS + CONNECT tunnel) when the policy allows, otherwise fail. + if (request.Version.Major >= 3 && ProxyApplies(options, request.RequestUri)) + { + if (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower) + { + request.Version = HttpVersion.Version20; + } + else + { + throw new HttpRequestException( + "HTTP/3 cannot be used through an HTTP proxy. Use HttpVersionPolicy.RequestVersionOrLower to allow a downgrade, or bypass the proxy for this host."); + } + } + // Rule 3: Default headers — add those absent from the request foreach (var header in options.DefaultRequestHeaders) { @@ -95,6 +110,13 @@ public HttpRequestMessage Enrich(HttpRequestMessage request) return request; } + internal static bool ProxyApplies(TurboRequestOptions options, Uri? requestUri) + { + return options is { UseProxy: true, Proxy: not null } + && requestUri is not null + && !options.Proxy.IsBypassed(requestUri); + } + /// /// Injects a Basic Authorization header using the supplied credentials. /// Uses with the request URI and "Basic" scheme. diff --git a/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs b/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs index ce09e8909..54b5b7852 100644 --- a/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs +++ b/src/TurboHTTP/Streams/Stages/Features/AltSvcBidiStage.cs @@ -14,11 +14,15 @@ namespace TurboHTTP.Streams.Stages.Features; /// entry and upgrades the request version to 3.0 if found. /// Response direction: parses Alt-Svc headers from HTTP/1.1 and HTTP/2 responses /// and stores them in the cache for future requests. +/// When a forward proxy applies to the request, the HTTP/3 upgrade is skipped — +/// QUIC cannot traverse an HTTP proxy and would silently bypass it. /// internal sealed class AltSvcBidiStage : GraphStage> { private readonly AltSvcCache _cache; + private readonly bool _useProxy; + private readonly IWebProxy? _proxy; private readonly Inlet _inRequest = new("AltSvc.In.Request"); private readonly Outlet _outRequest = new("AltSvc.Out.Request"); @@ -27,9 +31,11 @@ internal sealed class AltSvcBidiStage public override BidiShape Shape { get; } - public AltSvcBidiStage(AltSvcCache cache) + public AltSvcBidiStage(AltSvcCache cache, bool useProxy = false, IWebProxy? proxy = null) { _cache = cache; + _useProxy = useProxy; + _proxy = proxy; Shape = new BidiShape( _inRequest, _outRequest, _inResponse, _outResponse); } @@ -50,6 +56,7 @@ public Logic(AltSvcBidiStage stage) : base(stage.Shape) { if (request.RequestUri is not null && request.Version.Major < 3 + && !ProxyApplies(stage, request.RequestUri) && stage._cache.TryGetHttp3(request.RequestUri.Host, out var entry)) { // Upgrade to HTTP/3. Use the advertised port if different from origin. @@ -93,6 +100,9 @@ public Logic(AltSvcBidiStage stage) : base(stage.Shape) onPull: () => Pull(stage._inRequest), onDownstreamFinish: _ => Cancel(stage._inRequest)); + static bool ProxyApplies(AltSvcBidiStage stage, Uri requestUri) + => stage is { _useProxy: true, _proxy: not null } && !stage._proxy.IsBypassed(requestUri); + // Response direction: parse Alt-Svc headers and update cache. SetHandler(stage._inResponse, onPush: () =>