-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathHttp3Connection.cs
More file actions
404 lines (331 loc) · 14.6 KB
/
Copy pathHttp3Connection.cs
File metadata and controls
404 lines (331 loc) · 14.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
using System.Buffers;
using System.Net.Quic;
using System.Net.Security;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Shiny.Net.HttpServer.Http2.Hpack;
using Shiny.Net.HttpServer.Http3.Qpack;
#pragma warning disable CA1416 // QUIC support is checked at runtime; the listener refuses to bind without it.
namespace Shiny.Net.HttpServer.Http3;
/// <summary>
/// Serves one QUIC connection as HTTP/3.
/// <para>
/// Much of what HTTP/2 had to build is gone: QUIC provides the streams, their ordering and their
/// flow control, and a lost packet on one stream no longer stalls the others. What remains is the
/// control stream handshake, framing on each request stream, and QPACK.
/// </para>
/// </summary>
sealed class Http3Connection(
QuicConnection connection,
Http3Options options,
HttpServerOptions serverOptions,
RequestDelegate pipeline,
IServiceProvider? services,
ILoggerFactory loggerFactory
)
{
readonly ILogger logger = loggerFactory.CreateLogger<Http3Connection>();
readonly QpackDecoder decoder = new(options.MaxFieldSectionSize);
readonly QpackEncoder encoder = new();
long nextConnectionId;
public async Task ProcessAsync(CancellationToken cancellationToken)
{
try
{
// The control stream carries SETTINGS and must be opened before anything is sent that
// depends on them — which, for a server, means before the first response.
await this.SendControlStreamAsync(cancellationToken).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested)
{
var stream = await connection.AcceptInboundStreamAsync(cancellationToken).ConfigureAwait(false);
_ = stream.Type == QuicStreamType.Bidirectional
? Task.Run(() => this.ServeRequestAsync(stream, cancellationToken), CancellationToken.None)
: Task.Run(() => this.ReadUnidirectionalAsync(stream, cancellationToken), CancellationToken.None);
}
}
catch (Exception ex) when (IsExpectedDisconnect(ex))
{
}
catch (Exception ex)
{
this.logger.LogError(ex, "The HTTP/3 connection faulted");
}
finally
{
try
{
await connection.CloseAsync(Http3ErrorCode.NoError, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex) when (IsExpectedDisconnect(ex))
{
}
await connection.DisposeAsync().ConfigureAwait(false);
}
}
async Task SendControlStreamAsync(CancellationToken cancellationToken)
{
var stream = await connection
.OpenOutboundStreamAsync(QuicStreamType.Unidirectional, cancellationToken)
.ConfigureAwait(false);
var buffer = new ArrayBufferWriter<byte>(32);
VariableLengthInteger.Write(buffer, Http3StreamType.Control);
// Capacity zero is what keeps QPACK simple here: the peer may not reference a dynamic
// table, so no field section can ever block on one arriving.
var settings = Http3Frame.BuildSettings(
[
(Http3SettingId.QpackMaxTableCapacity, 0),
(Http3SettingId.QpackBlockedStreams, 0),
(Http3SettingId.MaxFieldSectionSize, options.MaxFieldSectionSize)
]);
Http3Frame.Write(buffer, Http3FrameType.Settings, settings);
await stream.WriteAsync(buffer.WrittenMemory, cancellationToken).ConfigureAwait(false);
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
// Deliberately left open. A control stream that closes is a connection error (RFC 9114
// §6.2.1), so it lives as long as the connection does.
}
/// <summary>
/// Reads a peer-opened unidirectional stream.
/// <para>
/// Its first varint says what it is. The control stream's settings are read and applied; the
/// QPACK encoder and decoder streams are drained and ignored, which is correct while the
/// dynamic table is disabled — a peer that respects our capacity of zero sends nothing on them.
/// </para>
/// </summary>
async Task ReadUnidirectionalAsync(QuicStream stream, CancellationToken cancellationToken)
{
try
{
var type = await VariableLengthInteger.ReadAsync(stream, cancellationToken).ConfigureAwait(false);
if (type is null)
return;
if (type == Http3StreamType.Control)
{
await this.ReadControlStreamAsync(stream, cancellationToken).ConfigureAwait(false);
return;
}
// Unknown stream types are ignored by design — that is the extension mechanism — and
// the QPACK streams have nothing to say to a server that disabled the dynamic table.
var scratch = new byte[1024];
while (await stream.ReadAsync(scratch, cancellationToken).ConfigureAwait(false) > 0)
{
}
}
catch (Exception ex) when (IsExpectedDisconnect(ex))
{
}
catch (Exception ex)
{
this.logger.LogDebug(ex, "A unidirectional stream ended badly");
}
finally
{
await stream.DisposeAsync().ConfigureAwait(false);
}
}
async Task ReadControlStreamAsync(QuicStream stream, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
var pending = new ArrayBufferWriter<byte>(4096);
var sawSettings = false;
while (true)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
break;
pending.Write(buffer.AsSpan(0, read));
while (true)
{
var sequence = new ReadOnlySequence<byte>(pending.WrittenMemory);
if (!Http3Frame.TryReadHeader(sequence, out var header, out var consumed))
break;
var headerLength = (int)sequence.Slice(sequence.Start, consumed).Length;
var total = headerLength + (int)header.Length;
if (pending.WrittenCount < total)
break;
var payload = pending.WrittenSpan.Slice(headerLength, (int)header.Length);
if (header.KnownType == Http3FrameType.Settings)
{
if (sawSettings)
throw new InvalidOperationException("SETTINGS arrived twice on the control stream.");
sawSettings = true;
if (!Http3Frame.TryParseSettings(payload, out var settings))
throw new InvalidOperationException("The SETTINGS payload is malformed.");
this.logger.LogDebug("HTTP/3 peer settings: {Count}", settings.Count);
}
else if (!sawSettings)
{
// SETTINGS must come first (RFC 9114 §6.2.1); anything else before it means the
// rest cannot be interpreted safely.
throw new InvalidOperationException("The control stream began with a frame other than SETTINGS.");
}
// Rebuild the buffer without the frame just handled. Control frames are rare and
// tiny, so the copy is not worth avoiding with a ring buffer.
var rest = pending.WrittenSpan[total..].ToArray();
pending.Clear();
pending.Write(rest);
}
}
}
/// <summary>Serves one request on a bidirectional stream.</summary>
async Task ServeRequestAsync(QuicStream stream, CancellationToken cancellationToken)
{
var id = $"h3-{connection.RemoteEndPoint}-{Interlocked.Increment(ref this.nextConnectionId)}";
try
{
var request = await this.ReadRequestAsync(stream, cancellationToken).ConfigureAwait(false);
if (request is null)
return;
var (fields, body) = request.Value;
var context = new HttpContext();
using var aborted = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
context.RequestAborted = aborted.Token;
this.ApplyConnectionInfo(context, id, stream);
if (!Http3RequestMapper.TryApply(context, fields, body, out var error))
{
await AbortAsync(stream, Http3ErrorCode.MessageError, cancellationToken).ConfigureAwait(false);
this.logger.LogDebug("Rejected an HTTP/3 request: {Error}", error);
return;
}
var output = new Http3ResponseBodyControl(stream, context.Response, this.encoder);
context.Response.Bind(output);
await this.RunPipelineAsync(context).ConfigureAwait(false);
await output.CompleteAsync(context.RequestAborted).ConfigureAwait(false);
}
catch (Exception ex) when (IsExpectedDisconnect(ex))
{
}
catch (QpackException ex)
{
this.logger.LogDebug(ex, "QPACK decoding failed");
await AbortAsync(stream, Http3ErrorCode.QpackDecompressionFailed, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
this.logger.LogError(ex, "An HTTP/3 request faulted");
await AbortAsync(stream, Http3ErrorCode.InternalError, cancellationToken).ConfigureAwait(false);
}
finally
{
await stream.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Reads HEADERS and any DATA frames from a request stream.
/// <para>
/// The body is buffered rather than streamed. Bounded by the configured maximum, and the reason
/// is honesty about scope: streaming it would mean plumbing a pipe through the frame reader,
/// and an embedded server's uploads are small. The limit is enforced as it reads, so a large
/// body is refused rather than accumulated.
/// </para>
/// </summary>
async Task<(List<HeaderField> Fields, byte[] Body)?> ReadRequestAsync(QuicStream stream, CancellationToken cancellationToken)
{
var pending = new ArrayBufferWriter<byte>(4096);
var buffer = new byte[8192];
List<HeaderField>? fields = null;
var body = new ArrayBufferWriter<byte>();
var maxBody = serverOptions.Limits.MaxRequestBodySize ?? long.MaxValue;
while (true)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read > 0)
pending.Write(buffer.AsSpan(0, read));
while (true)
{
var sequence = new ReadOnlySequence<byte>(pending.WrittenMemory);
if (!Http3Frame.TryReadHeader(sequence, out var header, out var consumed))
break;
var headerLength = (int)sequence.Slice(sequence.Start, consumed).Length;
if (header.Length < 0 || pending.WrittenCount < headerLength + header.Length)
break;
var payload = pending.WrittenSpan.Slice(headerLength, (int)header.Length);
switch (header.KnownType)
{
case Http3FrameType.Headers when fields is null:
fields = this.decoder.Decode(payload);
break;
case Http3FrameType.Headers:
// A second HEADERS is trailers, which nothing here consumes.
break;
case Http3FrameType.Data:
if (body.WrittenCount + payload.Length > maxBody)
throw new BadHttpRequestException(
"The request body is larger than the configured limit.",
StatusCodes.Status413PayloadTooLarge
);
body.Write(payload);
break;
default:
// Unknown and connection-level frames are ignored on a request stream.
break;
}
var total = headerLength + (int)header.Length;
var rest = pending.WrittenSpan[total..].ToArray();
pending.Clear();
pending.Write(rest);
}
if (read == 0)
break;
}
return fields is null ? null : (fields, body.WrittenSpan.ToArray());
}
void ApplyConnectionInfo(HttpContext context, string id, QuicStream stream)
{
var info = context.Connection;
info.ConnectionId = id;
// QUIC has no unencrypted mode, so this is not a question the way it is on TCP.
info.IsEncrypted = true;
if (connection.RemoteEndPoint is { } remote)
{
info.RemoteIpAddress = remote.Address;
info.RemotePort = remote.Port;
}
if (connection.LocalEndPoint is System.Net.IPEndPoint local)
{
info.LocalIpAddress = local.Address;
info.LocalPort = local.Port;
}
context.Request.Protocol = HttpProtocols.Http3;
context.Request.Scheme = "https";
context.AbortAction = () => stream.Abort(QuicAbortDirection.Both, Http3ErrorCode.RequestCancelled);
}
/// <summary>
/// Runs the pipeline in a request scope, so <c>Scoped</c> registrations behave the same on
/// HTTP/3 as on every other transport.
/// </summary>
async Task RunPipelineAsync(HttpContext context)
{
if (services is null)
{
await pipeline(context).ConfigureAwait(false);
return;
}
var scope = services.CreateAsyncScope();
try
{
context.RequestServices = scope.ServiceProvider;
await pipeline(context).ConfigureAwait(false);
}
finally
{
context.RequestServices = EmptyServiceProvider.Instance;
await scope.DisposeAsync().ConfigureAwait(false);
}
}
static async ValueTask AbortAsync(QuicStream stream, long errorCode, CancellationToken cancellationToken)
{
try
{
stream.Abort(QuicAbortDirection.Both, errorCode);
}
catch (Exception ex) when (IsExpectedDisconnect(ex))
{
}
await ValueTask.CompletedTask.ConfigureAwait(false);
}
internal static bool IsExpectedDisconnect(Exception ex) => ex
is OperationCanceledException
or ObjectDisposedException
or QuicException
or IOException;
}