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
6 changes: 6 additions & 0 deletions docs/api/client-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
20 changes: 9 additions & 11 deletions docs/api/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
|----------|------------------------------|----------------------------|
Expand All @@ -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
};
});
```
Expand Down
10 changes: 10 additions & 0 deletions docs/client/http3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
17 changes: 7 additions & 10 deletions docs/server/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,16 @@ namespace TurboHTTP.Client
}
public class TurboRequestOptions : System.IEquatable<TurboHTTP.Client.TurboRequestOptions>
{
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
Expand Down Expand Up @@ -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
{
Expand Down
202 changes: 202 additions & 0 deletions src/TurboHTTP.IntegrationTests.End2End/H11/ConnectTunnelSpec.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>An <see cref="IWebProxy"/> that always routes to a fixed proxy and never bypasses.</summary>
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<byte[]> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading