tigrcorn-protocols 0.3.16.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. tigrcorn_protocols/__init__.py +1 -0
  2. tigrcorn_protocols/_compression.py +219 -0
  3. tigrcorn_protocols/connect.py +107 -0
  4. tigrcorn_protocols/content_coding.py +179 -0
  5. tigrcorn_protocols/custom/__init__.py +3 -0
  6. tigrcorn_protocols/custom/adapters.py +18 -0
  7. tigrcorn_protocols/custom/registry.py +15 -0
  8. tigrcorn_protocols/flow/__init__.py +1 -0
  9. tigrcorn_protocols/flow/backpressure.py +17 -0
  10. tigrcorn_protocols/flow/buffers.py +29 -0
  11. tigrcorn_protocols/flow/credits.py +21 -0
  12. tigrcorn_protocols/flow/keepalive.py +85 -0
  13. tigrcorn_protocols/flow/timeouts.py +17 -0
  14. tigrcorn_protocols/flow/watermarks.py +16 -0
  15. tigrcorn_protocols/http1/__init__.py +16 -0
  16. tigrcorn_protocols/http1/keepalive.py +21 -0
  17. tigrcorn_protocols/http1/parser.py +481 -0
  18. tigrcorn_protocols/http1/serializer.py +198 -0
  19. tigrcorn_protocols/http1/state.py +9 -0
  20. tigrcorn_protocols/http2/__init__.py +16 -0
  21. tigrcorn_protocols/http2/codec.py +266 -0
  22. tigrcorn_protocols/http2/flow.py +35 -0
  23. tigrcorn_protocols/http2/handler.py +1303 -0
  24. tigrcorn_protocols/http2/hpack.py +393 -0
  25. tigrcorn_protocols/http2/state.py +226 -0
  26. tigrcorn_protocols/http2/streams.py +76 -0
  27. tigrcorn_protocols/http2/websocket.py +360 -0
  28. tigrcorn_protocols/http3/__init__.py +82 -0
  29. tigrcorn_protocols/http3/codec.py +148 -0
  30. tigrcorn_protocols/http3/handler/__init__.py +3 -0
  31. tigrcorn_protocols/http3/handler/core.py +1823 -0
  32. tigrcorn_protocols/http3/handler/webtransport.py +184 -0
  33. tigrcorn_protocols/http3/handler.py +3 -0
  34. tigrcorn_protocols/http3/qpack.py +843 -0
  35. tigrcorn_protocols/http3/state.py +129 -0
  36. tigrcorn_protocols/http3/streams.py +657 -0
  37. tigrcorn_protocols/http3/websocket.py +360 -0
  38. tigrcorn_protocols/lifespan/__init__.py +3 -0
  39. tigrcorn_protocols/lifespan/driver.py +83 -0
  40. tigrcorn_protocols/py.typed +1 -0
  41. tigrcorn_protocols/rawframed/__init__.py +5 -0
  42. tigrcorn_protocols/rawframed/codec.py +18 -0
  43. tigrcorn_protocols/rawframed/frames.py +28 -0
  44. tigrcorn_protocols/rawframed/handler.py +72 -0
  45. tigrcorn_protocols/rawframed/state.py +9 -0
  46. tigrcorn_protocols/registry.py +22 -0
  47. tigrcorn_protocols/scheduler/__init__.py +17 -0
  48. tigrcorn_protocols/scheduler/cancellation.py +40 -0
  49. tigrcorn_protocols/scheduler/dispatch.py +27 -0
  50. tigrcorn_protocols/scheduler/fairness.py +21 -0
  51. tigrcorn_protocols/scheduler/policy.py +12 -0
  52. tigrcorn_protocols/scheduler/priorities.py +8 -0
  53. tigrcorn_protocols/scheduler/quotas.py +19 -0
  54. tigrcorn_protocols/scheduler/runtime.py +156 -0
  55. tigrcorn_protocols/scheduler/tasks.py +31 -0
  56. tigrcorn_protocols/sessions/__init__.py +1 -0
  57. tigrcorn_protocols/sessions/base.py +16 -0
  58. tigrcorn_protocols/sessions/connection.py +12 -0
  59. tigrcorn_protocols/sessions/limits.py +12 -0
  60. tigrcorn_protocols/sessions/manager.py +31 -0
  61. tigrcorn_protocols/sessions/metadata.py +10 -0
  62. tigrcorn_protocols/sessions/quic.py +14 -0
  63. tigrcorn_protocols/streams/__init__.py +1 -0
  64. tigrcorn_protocols/streams/base.py +13 -0
  65. tigrcorn_protocols/streams/ids.py +5 -0
  66. tigrcorn_protocols/streams/multiplex.py +6 -0
  67. tigrcorn_protocols/streams/registry.py +22 -0
  68. tigrcorn_protocols/streams/singleplex.py +6 -0
  69. tigrcorn_protocols/websocket/__init__.py +1 -0
  70. tigrcorn_protocols/websocket/codec.py +31 -0
  71. tigrcorn_protocols/websocket/extensions.py +324 -0
  72. tigrcorn_protocols/websocket/frames.py +174 -0
  73. tigrcorn_protocols/websocket/handler.py +462 -0
  74. tigrcorn_protocols/websocket/handshake.py +66 -0
  75. tigrcorn_protocols/websocket/state.py +10 -0
  76. tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
  77. tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
  78. tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
  79. tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
  80. tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1823 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import suppress
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from tigrcorn_asgi.receive import HTTPRequestReceive, apply_request_trailer_policy
9
+ from tigrcorn_asgi.scopes.custom import build_custom_scope
10
+ from tigrcorn_asgi.scopes.http import build_http_scope
11
+ from tigrcorn_asgi.send import HTTPResponseCollector, iter_response_body_segments, response_body_segments_have_bytes
12
+ from tigrcorn_config.model import ListenerConfig, ServerConfig
13
+ from tigrcorn_core.errors import ProtocolError
14
+ from tigrcorn_observability.logging import AccessLogger
15
+ from tigrcorn_observability.metrics import Metrics
16
+ from tigrcorn_security.tls import build_server_ssl_context
17
+ from tigrcorn_protocols.connect import close_tcp_writer, half_close_tcp_writer, is_connect_allowed, parse_connect_authority
18
+ from tigrcorn_protocols.custom.adapters import adapt_scope
19
+ from tigrcorn_protocols.http1.parser import ParsedRequest
20
+ from tigrcorn_http.alt_svc import configured_alt_svc_values
21
+ from tigrcorn_http.entity import apply_response_entity_semantics, plan_file_backed_response_entity_semantics
22
+ from tigrcorn_protocols.http3.codec import (
23
+ FRAME_DATA,
24
+ FRAME_HEADERS,
25
+ H3_CONNECT_ERROR,
26
+ H3_ID_ERROR,
27
+ H3_GENERAL_PROTOCOL_ERROR,
28
+ H3_REQUEST_CANCELLED,
29
+ SETTING_ENABLE_CONNECT_PROTOCOL,
30
+ SETTING_ENABLE_WEBTRANSPORT,
31
+ SETTING_H3_DATAGRAM,
32
+ HTTP3ConnectionError,
33
+ HTTP3StreamError,
34
+ encode_frame,
35
+ )
36
+ from tigrcorn_protocols.http3.streams import (
37
+ STREAM_TYPE_QPACK_DECODER,
38
+ STREAM_TYPE_QPACK_ENCODER,
39
+ HTTP3ConnectionCore,
40
+ )
41
+ from tigrcorn_protocols.http3.websocket import H3WebSocketSession
42
+ from tigrcorn_transports.quic.connection import QuicConnection
43
+ from tigrcorn_transports.quic.handshake import QuicTlsHandshakeDriver, TransportParameters
44
+ from tigrcorn_transports.quic.packets import QuicLongHeaderPacket, QuicLongHeaderType, QuicRetryPacket, QuicShortHeaderPacket, QuicVersionNegotiationPacket, decode_packet
45
+ from tigrcorn_transports.udp.endpoint import UDPEndpoint
46
+ from tigrcorn_transports.udp.packet import UDPPacket
47
+ from tigrcorn_core.types import ASGIApp
48
+ from tigrcorn_core.utils.bytes import decode_quic_varint, encode_quic_varint
49
+ from tigrcorn_core.utils.authority import authority_allowed
50
+ from tigrcorn_core.utils.headers import apply_response_header_policy, sanitize_early_hints_headers, strip_connection_specific_headers
51
+
52
+ from .webtransport import _HTTP3WebTransportSession
53
+
54
+
55
+ @dataclass(slots=True)
56
+ class HTTP3Session:
57
+ addr: tuple[str, int]
58
+ quic: QuicConnection
59
+ h3: HTTP3ConnectionCore = field(default_factory=lambda: HTTP3ConnectionCore(role='server'))
60
+ server_control_stream_sent: bool = False
61
+ server_control_stream_id: int | None = None
62
+ responded_streams: set[int] = field(default_factory=set)
63
+ request_packets: int = 0
64
+ server_qpack_encoder_stream_id: int | None = None
65
+ server_qpack_decoder_stream_id: int | None = None
66
+ bytes_received: int = 0
67
+ bytes_sent: int = 0
68
+ address_validated: bool = False
69
+ session_ticket_issued: bool = False
70
+ pending_outbound: list[bytes] = field(default_factory=list)
71
+ timer_handle: asyncio.TimerHandle | None = None
72
+ connect_tunnels: dict[int, _HTTP3ConnectTunnel] = field(default_factory=dict)
73
+ websocket_sessions: dict[int, H3WebSocketSession] = field(default_factory=dict)
74
+ webtransport_sessions: dict[int, _HTTP3WebTransportSession] = field(default_factory=dict)
75
+ webtransport_streams: set[int] = field(default_factory=set)
76
+ webtransport_stream_owners: dict[int, int] = field(default_factory=dict)
77
+ webtransport_stream_prefaces: dict[int, bytearray] = field(default_factory=dict)
78
+ stream_work_leases: dict[int, object] = field(default_factory=dict)
79
+ early_data_accounted: bool = False
80
+ peer_goaway_observed: bool = False
81
+ last_quic_packets_lost_total: int = 0
82
+ last_quic_pto_expirations_total: int = 0
83
+
84
+
85
+ class _HTTP3ConnectTunnel:
86
+ def __init__(
87
+ self,
88
+ *,
89
+ handler: HTTP3DatagramHandler,
90
+ session: HTTP3Session,
91
+ stream_id: int,
92
+ authority: str,
93
+ endpoint: UDPEndpoint,
94
+ upstream_reader: asyncio.StreamReader,
95
+ upstream_writer: asyncio.StreamWriter,
96
+ work_lease: object | None = None,
97
+ ) -> None:
98
+ self.handler = handler
99
+ self.session = session
100
+ self.stream_id = stream_id
101
+ self.authority = authority
102
+ self.endpoint = endpoint
103
+ self.upstream_reader = upstream_reader
104
+ self.upstream_writer = upstream_writer
105
+ self.work_lease = work_lease
106
+ self.relay_task: asyncio.Task[None] | None = None
107
+ self.client_input_closed = False
108
+ self.server_output_closed = False
109
+ self.closed = False
110
+
111
+ def start(self) -> None:
112
+ self.relay_task = asyncio.create_task(
113
+ self._relay_upstream_to_client(),
114
+ name=f'tigrcorn-h3-connect-{self.stream_id}',
115
+ )
116
+
117
+ async def feed_client_data(self, chunks: list[bytes], *, end_stream: bool, already_locked: bool = False) -> None:
118
+ if self.closed:
119
+ return
120
+ try:
121
+ wrote = False
122
+ for chunk in chunks:
123
+ if not chunk:
124
+ continue
125
+ self.upstream_writer.write(chunk)
126
+ wrote = True
127
+ if wrote:
128
+ await self.upstream_writer.drain()
129
+ if end_stream and not self.client_input_closed:
130
+ self.client_input_closed = True
131
+ await half_close_tcp_writer(self.upstream_writer)
132
+ except Exception:
133
+ await self.handler._reset_http3_tunnel_stream(
134
+ self.session,
135
+ self.stream_id,
136
+ self.endpoint,
137
+ already_locked=already_locked,
138
+ )
139
+ await self.abort()
140
+ return
141
+ await self._finish_if_complete()
142
+
143
+ async def abort(self) -> None:
144
+ if self.closed:
145
+ return
146
+ self.closed = True
147
+ current = asyncio.current_task()
148
+ if self.relay_task is not None and self.relay_task is not current:
149
+ self.relay_task.cancel()
150
+ with suppress(asyncio.CancelledError):
151
+ await self.relay_task
152
+ self.session.connect_tunnels.pop(self.stream_id, None)
153
+ lease = self.session.stream_work_leases.pop(self.stream_id, None)
154
+ if lease is not None:
155
+ lease.release()
156
+ elif self.work_lease is not None:
157
+ self.work_lease.release()
158
+ await close_tcp_writer(self.upstream_writer)
159
+
160
+ async def _relay_upstream_to_client(self) -> None:
161
+ reset_stream = False
162
+ try:
163
+ while True:
164
+ chunk = await asyncio.wait_for(self.upstream_reader.read(65536), timeout=self.handler.config.http.idle_timeout)
165
+ if not chunk:
166
+ break
167
+ await self.handler._send_http3_tunnel_data(
168
+ self.session,
169
+ self.stream_id,
170
+ chunk,
171
+ end_stream=False,
172
+ endpoint=self.endpoint,
173
+ )
174
+ except asyncio.CancelledError:
175
+ raise
176
+ except Exception:
177
+ reset_stream = True
178
+ else:
179
+ with suppress(Exception):
180
+ await self.handler._send_http3_tunnel_data(
181
+ self.session,
182
+ self.stream_id,
183
+ b'',
184
+ end_stream=True,
185
+ endpoint=self.endpoint,
186
+ )
187
+ finally:
188
+ self.server_output_closed = True
189
+ if reset_stream:
190
+ with suppress(Exception):
191
+ await self.handler._reset_http3_tunnel_stream(self.session, self.stream_id, self.endpoint)
192
+ await self._finish_if_complete()
193
+
194
+ async def _finish_if_complete(self) -> None:
195
+ if self.client_input_closed and self.server_output_closed:
196
+ await self.abort()
197
+
198
+
199
+ class HTTP3DatagramHandler:
200
+ _EARLY_DATA_TICKET_SIZE = 4096
201
+ _WEBTRANSPORT_BIDI_STREAM_SIGNAL = 0x41
202
+
203
+ def __init__(self, *, app: ASGIApp, config: ServerConfig, listener: ListenerConfig, access_logger: AccessLogger, scheduler: ProductionScheduler | None = None, metrics: Metrics | None = None) -> None:
204
+ self.app = app
205
+ self.config = config
206
+ self.listener = listener
207
+ self.access_logger = access_logger
208
+ self.scheduler = scheduler
209
+ self.metrics = metrics
210
+ self.sessions: dict[tuple[str, int], HTTP3Session] = {}
211
+ self.sessions_by_local_cid: dict[bytes, HTTP3Session] = {}
212
+ self._lock = asyncio.Lock()
213
+
214
+ def _session_ticket_early_data_size(self, session: HTTP3Session) -> int:
215
+ if session.quic.handshake_driver is None:
216
+ return 0
217
+ if self.config.quic.early_data_policy == 'deny':
218
+ return 0
219
+ return self._EARLY_DATA_TICKET_SIZE
220
+
221
+ def _should_send_too_early(self, session: HTTP3Session) -> bool:
222
+ handshake = session.quic.handshake_driver
223
+ if handshake is None:
224
+ return False
225
+ if self.config.quic.early_data_policy != 'require':
226
+ return False
227
+ return bool(getattr(handshake, '_using_psk', False)) and not bool(getattr(handshake, 'early_data_accepted', False))
228
+
229
+ def _configure_session_handshake(self, session: HTTP3Session) -> None:
230
+ if not self.listener.ssl_enabled or session.quic.handshake_driver is not None:
231
+ return
232
+ context = build_server_ssl_context(self.listener)
233
+ assert context is not None
234
+ transport_parameters = TransportParameters(max_udp_payload_size=self.listener.max_datagram_size, max_streams_bidi=self.config.scheduler.max_streams or 128, max_streams_uni=self.config.scheduler.max_streams or 128, idle_timeout=int(self.config.quic.idle_timeout * 1000))
235
+ session.quic.configure_handshake(
236
+ QuicTlsHandshakeDriver(
237
+ is_client=False,
238
+ alpn=tuple(self.listener.alpn_protocols or ('h3',)),
239
+ server_name=self.listener.host or 'localhost',
240
+ certificate_pem=context.certificate_pem,
241
+ private_key_pem=context.private_key_pem,
242
+ private_key_password=context.private_key_password,
243
+ trusted_certificates=context.trusted_certificates,
244
+ require_client_certificate=context.require_client_certificate,
245
+ validation_policy=context.validation_policy,
246
+ cipher_suites=context.cipher_suites,
247
+ transport_parameters=transport_parameters,
248
+ enable_early_data=self.config.quic.early_data_policy != 'deny',
249
+ )
250
+ )
251
+
252
+ def _queue_or_send(self, session: HTTP3Session, raw: bytes, endpoint: UDPEndpoint, addr: tuple[str, int]) -> None:
253
+ transport = getattr(endpoint, 'transport', None)
254
+ if transport is not None and transport.is_closing():
255
+ return
256
+ if self._can_send_now(session, raw):
257
+ endpoint.send(raw, addr)
258
+ session.bytes_sent += len(raw)
259
+ if self.metrics is not None:
260
+ self.metrics.quic_datagram_sent(len(raw))
261
+ return
262
+ session.quic.defer_datagram(raw)
263
+ session.pending_outbound.append(raw)
264
+
265
+ def _sync_quic_loss_metrics(self, session: HTTP3Session) -> None:
266
+ if self.metrics is None:
267
+ return
268
+ lost_total = int(getattr(session.quic, 'packets_lost_total', 0))
269
+ if lost_total > session.last_quic_packets_lost_total:
270
+ self.metrics.quic_packets_lost_observed(lost_total - session.last_quic_packets_lost_total)
271
+ session.last_quic_packets_lost_total = lost_total
272
+ pto_total = int(getattr(session.quic, 'pto_expirations_total', 0))
273
+ while pto_total > session.last_quic_pto_expirations_total:
274
+ self.metrics.quic_pto_expired()
275
+ session.last_quic_pto_expirations_total += 1
276
+
277
+ def _flush_pending_outbound(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
278
+ if not session.pending_outbound:
279
+ return
280
+ transport = getattr(endpoint, 'transport', None)
281
+ if transport is not None and transport.is_closing():
282
+ return
283
+ remaining: list[bytes] = []
284
+ for raw in session.pending_outbound:
285
+ if self._can_send_now(session, raw):
286
+ session.quic.confirm_datagram_sent(raw)
287
+ endpoint.send(raw, session.addr)
288
+ session.bytes_sent += len(raw)
289
+ if self.metrics is not None:
290
+ self.metrics.quic_datagram_sent(len(raw))
291
+ else:
292
+ remaining.append(raw)
293
+ session.pending_outbound = remaining
294
+
295
+ def _can_send_now(self, session: HTTP3Session, raw: bytes) -> bool:
296
+ amplification_ok = session.address_validated or (session.bytes_sent + len(raw) <= (session.bytes_received * 3))
297
+ return amplification_ok and session.quic.can_transmit_datagram(raw)
298
+
299
+ def _cancel_session_timer(self, session: HTTP3Session) -> None:
300
+ if session.timer_handle is not None:
301
+ session.timer_handle.cancel()
302
+ session.timer_handle = None
303
+
304
+ def _next_session_delay(self, session: HTTP3Session) -> float | None:
305
+ delays: list[float] = []
306
+ runtime_delay = session.quic.next_runtime_deadline()
307
+ if runtime_delay is not None:
308
+ delays.append(runtime_delay)
309
+ for raw in session.pending_outbound:
310
+ delay = session.quic.next_transmit_delay(raw)
311
+ if delay is not None:
312
+ delays.append(delay)
313
+ if not delays:
314
+ return None
315
+ return max(0.0, min(delays))
316
+
317
+ def _arm_session_timer(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
318
+ self._cancel_session_timer(session)
319
+ delay = self._next_session_delay(session)
320
+ if delay is None:
321
+ return
322
+ loop = asyncio.get_running_loop()
323
+ session.timer_handle = loop.call_later(delay, self._fire_session_timer, session, endpoint)
324
+
325
+ def _close_session(self, session: HTTP3Session) -> None:
326
+ removed = self.sessions.pop(session.addr, None)
327
+ if removed is session:
328
+ self.sessions_by_local_cid.pop(session.quic.local_cid, None)
329
+ if self.metrics is not None:
330
+ self.metrics.quic_session_closed()
331
+
332
+ async def close(self) -> None:
333
+ async with self._lock:
334
+ for session in list(self.sessions.values()):
335
+ self._cancel_session_timer(session)
336
+ await self._abort_session_tunnels(session)
337
+ await self._abort_session_websockets(session)
338
+ await self._abort_session_webtransports(session)
339
+ self._close_session(session)
340
+ self.sessions.clear()
341
+ self.sessions_by_local_cid.clear()
342
+
343
+ def _fire_session_timer(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
344
+ transport = getattr(endpoint, 'transport', None)
345
+ if transport is None or transport.is_closing():
346
+ return
347
+ try:
348
+ loop = asyncio.get_running_loop()
349
+ except RuntimeError:
350
+ return
351
+ if loop.is_closed():
352
+ return
353
+ loop.create_task(self._on_session_timer(session, endpoint))
354
+
355
+ async def _on_session_timer(self, session: HTTP3Session, endpoint: UDPEndpoint) -> None:
356
+ async with self._lock:
357
+ session.timer_handle = None
358
+ transport = getattr(endpoint, 'transport', None)
359
+ if transport is None or transport.is_closing():
360
+ return
361
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
362
+ return
363
+ outbound = session.quic.drain_scheduled_datagrams()
364
+ for raw in outbound:
365
+ self._queue_or_send(session, raw, endpoint, session.addr)
366
+ self._flush_pending_outbound(session, endpoint)
367
+ self._arm_session_timer(session, endpoint)
368
+
369
+ async def handle_packet(self, packet: UDPPacket, endpoint: UDPEndpoint) -> None:
370
+ async with self._lock:
371
+ try:
372
+ parsed = decode_packet(packet.data, destination_connection_id_length=8)
373
+ except Exception:
374
+ return
375
+ if isinstance(parsed, QuicVersionNegotiationPacket):
376
+ return
377
+ if isinstance(parsed, QuicLongHeaderPacket):
378
+ dcid = parsed.destination_connection_id
379
+ scid = parsed.source_connection_id
380
+ elif isinstance(parsed, QuicShortHeaderPacket):
381
+ dcid = parsed.destination_connection_id
382
+ scid = b''
383
+ elif isinstance(parsed, QuicRetryPacket):
384
+ dcid = parsed.destination_connection_id
385
+ scid = parsed.source_connection_id
386
+ else:
387
+ return
388
+ session = self.sessions_by_local_cid.get(dcid)
389
+ allow_addr_fallback = not (
390
+ isinstance(parsed, QuicLongHeaderPacket)
391
+ and parsed.packet_type == QuicLongHeaderType.INITIAL
392
+ and not parsed.token
393
+ )
394
+ if session is None and allow_addr_fallback:
395
+ session = self.sessions.get(packet.addr)
396
+ if session is None and isinstance(parsed, QuicShortHeaderPacket):
397
+ for known_cid, known_session in self.sessions_by_local_cid.items():
398
+ try:
399
+ candidate = decode_packet(packet.data, destination_connection_id_length=len(known_cid))
400
+ except Exception:
401
+ continue
402
+ if isinstance(candidate, QuicShortHeaderPacket) and candidate.destination_connection_id == known_cid:
403
+ parsed = candidate
404
+ dcid = candidate.destination_connection_id
405
+ session = known_session
406
+ break
407
+ predecoded_events = None
408
+ if session is None:
409
+ if 'http3' in self.listener.enabled_protocols:
410
+ if not isinstance(parsed, QuicLongHeaderPacket) or parsed.packet_type != QuicLongHeaderType.INITIAL:
411
+ return
412
+ session = HTTP3Session(
413
+ addr=packet.addr,
414
+ quic=QuicConnection(
415
+ is_client=False,
416
+ secret=self.listener.quic_secret,
417
+ local_cid=dcid or b'tigrcorn',
418
+ remote_cid=scid,
419
+ require_retry=self.listener.quic_require_retry,
420
+ ),
421
+ )
422
+ self._configure_session_handshake(session)
423
+ else:
424
+ candidate_session = None
425
+ for cid_length in range(1, 21):
426
+ try:
427
+ candidate_packet = decode_packet(packet.data, destination_connection_id_length=cid_length)
428
+ except Exception:
429
+ continue
430
+ if not isinstance(candidate_packet, QuicShortHeaderPacket):
431
+ continue
432
+ probe = HTTP3Session(
433
+ addr=packet.addr,
434
+ quic=QuicConnection(
435
+ is_client=False,
436
+ secret=self.listener.quic_secret,
437
+ local_cid=candidate_packet.destination_connection_id,
438
+ remote_cid=candidate_packet.destination_connection_id,
439
+ require_retry=self.listener.quic_require_retry,
440
+ ),
441
+ )
442
+ try:
443
+ events = probe.quic.receive_datagram(packet.data, addr=packet.addr)
444
+ except Exception:
445
+ continue
446
+ if any(event.kind != 'integrity_error' for event in events):
447
+ candidate_session = probe
448
+ parsed = candidate_packet
449
+ predecoded_events = events
450
+ break
451
+ if candidate_session is None:
452
+ return
453
+ session = candidate_session
454
+ self._configure_session_handshake(session)
455
+ self.sessions[packet.addr] = session
456
+ if session.quic.local_cid:
457
+ self.sessions_by_local_cid[session.quic.local_cid] = session
458
+ if self.metrics is not None:
459
+ self.metrics.quic_session_opened()
460
+ else:
461
+ session.quic.remote_cid = scid or session.quic.remote_cid
462
+
463
+ outbound: list[bytes] = []
464
+
465
+ session.bytes_received += len(packet.data)
466
+ if self.metrics is not None:
467
+ self.metrics.quic_datagram_received(len(packet.data))
468
+ if predecoded_events is None:
469
+ try:
470
+ events = session.quic.receive_datagram(packet.data, addr=packet.addr)
471
+ except Exception:
472
+ return
473
+ else:
474
+ events = predecoded_events
475
+ if session.addr != packet.addr and not any(event.kind == 'close' for event in events):
476
+ self.sessions.pop(session.addr, None)
477
+ session.addr = packet.addr
478
+ session.address_validated = True
479
+ session.quic.address_validated = True
480
+ self.sessions[packet.addr] = session
481
+ if session.quic.local_cid:
482
+ self.sessions_by_local_cid[session.quic.local_cid] = session
483
+ session.request_packets += 1
484
+ outbound.extend(self._ensure_server_control_stream_locked(session))
485
+ for event in events:
486
+ if self.metrics is not None:
487
+ if event.kind == 'retry':
488
+ self.metrics.quic_retry_emitted()
489
+ elif event.kind == 'path_challenge':
490
+ self.metrics.quic_path_challenge_observed()
491
+ elif event.kind == 'path_response':
492
+ self.metrics.quic_path_response_observed()
493
+ elif event.kind == 'path_migrated':
494
+ self.metrics.quic_path_migrated()
495
+ elif event.kind == 'reset_stream':
496
+ self.metrics.http3_stream_reset()
497
+ if event.kind == 'handshake_complete':
498
+ session.address_validated = True
499
+ session.quic.address_validated = True
500
+ if self.metrics is not None:
501
+ self.metrics.tls_handshake_completed()
502
+ if not session.early_data_accounted and session.quic.handshake_driver is not None:
503
+ using_psk = bool(getattr(session.quic.handshake_driver, '_using_psk', False))
504
+ if using_psk:
505
+ accepted = bool(getattr(session.quic.handshake_driver, 'early_data_accepted', False))
506
+ self.metrics.quic_early_data_observed(accepted=accepted)
507
+ session.early_data_accounted = True
508
+ outbound.extend(session.quic.take_handshake_datagrams())
509
+ outbound.extend(self._ensure_server_control_stream_locked(session))
510
+ if (
511
+ session.quic.handshake_driver is not None
512
+ and not session.quic.is_client
513
+ and not session.session_ticket_issued
514
+ ):
515
+ try:
516
+ ticket = session.quic.handshake_driver.issue_session_ticket(
517
+ max_early_data_size=self._session_ticket_early_data_size(session)
518
+ )
519
+ except Exception:
520
+ ticket = b''
521
+ if ticket:
522
+ outbound.append(session.quic.send_crypto_data(ticket, packet_space='application'))
523
+ session.session_ticket_issued = True
524
+ elif event.kind == 'path_response':
525
+ session.address_validated = True
526
+ session.quic.address_validated = True
527
+ outbound.extend(self._ensure_server_control_stream_locked(session))
528
+ elif event.kind == 'stream' and event.stream_id is not None:
529
+ if 'http3' in self.listener.enabled_protocols:
530
+ try:
531
+ handled, h3_payload = await self._consume_webtransport_stream_event_locked(
532
+ session,
533
+ event.stream_id,
534
+ event.data,
535
+ fin=event.fin,
536
+ )
537
+ except HTTP3ConnectionError as exc:
538
+ outbound.extend(self._flush_qpack_streams(session))
539
+ outbound.append(session.quic.close(error_code=exc.error_code, reason=str(exc), application=True))
540
+ await self._abort_session_tunnels(session)
541
+ await self._abort_session_websockets(session)
542
+ await self._abort_session_webtransports(session)
543
+ self._cancel_session_timer(session)
544
+ self._close_session(session)
545
+ break
546
+ if handled:
547
+ continue
548
+ try:
549
+ peer_goaway_before = session.h3.state.peer_goaway_id
550
+ request_state = session.h3.receive_stream_data(event.stream_id, h3_payload, fin=event.fin)
551
+ if (
552
+ self.metrics is not None
553
+ and session.h3.state.peer_goaway_id is not None
554
+ and session.h3.state.peer_goaway_id != peer_goaway_before
555
+ ):
556
+ self.metrics.http3_goaway_observed()
557
+ except HTTP3StreamError as exc:
558
+ if exc.stream_id is not None:
559
+ session.h3.abandon_stream(exc.stream_id)
560
+ outbound.extend(self._flush_qpack_streams(session))
561
+ if exc.stream_id is not None:
562
+ outbound.append(session.quic.reset_stream(exc.stream_id, exc.error_code))
563
+ continue
564
+ except HTTP3ConnectionError as exc:
565
+ outbound.extend(self._flush_qpack_streams(session))
566
+ outbound.append(session.quic.close(error_code=exc.error_code, reason=str(exc), application=True))
567
+ await self._abort_session_tunnels(session)
568
+ await self._abort_session_websockets(session)
569
+ await self._abort_session_webtransports(session)
570
+ self._cancel_session_timer(session)
571
+ self._close_session(session)
572
+ break
573
+ except ProtocolError as exc:
574
+ outbound.extend(self._flush_qpack_streams(session))
575
+ outbound.append(session.quic.close(error_code=H3_GENERAL_PROTOCOL_ERROR, reason=str(exc), application=True))
576
+ await self._abort_session_tunnels(session)
577
+ await self._abort_session_websockets(session)
578
+ await self._abort_session_webtransports(session)
579
+ self._cancel_session_timer(session)
580
+ self._close_session(session)
581
+ break
582
+ outbound.extend(self._flush_qpack_streams(session))
583
+ if request_state is not None:
584
+ header_map: dict[bytes, bytes] | None = None
585
+ if request_state.received_initial_headers:
586
+ try:
587
+ header_map = self._validate_request_headers(list(request_state.headers))
588
+ except ProtocolError:
589
+ if event.stream_id not in session.responded_streams:
590
+ outbound.extend(
591
+ self._build_http3_response_datagrams_locked(
592
+ session,
593
+ event.stream_id,
594
+ 400,
595
+ [(b'content-type', b'text/plain')],
596
+ b'bad request',
597
+ end_stream=True,
598
+ )
599
+ )
600
+ session.responded_streams.add(event.stream_id)
601
+ outbound.extend(await self._respond_ready_requests(session, endpoint))
602
+ continue
603
+ protocol = header_map.get(b':protocol') if header_map is not None else None
604
+ if header_map is not None and protocol is not None and event.stream_id not in session.responded_streams:
605
+ if protocol == b'webtransport' and 'webtransport' in self.listener.enabled_protocols:
606
+ outbound.extend(
607
+ await self._start_webtransport_stream_locked(
608
+ session,
609
+ event.stream_id,
610
+ request_state,
611
+ header_map,
612
+ endpoint,
613
+ )
614
+ )
615
+ elif protocol != b'websocket' or not self.listener.websocket:
616
+ target = self._request_target_from_header_map(header_map)
617
+ self.access_logger.log_http(session.addr, 'CONNECT', target, 501, 'HTTP/3')
618
+ outbound.extend(
619
+ self._build_http3_response_datagrams_locked(
620
+ session,
621
+ event.stream_id,
622
+ 501,
623
+ [(b'content-type', b'text/plain')],
624
+ b'unsupported extended connect protocol',
625
+ end_stream=True,
626
+ )
627
+ )
628
+ else:
629
+ outbound.extend(
630
+ await self._start_websocket_stream_locked(
631
+ session,
632
+ event.stream_id,
633
+ request_state,
634
+ header_map,
635
+ endpoint,
636
+ )
637
+ )
638
+ session.responded_streams.add(event.stream_id)
639
+ elif header_map is not None and header_map.get(b':method') == b'CONNECT' and event.stream_id not in session.responded_streams:
640
+ outbound.extend(
641
+ await self._start_connect_tunnel_locked(
642
+ session,
643
+ event.stream_id,
644
+ request_state,
645
+ header_map,
646
+ endpoint,
647
+ )
648
+ )
649
+ session.responded_streams.add(event.stream_id)
650
+ if event.stream_id in session.websocket_sessions:
651
+ await self._drain_websocket_request_body_locked(session, event.stream_id, request_state, endpoint)
652
+ elif event.stream_id in session.connect_tunnels:
653
+ await self._drain_connect_request_body_locked(session, event.stream_id, request_state)
654
+ elif event.stream_id in session.webtransport_streams:
655
+ await self._drain_webtransport_request_body_locked(session, event.stream_id, request_state)
656
+ elif request_state.ready and event.stream_id not in session.responded_streams:
657
+ outbound.extend(await self._invoke_http_app(session, event.stream_id, request_state, endpoint))
658
+ session.responded_streams.add(event.stream_id)
659
+ outbound.extend(await self._respond_ready_requests(session, endpoint))
660
+ else:
661
+ outbound.extend(await self._invoke_custom_quic_app(session, event, endpoint))
662
+ if event.stream_id is not None:
663
+ session.responded_streams.add(event.stream_id)
664
+ elif event.kind == 'reset_stream' and event.stream_id is not None:
665
+ if 'http3' in self.listener.enabled_protocols:
666
+ websocket = session.websocket_sessions.get(event.stream_id)
667
+ if websocket is not None:
668
+ await websocket.abort()
669
+ session.websocket_sessions.pop(event.stream_id, None)
670
+ tunnel = session.connect_tunnels.get(event.stream_id)
671
+ if tunnel is not None:
672
+ await tunnel.abort()
673
+ if event.stream_id in session.webtransport_streams:
674
+ owner_stream_id = session.webtransport_stream_owners.get(event.stream_id, event.stream_id)
675
+ webtransport = session.webtransport_sessions.get(owner_stream_id)
676
+ if owner_stream_id == event.stream_id and webtransport is not None:
677
+ webtransport.note_connect_stream_stopped()
678
+ else:
679
+ session.webtransport_streams.discard(event.stream_id)
680
+ session.webtransport_stream_prefaces.pop(event.stream_id, None)
681
+ session.webtransport_stream_owners.pop(event.stream_id, None)
682
+ webtransport = session.webtransport_sessions.pop(owner_stream_id, None) if owner_stream_id == event.stream_id else None
683
+ if webtransport is not None:
684
+ await webtransport.abort()
685
+ self._release_stream_work_lease(session, event.stream_id)
686
+ if event.stream_id not in session.webtransport_sessions:
687
+ session.h3.abandon_stream(event.stream_id)
688
+ outbound.extend(self._flush_qpack_streams(session))
689
+ elif event.kind == 'stop_sending' and event.stream_id is not None:
690
+ if 'http3' in self.listener.enabled_protocols and event.stream_id in session.webtransport_sessions:
691
+ session.quic.suppress_pending_reset(event.stream_id)
692
+ webtransport = session.webtransport_sessions.get(event.stream_id)
693
+ if webtransport is not None:
694
+ webtransport.note_connect_stream_stopped()
695
+ elif event.kind == 'datagram':
696
+ if 'http3' in self.listener.enabled_protocols:
697
+ await self._dispatch_webtransport_datagram_locked(session, event.data)
698
+ elif event.kind == 'close':
699
+ await self._abort_session_tunnels(session)
700
+ await self._abort_session_websockets(session)
701
+ await self._abort_session_webtransports(session)
702
+ self._cancel_session_timer(session)
703
+ self._close_session(session)
704
+ self._sync_quic_loss_metrics(session)
705
+ outbound.extend(session.quic.take_handshake_datagrams())
706
+ outbound.extend(session.quic.drain_scheduled_datagrams())
707
+ for raw in outbound:
708
+ self._queue_or_send(session, raw, endpoint, packet.addr)
709
+ self._flush_pending_outbound(session, endpoint)
710
+ if session.addr in self.sessions and self.sessions.get(session.addr) is session:
711
+ self._arm_session_timer(session, endpoint)
712
+
713
+ def _ensure_server_control_stream_locked(self, session: HTTP3Session) -> list[bytes]:
714
+ if (
715
+ session.server_control_stream_sent
716
+ or 'http3' not in self.listener.enabled_protocols
717
+ or (not session.address_validated and session.quic.handshake_driver is not None)
718
+ ):
719
+ return []
720
+ if session.server_control_stream_id is None:
721
+ session.server_control_stream_id = session.quic.streams.next_stream_id(client=False, unidirectional=True)
722
+ control_settings = {1: 0, 6: self.listener.max_datagram_size}
723
+ if self.listener.websocket:
724
+ control_settings[SETTING_ENABLE_CONNECT_PROTOCOL] = 1
725
+ if 'webtransport' in self.listener.enabled_protocols:
726
+ control_settings[SETTING_ENABLE_CONNECT_PROTOCOL] = 1
727
+ control_settings[SETTING_H3_DATAGRAM] = 1
728
+ control_settings[SETTING_ENABLE_WEBTRANSPORT] = 1
729
+ control_payload = session.h3.encode_control_stream(control_settings)
730
+ session.server_control_stream_sent = True
731
+ return [session.quic.send_stream_data(session.server_control_stream_id, control_payload, fin=False)]
732
+
733
+ def _flush_qpack_streams(self, session: HTTP3Session) -> list[bytes]:
734
+ outbound: list[bytes] = []
735
+ encoder_data = session.h3.take_encoder_stream_data()
736
+ if encoder_data:
737
+ if session.server_qpack_encoder_stream_id is None:
738
+ session.server_qpack_encoder_stream_id = session.quic.streams.next_stream_id(client=False, unidirectional=True)
739
+ encoder_data = encode_quic_varint(STREAM_TYPE_QPACK_ENCODER) + encoder_data
740
+ if self.metrics is not None:
741
+ self.metrics.http3_qpack_encoder_stream_opened()
742
+ outbound.append(session.quic.send_stream_data(session.server_qpack_encoder_stream_id, encoder_data, fin=False))
743
+ decoder_data = session.h3.take_decoder_stream_data()
744
+ if decoder_data:
745
+ if session.server_qpack_decoder_stream_id is None:
746
+ session.server_qpack_decoder_stream_id = session.quic.streams.next_stream_id(client=False, unidirectional=True)
747
+ decoder_data = encode_quic_varint(STREAM_TYPE_QPACK_DECODER) + decoder_data
748
+ if self.metrics is not None:
749
+ self.metrics.http3_qpack_decoder_stream_opened()
750
+ outbound.append(session.quic.send_stream_data(session.server_qpack_decoder_stream_id, decoder_data, fin=False))
751
+ return outbound
752
+
753
+ def _queue_session_outbound_locked(self, session: HTTP3Session, outbound: list[bytes], endpoint: UDPEndpoint) -> None:
754
+ for raw in outbound:
755
+ self._queue_or_send(session, raw, endpoint, session.addr)
756
+ self._flush_pending_outbound(session, endpoint)
757
+ if session.addr in self.sessions and self.sessions.get(session.addr) is session:
758
+ self._arm_session_timer(session, endpoint)
759
+
760
+ def _webtransport_max_datagram_size(self) -> int:
761
+ # Keep the public config path discoverable for SSOT proof checks: webtransport.max_datagram_size.
762
+ configured = self.config.webtransport.max_datagram_size
763
+ return int(configured if configured is not None else self.listener.max_datagram_size)
764
+
765
+ def _webtransport_security_extension(self, session: HTTP3Session) -> dict[str, object]:
766
+ handshake = session.quic.handshake_driver
767
+ peer_certificate = getattr(handshake, 'peer_certificate_pem', None)
768
+ return {
769
+ 'alpn': getattr(handshake, 'selected_alpn', None),
770
+ 'mtls': bool(peer_certificate),
771
+ 'peer_certificate': peer_certificate,
772
+ 'sni': getattr(handshake, 'server_name', None),
773
+ 'tls': bool(self.listener.ssl_enabled and handshake is not None and getattr(handshake, 'complete', False)),
774
+ }
775
+
776
+ def _webtransport_transport_extension(self, session: HTTP3Session) -> dict[str, object]:
777
+ return {
778
+ 'address_validated': bool(session.address_validated or session.quic.address_validated),
779
+ 'connection_id': session.quic.local_cid.hex(),
780
+ 'max_datagram_size': self._webtransport_max_datagram_size(),
781
+ 'retry_required': bool(self.listener.quic_require_retry),
782
+ }
783
+
784
+ def _encode_webtransport_datagram_payload(self, stream_id: int, data: bytes) -> bytes:
785
+ if len(data) > self._webtransport_max_datagram_size():
786
+ raise ProtocolError('webtransport.max_datagram_size exceeded')
787
+ quarter_stream_id = stream_id // 4
788
+ return encode_quic_varint(quarter_stream_id) + data
789
+
790
+ def _decode_webtransport_datagram_payload(self, payload: bytes) -> tuple[int, bytes]:
791
+ quarter_stream_id, offset = decode_quic_varint(payload, 0)
792
+ return int(quarter_stream_id) * 4, payload[offset:]
793
+
794
+ def _stream_is_client_initiated_bidi(self, stream_id: int) -> bool:
795
+ return (stream_id & 0x03) == 0x00
796
+
797
+ def _parse_webtransport_bidi_stream_prefix(self, payload: bytes) -> tuple[int, int, bytes] | None:
798
+ try:
799
+ signal, offset = decode_quic_varint(payload, 0)
800
+ except ValueError:
801
+ return None
802
+ if signal != self._WEBTRANSPORT_BIDI_STREAM_SIGNAL:
803
+ return (-1, -1, payload)
804
+ try:
805
+ session_id, offset = decode_quic_varint(payload, offset)
806
+ except ValueError:
807
+ return None
808
+ return signal, session_id, payload[offset:]
809
+
810
+ async def _consume_webtransport_stream_event_locked(
811
+ self,
812
+ session: HTTP3Session,
813
+ stream_id: int,
814
+ data: bytes,
815
+ *,
816
+ fin: bool,
817
+ ) -> tuple[bool, bytes]:
818
+ owner_stream_id = session.webtransport_stream_owners.get(stream_id)
819
+ if owner_stream_id is not None and owner_stream_id != stream_id:
820
+ webtransport = session.webtransport_sessions.get(owner_stream_id)
821
+ if webtransport is not None:
822
+ await webtransport.feed_stream_data(
823
+ data,
824
+ end_stream=fin,
825
+ disconnect_on_end=False,
826
+ stream_id=stream_id,
827
+ )
828
+ return True, b''
829
+ if owner_stream_id == stream_id:
830
+ return False, data
831
+ if not self._stream_is_client_initiated_bidi(stream_id):
832
+ return False, data
833
+ if stream_id in session.h3.requests:
834
+ return False, data
835
+
836
+ preface = session.webtransport_stream_prefaces.setdefault(stream_id, bytearray())
837
+ preface.extend(data)
838
+ parsed = self._parse_webtransport_bidi_stream_prefix(bytes(preface))
839
+ if parsed is None:
840
+ if fin:
841
+ payload = bytes(preface)
842
+ session.webtransport_stream_prefaces.pop(stream_id, None)
843
+ return False, payload
844
+ return True, b''
845
+
846
+ signal, owner_candidate, remaining = parsed
847
+ if signal != self._WEBTRANSPORT_BIDI_STREAM_SIGNAL:
848
+ session.webtransport_stream_prefaces.pop(stream_id, None)
849
+ return False, bytes(preface)
850
+
851
+ webtransport = session.webtransport_sessions.get(owner_candidate)
852
+ if webtransport is None:
853
+ raise HTTP3ConnectionError(
854
+ f'invalid WebTransport session id {owner_candidate} on stream {stream_id}',
855
+ error_code=H3_ID_ERROR,
856
+ )
857
+ session.webtransport_streams.add(stream_id)
858
+ session.webtransport_stream_owners[stream_id] = owner_candidate
859
+ session.webtransport_stream_prefaces.pop(stream_id, None)
860
+ await webtransport.feed_stream_data(
861
+ remaining,
862
+ end_stream=fin,
863
+ disconnect_on_end=False,
864
+ stream_id=stream_id,
865
+ )
866
+ return True, b''
867
+
868
+ async def _dispatch_webtransport_datagram_locked(self, session: HTTP3Session, payload: bytes) -> None:
869
+ try:
870
+ stream_id, data = self._decode_webtransport_datagram_payload(payload)
871
+ except ProtocolError:
872
+ return
873
+ webtransport = session.webtransport_sessions.get(stream_id)
874
+ if webtransport is None and len(session.webtransport_sessions) == 1:
875
+ webtransport = next(iter(session.webtransport_sessions.values()))
876
+ if webtransport is None:
877
+ return
878
+ if len(data) > self._webtransport_max_datagram_size():
879
+ return
880
+ datagram_id = f'{stream_id}:{getattr(session, "request_packets", 0)}'
881
+ await webtransport.feed_datagram(datagram_id, data)
882
+
883
+ async def _send_webtransport_stream_data(
884
+ self,
885
+ session: HTTP3Session,
886
+ stream_id: int,
887
+ data: bytes,
888
+ *,
889
+ end_stream: bool,
890
+ endpoint: UDPEndpoint,
891
+ already_locked: bool = False,
892
+ ) -> None:
893
+ if not already_locked:
894
+ async with self._lock:
895
+ await self._send_webtransport_stream_data(
896
+ session,
897
+ stream_id,
898
+ data,
899
+ end_stream=end_stream,
900
+ endpoint=endpoint,
901
+ already_locked=True,
902
+ )
903
+ return
904
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
905
+ return
906
+ owner_stream_id = session.webtransport_stream_owners.get(stream_id)
907
+ if owner_stream_id is None:
908
+ return
909
+ if owner_stream_id == stream_id:
910
+ outbound = self._build_http3_data_datagrams_locked(session, stream_id, data, end_stream=end_stream)
911
+ else:
912
+ outbound = [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, data, fin=end_stream)]
913
+ if end_stream:
914
+ session.webtransport_streams.discard(stream_id)
915
+ session.webtransport_stream_owners.pop(stream_id, None)
916
+ session.webtransport_stream_prefaces.pop(stream_id, None)
917
+ if owner_stream_id == stream_id:
918
+ session.webtransport_sessions.pop(stream_id, None)
919
+ self._release_stream_work_lease(session, stream_id)
920
+ session.h3.abandon_stream(stream_id)
921
+ self._queue_session_outbound_locked(session, outbound, endpoint)
922
+
923
+ async def _send_webtransport_datagram(
924
+ self,
925
+ session: HTTP3Session,
926
+ stream_id: int,
927
+ data: bytes,
928
+ *,
929
+ datagram_id: str,
930
+ endpoint: UDPEndpoint,
931
+ already_locked: bool = False,
932
+ ) -> None:
933
+ if not already_locked:
934
+ async with self._lock:
935
+ await self._send_webtransport_datagram(
936
+ session,
937
+ stream_id,
938
+ data,
939
+ datagram_id=datagram_id,
940
+ endpoint=endpoint,
941
+ already_locked=True,
942
+ )
943
+ return
944
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
945
+ return
946
+ if stream_id not in session.webtransport_sessions:
947
+ return
948
+ payload = self._encode_webtransport_datagram_payload(stream_id, data)
949
+ outbound = [session.quic.send_datagram_frame(payload)]
950
+ self._queue_session_outbound_locked(session, outbound, endpoint)
951
+
952
+ def _build_http3_response_datagrams_locked(
953
+ self,
954
+ session: HTTP3Session,
955
+ stream_id: int,
956
+ status: int,
957
+ headers: list[tuple[bytes, bytes]],
958
+ body: bytes,
959
+ *,
960
+ end_stream: bool,
961
+ ) -> list[bytes]:
962
+ response_headers = apply_response_header_policy(
963
+ strip_connection_specific_headers(headers),
964
+ server_header=self.config.server_header_value,
965
+ include_date_header=self.config.include_date_header,
966
+ default_headers=self.config.default_response_headers,
967
+ alt_svc_values=configured_alt_svc_values(self.config, request_http_version='3'),
968
+ )
969
+ header_block = session.h3.encode_headers(
970
+ stream_id,
971
+ [(b':status', str(status).encode('ascii')), *response_headers],
972
+ )
973
+ payload = bytearray(encode_frame(FRAME_HEADERS, header_block))
974
+ if body:
975
+ payload.extend(encode_frame(FRAME_DATA, body))
976
+ return [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, bytes(payload), fin=end_stream)]
977
+
978
+ async def _send_http3_streamed_response_locked(
979
+ self,
980
+ session: HTTP3Session,
981
+ stream_id: int,
982
+ status: int,
983
+ headers: list[tuple[bytes, bytes]],
984
+ body_segments: list,
985
+ trailers: list[tuple[bytes, bytes]],
986
+ informational: list[tuple[int, list[tuple[bytes, bytes]]]],
987
+ endpoint: UDPEndpoint,
988
+ ) -> None:
989
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
990
+ return
991
+ for interim_status, interim_headers in informational:
992
+ interim_header_block = session.h3.encode_headers(
993
+ stream_id,
994
+ [(b':status', str(interim_status).encode('ascii')), *sanitize_early_hints_headers(interim_headers)],
995
+ )
996
+ outbound = [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, encode_frame(FRAME_HEADERS, interim_header_block), fin=False)]
997
+ self._queue_session_outbound_locked(session, outbound, endpoint)
998
+ has_body = response_body_segments_have_bytes(body_segments)
999
+ response_headers = apply_response_header_policy(
1000
+ strip_connection_specific_headers(headers),
1001
+ server_header=self.config.server_header_value,
1002
+ include_date_header=self.config.include_date_header,
1003
+ default_headers=self.config.default_response_headers,
1004
+ alt_svc_values=configured_alt_svc_values(self.config, request_http_version='3'),
1005
+ )
1006
+ header_block = session.h3.encode_headers(stream_id, [(b':status', str(status).encode('ascii')), *response_headers])
1007
+ outbound = [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, encode_frame(FRAME_HEADERS, header_block), fin=(not has_body and not trailers))]
1008
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1009
+ if not has_body and not trailers:
1010
+ return
1011
+ if has_body:
1012
+ chunk_size = max(1024, int(self.listener.max_datagram_size) - 256)
1013
+ async for chunk in iter_response_body_segments(body_segments, chunk_size=chunk_size):
1014
+ outbound = self._build_http3_data_datagrams_locked(session, stream_id, chunk, end_stream=False)
1015
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1016
+ if trailers:
1017
+ trailer_block = session.h3.encode_headers(stream_id, list(trailers))
1018
+ outbound = [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, encode_frame(FRAME_HEADERS, trailer_block), fin=True)]
1019
+ else:
1020
+ outbound = self._build_http3_data_datagrams_locked(session, stream_id, b'', end_stream=True)
1021
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1022
+
1023
+ def _build_http3_data_datagrams_locked(
1024
+ self,
1025
+ session: HTTP3Session,
1026
+ stream_id: int,
1027
+ data: bytes,
1028
+ *,
1029
+ end_stream: bool,
1030
+ ) -> list[bytes]:
1031
+ payload = encode_frame(FRAME_DATA, data) if data else b''
1032
+ return [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, payload, fin=end_stream)]
1033
+
1034
+ async def _send_http3_websocket_headers(
1035
+ self,
1036
+ session: HTTP3Session,
1037
+ stream_id: int,
1038
+ status: int,
1039
+ headers: list[tuple[bytes, bytes]],
1040
+ *,
1041
+ end_stream: bool,
1042
+ endpoint: UDPEndpoint,
1043
+ already_locked: bool = False,
1044
+ ) -> None:
1045
+ if not already_locked:
1046
+ async with self._lock:
1047
+ await self._send_http3_websocket_headers(
1048
+ session,
1049
+ stream_id,
1050
+ status,
1051
+ headers,
1052
+ end_stream=end_stream,
1053
+ endpoint=endpoint,
1054
+ already_locked=True,
1055
+ )
1056
+ return
1057
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
1058
+ return
1059
+ if stream_id not in session.websocket_sessions:
1060
+ return
1061
+ outbound = self._build_http3_response_datagrams_locked(
1062
+ session,
1063
+ stream_id,
1064
+ status,
1065
+ headers,
1066
+ b'',
1067
+ end_stream=end_stream,
1068
+ )
1069
+ if end_stream:
1070
+ session.websocket_sessions.pop(stream_id, None)
1071
+ self._release_stream_work_lease(session, stream_id)
1072
+ session.h3.abandon_stream(stream_id)
1073
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1074
+
1075
+ async def _send_http3_websocket_data(
1076
+ self,
1077
+ session: HTTP3Session,
1078
+ stream_id: int,
1079
+ data: bytes,
1080
+ *,
1081
+ end_stream: bool,
1082
+ endpoint: UDPEndpoint,
1083
+ already_locked: bool = False,
1084
+ ) -> None:
1085
+ if not already_locked:
1086
+ async with self._lock:
1087
+ await self._send_http3_websocket_data(
1088
+ session,
1089
+ stream_id,
1090
+ data,
1091
+ end_stream=end_stream,
1092
+ endpoint=endpoint,
1093
+ already_locked=True,
1094
+ )
1095
+ return
1096
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
1097
+ return
1098
+ if stream_id not in session.websocket_sessions:
1099
+ return
1100
+ outbound = self._build_http3_data_datagrams_locked(session, stream_id, data, end_stream=end_stream)
1101
+ if end_stream:
1102
+ session.websocket_sessions.pop(stream_id, None)
1103
+ self._release_stream_work_lease(session, stream_id)
1104
+ session.h3.abandon_stream(stream_id)
1105
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1106
+
1107
+ async def _send_http3_tunnel_data(
1108
+ self,
1109
+ session: HTTP3Session,
1110
+ stream_id: int,
1111
+ data: bytes,
1112
+ *,
1113
+ end_stream: bool,
1114
+ endpoint: UDPEndpoint,
1115
+ already_locked: bool = False,
1116
+ ) -> None:
1117
+ if not already_locked:
1118
+ async with self._lock:
1119
+ await self._send_http3_tunnel_data(
1120
+ session,
1121
+ stream_id,
1122
+ data,
1123
+ end_stream=end_stream,
1124
+ endpoint=endpoint,
1125
+ already_locked=True,
1126
+ )
1127
+ return
1128
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
1129
+ return
1130
+ if stream_id not in session.connect_tunnels:
1131
+ return
1132
+ outbound = self._build_http3_data_datagrams_locked(session, stream_id, data, end_stream=end_stream)
1133
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1134
+
1135
+ async def _reset_http3_tunnel_stream(
1136
+ self,
1137
+ session: HTTP3Session,
1138
+ stream_id: int,
1139
+ endpoint: UDPEndpoint,
1140
+ *,
1141
+ already_locked: bool = False,
1142
+ ) -> None:
1143
+ if not already_locked:
1144
+ async with self._lock:
1145
+ await self._reset_http3_tunnel_stream(
1146
+ session,
1147
+ stream_id,
1148
+ endpoint,
1149
+ already_locked=True,
1150
+ )
1151
+ return
1152
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
1153
+ return
1154
+ self._release_stream_work_lease(session, stream_id)
1155
+ session.h3.abandon_stream(stream_id)
1156
+ outbound = self._flush_qpack_streams(session)
1157
+ outbound.append(session.quic.reset_stream(stream_id, H3_CONNECT_ERROR))
1158
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1159
+
1160
+ async def _abort_session_tunnels(self, session: HTTP3Session) -> None:
1161
+ for tunnel in list(session.connect_tunnels.values()):
1162
+ with suppress(Exception):
1163
+ await tunnel.abort()
1164
+
1165
+ async def _reset_http3_websocket_stream(
1166
+ self,
1167
+ session: HTTP3Session,
1168
+ stream_id: int,
1169
+ endpoint: UDPEndpoint,
1170
+ *,
1171
+ already_locked: bool = False,
1172
+ ) -> None:
1173
+ if not already_locked:
1174
+ async with self._lock:
1175
+ await self._reset_http3_websocket_stream(
1176
+ session,
1177
+ stream_id,
1178
+ endpoint,
1179
+ already_locked=True,
1180
+ )
1181
+ return
1182
+ if session.addr not in self.sessions or self.sessions.get(session.addr) is not session:
1183
+ return
1184
+ session.websocket_sessions.pop(stream_id, None)
1185
+ self._release_stream_work_lease(session, stream_id)
1186
+ session.h3.abandon_stream(stream_id)
1187
+ outbound = self._flush_qpack_streams(session)
1188
+ outbound.append(session.quic.reset_stream(stream_id, H3_REQUEST_CANCELLED))
1189
+ self._queue_session_outbound_locked(session, outbound, endpoint)
1190
+
1191
+ async def _abort_session_websockets(self, session: HTTP3Session) -> None:
1192
+ for websocket in list(session.websocket_sessions.values()):
1193
+ with suppress(Exception):
1194
+ await websocket.abort()
1195
+ session.websocket_sessions.clear()
1196
+
1197
+ async def _abort_session_webtransports(self, session: HTTP3Session) -> None:
1198
+ for webtransport in list(session.webtransport_sessions.values()):
1199
+ with suppress(Exception):
1200
+ await webtransport.abort()
1201
+ session.webtransport_sessions.clear()
1202
+ session.webtransport_streams.clear()
1203
+ session.webtransport_stream_owners.clear()
1204
+ session.webtransport_stream_prefaces.clear()
1205
+
1206
+ def _release_stream_work_lease(self, session: HTTP3Session, stream_id: int) -> None:
1207
+ lease = session.stream_work_leases.pop(stream_id, None)
1208
+ if lease is not None:
1209
+ lease.release()
1210
+
1211
+ def _on_websocket_stream_closed(self, session: HTTP3Session, stream_id: int) -> None:
1212
+ session.websocket_sessions.pop(stream_id, None)
1213
+ self._release_stream_work_lease(session, stream_id)
1214
+ session.h3.abandon_stream(stream_id)
1215
+
1216
+ def _on_webtransport_stream_closed(self, session: HTTP3Session, stream_id: int) -> None:
1217
+ session.webtransport_sessions.pop(stream_id, None)
1218
+ session.webtransport_streams.discard(stream_id)
1219
+ session.webtransport_stream_owners.pop(stream_id, None)
1220
+ session.webtransport_stream_prefaces.pop(stream_id, None)
1221
+ self._release_stream_work_lease(session, stream_id)
1222
+ session.h3.abandon_stream(stream_id)
1223
+
1224
+ def _admit_stream_work(self, session: HTTP3Session, stream_id: int) -> bool:
1225
+ if self.scheduler is None:
1226
+ return True
1227
+ lease = self.scheduler.acquire_work()
1228
+ if lease is None:
1229
+ if self.metrics is not None:
1230
+ self.metrics.scheduler_task_rejected()
1231
+ return False
1232
+ session.stream_work_leases[stream_id] = lease
1233
+ return True
1234
+
1235
+ def _request_target_from_header_map(self, header_map: dict[bytes, bytes]) -> str:
1236
+ method = header_map.get(b':method', b'GET')
1237
+ if method == b'CONNECT' and header_map.get(b':protocol') is None:
1238
+ return header_map.get(b':authority', b'').decode('ascii', 'replace')
1239
+ return header_map.get(b':path', b'/').decode('ascii', 'replace')
1240
+
1241
+ def _build_request(self, request_state: Any, header_map: dict[bytes, bytes]) -> ParsedRequest:
1242
+ method = header_map.get(b':method', b'GET').decode('ascii', 'replace')
1243
+ if method.upper() == 'CONNECT' and header_map.get(b':protocol') is None:
1244
+ target = header_map.get(b':authority', b'').decode('ascii', 'replace')
1245
+ path = target
1246
+ raw_path = target.encode('ascii', 'ignore')
1247
+ query = b''
1248
+ else:
1249
+ target = header_map.get(b':path', b'/').decode('ascii', 'replace')
1250
+ raw_path, _, query = target.encode('ascii', 'ignore').partition(b'?')
1251
+ path = raw_path.decode('utf-8', 'replace')
1252
+ return ParsedRequest(
1253
+ method=method,
1254
+ target=target,
1255
+ path=path,
1256
+ raw_path=raw_path,
1257
+ query_string=query,
1258
+ http_version='3',
1259
+ headers=[(k, v) for k, v in request_state.headers if not k.startswith(b':')],
1260
+ body=request_state.body,
1261
+ keep_alive=True,
1262
+ expect_continue=False,
1263
+ websocket_upgrade=False,
1264
+ )
1265
+
1266
+ async def _start_connect_tunnel_locked(
1267
+ self,
1268
+ session: HTTP3Session,
1269
+ stream_id: int,
1270
+ request_state: Any,
1271
+ header_map: dict[bytes, bytes],
1272
+ endpoint: UDPEndpoint,
1273
+ ) -> list[bytes]:
1274
+ authority = header_map.get(b':authority', b'').decode('ascii', 'replace')
1275
+ try:
1276
+ host, port = parse_connect_authority(authority)
1277
+ except Exception:
1278
+ self.access_logger.log_http(session.addr, 'CONNECT', authority or '', 400, 'HTTP/3')
1279
+ return self._build_http3_response_datagrams_locked(
1280
+ session,
1281
+ stream_id,
1282
+ 400,
1283
+ [(b'content-type', b'text/plain')],
1284
+ b'bad connect target',
1285
+ end_stream=True,
1286
+ )
1287
+ if self.config.http.connect_policy == 'deny':
1288
+ self.access_logger.log_http(session.addr, 'CONNECT', authority or '', 403, 'HTTP/3')
1289
+ return self._build_http3_response_datagrams_locked(
1290
+ session,
1291
+ stream_id,
1292
+ 403,
1293
+ [(b'content-type', b'text/plain')],
1294
+ b'connect denied',
1295
+ end_stream=True,
1296
+ )
1297
+ if self.config.http.connect_policy == 'allowlist' and not is_connect_allowed(host, port, self.config.http.connect_allow):
1298
+ self.access_logger.log_http(session.addr, 'CONNECT', authority or '', 403, 'HTTP/3')
1299
+ return self._build_http3_response_datagrams_locked(
1300
+ session,
1301
+ stream_id,
1302
+ 403,
1303
+ [(b'content-type', b'text/plain')],
1304
+ b'connect denied',
1305
+ end_stream=True,
1306
+ )
1307
+ if not self._admit_stream_work(session, stream_id):
1308
+ self.access_logger.log_http(session.addr, 'CONNECT', authority or '', 503, 'HTTP/3')
1309
+ return self._build_http3_response_datagrams_locked(
1310
+ session,
1311
+ stream_id,
1312
+ 503,
1313
+ [(b'content-type', b'text/plain')],
1314
+ b'scheduler overloaded',
1315
+ end_stream=True,
1316
+ )
1317
+ try:
1318
+ upstream_reader, upstream_writer = await asyncio.wait_for(
1319
+ asyncio.open_connection(host, port),
1320
+ timeout=getattr(self.config, 'read_timeout', 5.0),
1321
+ )
1322
+ except Exception:
1323
+ self._release_stream_work_lease(session, stream_id)
1324
+ self.access_logger.log_http(session.addr, 'CONNECT', authority, 502, 'HTTP/3')
1325
+ return self._build_http3_response_datagrams_locked(
1326
+ session,
1327
+ stream_id,
1328
+ 502,
1329
+ [(b'content-type', b'text/plain')],
1330
+ b'bad gateway',
1331
+ end_stream=True,
1332
+ )
1333
+ tunnel = _HTTP3ConnectTunnel(
1334
+ handler=self,
1335
+ session=session,
1336
+ stream_id=stream_id,
1337
+ authority=authority,
1338
+ endpoint=endpoint,
1339
+ upstream_reader=upstream_reader,
1340
+ upstream_writer=upstream_writer,
1341
+ work_lease=session.stream_work_leases.get(stream_id),
1342
+ )
1343
+ session.connect_tunnels[stream_id] = tunnel
1344
+ tunnel.start()
1345
+ self.access_logger.log_http(session.addr, 'CONNECT', authority, 200, 'HTTP/3')
1346
+ return self._build_http3_response_datagrams_locked(session, stream_id, 200, [], b'', end_stream=False)
1347
+
1348
+ async def _start_websocket_stream_locked(
1349
+ self,
1350
+ session: HTTP3Session,
1351
+ stream_id: int,
1352
+ request_state: Any,
1353
+ header_map: dict[bytes, bytes],
1354
+ endpoint: UDPEndpoint,
1355
+ ) -> list[bytes]:
1356
+ request = self._build_request(request_state, header_map)
1357
+ authority = header_map.get(b':authority')
1358
+ if self.config.allowed_server_names and not authority_allowed(authority, self.config.allowed_server_names):
1359
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 421, 'HTTP/3')
1360
+ return self._build_http3_response_datagrams_locked(
1361
+ session,
1362
+ stream_id,
1363
+ 421,
1364
+ [(b'content-type', b'text/plain')],
1365
+ b'misdirected request',
1366
+ end_stream=True,
1367
+ )
1368
+ local = endpoint.local_addr
1369
+ server = (local[0], local[1]) if isinstance(local, tuple) and len(local) >= 2 else ('', None)
1370
+ scheme = header_map.get(
1371
+ b':scheme',
1372
+ self.listener.scheme.encode('ascii', 'ignore') if self.listener.scheme else b'https',
1373
+ ).decode('ascii', 'replace')
1374
+ if not self._admit_stream_work(session, stream_id):
1375
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 503, 'HTTP/3')
1376
+ return self._build_http3_response_datagrams_locked(
1377
+ session,
1378
+ stream_id,
1379
+ 503,
1380
+ [(b'content-type', b'text/plain')],
1381
+ b'scheduler overloaded',
1382
+ end_stream=True,
1383
+ )
1384
+ try:
1385
+ websocket = H3WebSocketSession(
1386
+ app=self.app,
1387
+ config=self.config,
1388
+ request=request,
1389
+ client=session.addr,
1390
+ server=server,
1391
+ scheme=scheme,
1392
+ send_headers=lambda status, headers, end_stream: self._send_http3_websocket_headers(
1393
+ session,
1394
+ stream_id,
1395
+ status,
1396
+ headers,
1397
+ end_stream=end_stream,
1398
+ endpoint=endpoint,
1399
+ ),
1400
+ send_data=lambda data, end_stream: self._send_http3_websocket_data(
1401
+ session,
1402
+ stream_id,
1403
+ data,
1404
+ end_stream=end_stream,
1405
+ endpoint=endpoint,
1406
+ ),
1407
+ metrics=self.metrics,
1408
+ on_close=lambda session=session, stream_id=stream_id: self._on_websocket_stream_closed(session, stream_id),
1409
+ )
1410
+ except ProtocolError:
1411
+ self._release_stream_work_lease(session, stream_id)
1412
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 400, 'HTTP/3')
1413
+ return self._build_http3_response_datagrams_locked(
1414
+ session,
1415
+ stream_id,
1416
+ 400,
1417
+ [(b'content-type', b'text/plain')],
1418
+ b'bad request',
1419
+ end_stream=True,
1420
+ )
1421
+ session.websocket_sessions[stream_id] = websocket
1422
+ await websocket.start()
1423
+ return []
1424
+
1425
+ async def _start_webtransport_stream_locked(
1426
+ self,
1427
+ session: HTTP3Session,
1428
+ stream_id: int,
1429
+ request_state: Any,
1430
+ header_map: dict[bytes, bytes],
1431
+ endpoint: UDPEndpoint,
1432
+ ) -> list[bytes]:
1433
+ request = self._build_request(request_state, header_map)
1434
+ authority = header_map.get(b':authority')
1435
+ if self.config.allowed_server_names and not authority_allowed(authority, self.config.allowed_server_names):
1436
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 421, 'HTTP/3')
1437
+ return self._build_http3_response_datagrams_locked(
1438
+ session,
1439
+ stream_id,
1440
+ 421,
1441
+ [(b'content-type', b'text/plain')],
1442
+ b'misdirected request',
1443
+ end_stream=True,
1444
+ )
1445
+ if not self._admit_stream_work(session, stream_id):
1446
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 503, 'HTTP/3')
1447
+ return self._build_http3_response_datagrams_locked(
1448
+ session,
1449
+ stream_id,
1450
+ 503,
1451
+ [(b'content-type', b'text/plain')],
1452
+ b'scheduler overloaded',
1453
+ end_stream=True,
1454
+ )
1455
+ response_headers: list[tuple[bytes, bytes]] = []
1456
+ draft = next((value for name, value in request_state.headers if name.lower() == b'sec-webtransport-http3-draft'), None)
1457
+ if draft:
1458
+ response_headers.append((b'sec-webtransport-http3-draft', draft))
1459
+ session.webtransport_streams.add(stream_id)
1460
+ session.webtransport_stream_owners[stream_id] = stream_id
1461
+ local = endpoint.local_addr
1462
+ server = (local[0], local[1]) if isinstance(local, tuple) and len(local) >= 2 else ('', None)
1463
+ webtransport = _HTTP3WebTransportSession(
1464
+ handler=self,
1465
+ session=session,
1466
+ stream_id=stream_id,
1467
+ request=request,
1468
+ client=session.addr,
1469
+ server=server,
1470
+ scheme='https' if self.listener.scheme in {'https', 'wss'} else self.listener.scheme,
1471
+ endpoint=endpoint,
1472
+ work_lease=session.stream_work_leases.get(stream_id),
1473
+ )
1474
+ session.webtransport_sessions[stream_id] = webtransport
1475
+ await webtransport.start()
1476
+ self.access_logger.log_http(session.addr, 'CONNECT', request.path, 200, 'HTTP/3')
1477
+ return self._build_http3_response_datagrams_locked(session, stream_id, 200, response_headers, b'', end_stream=False)
1478
+
1479
+ async def _drain_connect_request_body_locked(
1480
+ self,
1481
+ session: HTTP3Session,
1482
+ stream_id: int,
1483
+ request_state: Any,
1484
+ ) -> None:
1485
+ tunnel = session.connect_tunnels.get(stream_id)
1486
+ if tunnel is None:
1487
+ return
1488
+ chunks = list(request_state.body_parts)
1489
+ request_state.body_parts.clear()
1490
+ await tunnel.feed_client_data(chunks, end_stream=request_state.ended, already_locked=True)
1491
+
1492
+ async def _drain_websocket_request_body_locked(
1493
+ self,
1494
+ session: HTTP3Session,
1495
+ stream_id: int,
1496
+ request_state: Any,
1497
+ endpoint: UDPEndpoint,
1498
+ ) -> None:
1499
+ websocket = session.websocket_sessions.get(stream_id)
1500
+ if websocket is None:
1501
+ return
1502
+ chunks = list(request_state.body_parts)
1503
+ request_state.body_parts.clear()
1504
+ try:
1505
+ await websocket.feed_data(b''.join(chunks), end_stream=request_state.ended)
1506
+ except Exception:
1507
+ await self._reset_http3_websocket_stream(
1508
+ session,
1509
+ stream_id,
1510
+ endpoint,
1511
+ already_locked=True,
1512
+ )
1513
+ await websocket.abort()
1514
+
1515
+ async def _drain_webtransport_request_body_locked(
1516
+ self,
1517
+ session: HTTP3Session,
1518
+ stream_id: int,
1519
+ request_state: Any,
1520
+ ) -> None:
1521
+ webtransport = session.webtransport_sessions.get(stream_id)
1522
+ if webtransport is None:
1523
+ return
1524
+ chunks = list(request_state.body_parts)
1525
+ request_state.body_parts.clear()
1526
+ await webtransport.feed_connect_stream_data(b''.join(chunks), end_stream=request_state.ended)
1527
+
1528
+ async def _respond_ready_requests(self, session: HTTP3Session, endpoint: UDPEndpoint) -> list[bytes]:
1529
+ outbound: list[bytes] = []
1530
+ for request_state in session.h3.ready_request_states():
1531
+ stream_id = request_state.stream_id
1532
+ if not request_state.ended or stream_id in session.responded_streams:
1533
+ continue
1534
+ outbound.extend(await self._invoke_http_app(session, stream_id, request_state, endpoint))
1535
+ session.responded_streams.add(stream_id)
1536
+ return outbound
1537
+
1538
+ def _validate_request_headers(self, headers: list[tuple[bytes, bytes]]) -> dict[bytes, bytes]:
1539
+ pseudo_seen: set[bytes] = set()
1540
+ regular_seen = False
1541
+ header_map: dict[bytes, bytes] = {}
1542
+ for name, value in headers:
1543
+ if any(65 <= byte <= 90 for byte in name):
1544
+ raise ProtocolError('uppercase header field name forbidden')
1545
+ if name.startswith(b':'):
1546
+ if regular_seen:
1547
+ raise ProtocolError('pseudo-header after regular header')
1548
+ if name not in {b':method', b':scheme', b':authority', b':path', b':protocol'}:
1549
+ raise ProtocolError('invalid request pseudo-header')
1550
+ if name in pseudo_seen:
1551
+ raise ProtocolError('duplicate pseudo-header')
1552
+ pseudo_seen.add(name)
1553
+ else:
1554
+ regular_seen = True
1555
+ if name in {b'connection', b'upgrade', b'proxy-connection', b'transfer-encoding'}:
1556
+ raise ProtocolError('connection-specific header forbidden')
1557
+ if name == b'te' and value.lower() != b'trailers':
1558
+ raise ProtocolError('invalid TE header')
1559
+ header_map[name] = value
1560
+ if b':method' not in pseudo_seen:
1561
+ raise ProtocolError('missing :method pseudo-header')
1562
+ method = header_map.get(b':method', b'GET')
1563
+ protocol = header_map.get(b':protocol')
1564
+ if protocol is not None:
1565
+ if method != b'CONNECT':
1566
+ raise ProtocolError('extended CONNECT requires CONNECT method')
1567
+ if b':scheme' not in pseudo_seen or b':path' not in pseudo_seen or b':authority' not in pseudo_seen:
1568
+ raise ProtocolError('extended CONNECT missing required pseudo-headers')
1569
+ return header_map
1570
+ if method == b'CONNECT':
1571
+ if b':authority' not in pseudo_seen:
1572
+ raise ProtocolError('CONNECT missing :authority pseudo-header')
1573
+ if b':scheme' in pseudo_seen or b':path' in pseudo_seen:
1574
+ raise ProtocolError('CONNECT must not include :scheme or :path pseudo-headers')
1575
+ return header_map
1576
+ if b':scheme' not in pseudo_seen or b':path' not in pseudo_seen:
1577
+ raise ProtocolError('missing required request pseudo-header')
1578
+ return header_map
1579
+
1580
+ async def _invoke_http_app(self, session: HTTP3Session, stream_id: int, request_state: Any, endpoint: UDPEndpoint) -> list[bytes]:
1581
+ try:
1582
+ header_map = self._validate_request_headers(list(request_state.headers))
1583
+ scheme = header_map.get(b':scheme', self.listener.scheme.encode('ascii', 'ignore') if self.listener.scheme else b'https').decode('ascii', 'replace')
1584
+ except ProtocolError:
1585
+ header_lines = [(b':status', b'400'), (b'content-type', b'text/plain')]
1586
+ header_block = session.h3.encode_headers(stream_id, header_lines)
1587
+ payload = encode_frame(FRAME_HEADERS, header_block) + encode_frame(FRAME_DATA, b'bad request')
1588
+ return [*self._flush_qpack_streams(session), session.quic.send_stream_data(stream_id, payload, fin=True)]
1589
+ if not self._admit_stream_work(session, stream_id):
1590
+ return self._build_http3_response_datagrams_locked(
1591
+ session,
1592
+ stream_id,
1593
+ 503,
1594
+ [(b'content-type', b'text/plain')],
1595
+ b'scheduler overloaded',
1596
+ end_stream=True,
1597
+ )
1598
+ request = self._build_request(request_state, header_map)
1599
+ client = session.addr
1600
+ local = endpoint.local_addr
1601
+ server = (local[0], local[1]) if isinstance(local, tuple) and len(local) >= 2 else ('', None)
1602
+ extensions = {}
1603
+ raw_request_trailers = list(getattr(request_state, 'trailers', ()))
1604
+ try:
1605
+ request_trailers = apply_request_trailer_policy(raw_request_trailers, self.config.http.trailer_policy)
1606
+ except ProtocolError:
1607
+ self._release_stream_work_lease(session, stream_id)
1608
+ return self._build_http3_response_datagrams_locked(
1609
+ session,
1610
+ stream_id,
1611
+ 400,
1612
+ [(b'content-type', b'text/plain')],
1613
+ b'bad request trailers',
1614
+ end_stream=True,
1615
+ )
1616
+ if request.method.upper() == 'CONNECT':
1617
+ extensions['tigrcorn.http.connect'] = {'authority': request.target}
1618
+ if request_trailers and self.config.http.trailer_policy != 'drop':
1619
+ extensions['tigrcorn.http.request_trailers'] = {}
1620
+ extensions['tigrcorn.http.response.file'] = {'protocol': 'http/3', 'streaming': True, 'sendfile': False}
1621
+ extensions['http.response.pathsend'] = {}
1622
+ authority = header_map.get(b':authority')
1623
+ if self.config.allowed_server_names and not authority_allowed(authority, self.config.allowed_server_names):
1624
+ self._release_stream_work_lease(session, stream_id)
1625
+ self.access_logger.log_http(client, request.method, request.path, 421, 'HTTP/3')
1626
+ return self._build_http3_response_datagrams_locked(
1627
+ session,
1628
+ stream_id,
1629
+ 421,
1630
+ [(b'content-type', b'text/plain')],
1631
+ b'misdirected request',
1632
+ end_stream=True,
1633
+ )
1634
+ if self._should_send_too_early(session):
1635
+ self._release_stream_work_lease(session, stream_id)
1636
+ self.access_logger.log_http(client, request.method, request.path, 425, 'HTTP/3')
1637
+ return self._build_http3_response_datagrams_locked(
1638
+ session,
1639
+ stream_id,
1640
+ 425,
1641
+ [(b'content-type', b'text/plain')],
1642
+ b'too early',
1643
+ end_stream=True,
1644
+ )
1645
+ scope = build_http_scope(request, client=client, server=server, scheme=scheme, extensions=extensions, root_path=self.config.proxy.root_path, proxy=self.config.proxy)
1646
+ receive = HTTPRequestReceive(request.body, trailers=request_trailers, trailer_policy=self.config.http.trailer_policy)
1647
+ send = HTTPResponseCollector()
1648
+ status = 500
1649
+ try:
1650
+ try:
1651
+ await self.app(scope, receive, send)
1652
+ send.finalize()
1653
+ assert send.status is not None
1654
+ status = send.status
1655
+ headers = list(send.headers)
1656
+ trailers = list(send.trailers)
1657
+ informational = list(send.informational_responses)
1658
+ body_segments = list(send.body_segments) if send.uses_streamed_body else None
1659
+ if body_segments is None and send.has_spooled_body():
1660
+ spooled_segments = send.spooled_body_segments()
1661
+ spooled_path = ''
1662
+ if spooled_segments:
1663
+ first_segment = spooled_segments[0]
1664
+ spooled_path = getattr(first_segment, 'path', '')
1665
+ planned = plan_file_backed_response_entity_semantics(
1666
+ method=request.method,
1667
+ request_headers=request.headers,
1668
+ response_headers=headers,
1669
+ status=status,
1670
+ body_path=spooled_path,
1671
+ body_length=send.body_length,
1672
+ generated_etag=send.generated_entity_tag(),
1673
+ apply_content_coding=True,
1674
+ trailers_present=bool(trailers) and request.method.upper() != 'HEAD',
1675
+ )
1676
+ if planned.requires_materialization:
1677
+ body = await send.materialize_body()
1678
+ processed = apply_response_entity_semantics(
1679
+ method=request.method,
1680
+ request_headers=request.headers,
1681
+ response_headers=headers,
1682
+ body=body,
1683
+ status=status,
1684
+ content_coding_policy=self.config.http.content_coding_policy,
1685
+ supported_codings=tuple(self.config.http.content_codings),
1686
+ apply_content_coding=True,
1687
+ generate_etag=True,
1688
+ )
1689
+ status = processed.status
1690
+ headers = processed.headers
1691
+ body = processed.body
1692
+ if processed.head_response:
1693
+ trailers = []
1694
+ elif planned.use_body_segments:
1695
+ status = planned.status
1696
+ headers = planned.headers
1697
+ body_segments = list(planned.body_segments)
1698
+ body = b''
1699
+ else:
1700
+ status = planned.status
1701
+ headers = planned.headers
1702
+ body = planned.body
1703
+ trailers = []
1704
+ elif body_segments is None:
1705
+ body = await send.materialize_body()
1706
+ processed = apply_response_entity_semantics(
1707
+ method=request.method,
1708
+ request_headers=request.headers,
1709
+ response_headers=headers,
1710
+ body=body,
1711
+ status=status,
1712
+ content_coding_policy=self.config.http.content_coding_policy,
1713
+ supported_codings=tuple(self.config.http.content_codings),
1714
+ apply_content_coding=True,
1715
+ generate_etag=True,
1716
+ )
1717
+ status = processed.status
1718
+ headers = processed.headers
1719
+ body = processed.body
1720
+ if processed.head_response:
1721
+ trailers = []
1722
+ except Exception:
1723
+ send.cleanup()
1724
+ status, headers, body, trailers = 500, [(b'content-type', b'text/plain')], b'internal server error', []
1725
+ informational = []
1726
+ body_segments = None
1727
+ if body_segments is not None:
1728
+ await self._send_http3_streamed_response_locked(
1729
+ session,
1730
+ stream_id,
1731
+ status,
1732
+ headers,
1733
+ body_segments,
1734
+ trailers,
1735
+ informational,
1736
+ endpoint,
1737
+ )
1738
+ if self.metrics is not None:
1739
+ self.metrics.http3_request_served()
1740
+ self.access_logger.log_http(client, request.method, request.path, status, 'HTTP/3')
1741
+ self.sessions[session.addr] = session
1742
+ return []
1743
+ headers = apply_response_header_policy(
1744
+ strip_connection_specific_headers(headers),
1745
+ server_header=self.config.server_header_value,
1746
+ include_date_header=self.config.include_date_header,
1747
+ default_headers=self.config.default_response_headers,
1748
+ alt_svc_values=configured_alt_svc_values(self.config, request_http_version='3'),
1749
+ )
1750
+ frame_payload = bytearray()
1751
+ for interim_status, interim_headers in informational:
1752
+ interim_header_block = session.h3.encode_headers(
1753
+ stream_id,
1754
+ [(b':status', str(interim_status).encode('ascii')), *sanitize_early_hints_headers(interim_headers)],
1755
+ )
1756
+ frame_payload.extend(encode_frame(FRAME_HEADERS, interim_header_block))
1757
+ header_lines = [(b':status', str(status).encode('ascii')), *headers]
1758
+ header_block = session.h3.encode_headers(stream_id, header_lines)
1759
+ qpack_outbound = self._flush_qpack_streams(session)
1760
+ frame_payload.extend(encode_frame(FRAME_HEADERS, header_block))
1761
+ if body:
1762
+ frame_payload.extend(encode_frame(FRAME_DATA, body))
1763
+ if trailers:
1764
+ trailer_block = session.h3.encode_headers(stream_id, list(trailers))
1765
+ frame_payload.extend(encode_frame(FRAME_HEADERS, trailer_block))
1766
+ self.access_logger.log_http(client, request.method, request.path, status, 'HTTP/3')
1767
+ self.sessions[session.addr] = session
1768
+ if self.metrics is not None:
1769
+ self.metrics.http3_request_served()
1770
+ return [*qpack_outbound, session.quic.send_stream_data(stream_id, bytes(frame_payload), fin=True)]
1771
+ finally:
1772
+ send.cleanup()
1773
+ self._release_stream_work_lease(session, stream_id)
1774
+
1775
+ async def _invoke_custom_quic_app(self, session: HTTP3Session, event: Any, endpoint: UDPEndpoint) -> list[bytes]:
1776
+ client = session.addr
1777
+ local = endpoint.local_addr
1778
+ server = (local[0], local[1]) if isinstance(local, tuple) and len(local) >= 2 else ('', None)
1779
+ scope = adapt_scope(
1780
+ build_custom_scope(
1781
+ 'tigrcorn.quic',
1782
+ scheme=self.listener.scheme or 'quic',
1783
+ client=client,
1784
+ server=server,
1785
+ stream_id=event.stream_id,
1786
+ packet_number=event.packet_number,
1787
+ extensions={'tigrcorn.custom': {'transport': 'udp', 'protocol': 'quic'}},
1788
+ )
1789
+ )
1790
+ receive = _SingleEventReceive({'type': 'tigrcorn.stream.receive', 'data': event.data, 'more_data': not bool(event.fin)})
1791
+ send = _CustomQuicSend(session=session, stream_id=event.stream_id)
1792
+ await self.app(scope, receive, send)
1793
+ return send.flush()
1794
+
1795
+
1796
+ class _SingleEventReceive:
1797
+ def __init__(self, event: dict) -> None:
1798
+ self.event = event
1799
+ self.sent = False
1800
+
1801
+ async def __call__(self) -> dict:
1802
+ if not self.sent:
1803
+ self.sent = True
1804
+ return self.event
1805
+ return {'type': 'tigrcorn.stream.disconnect'}
1806
+
1807
+
1808
+ class _CustomQuicSend:
1809
+ def __init__(self, *, session: HTTP3Session, stream_id: int | None) -> None:
1810
+ self.session = session
1811
+ self.stream_id = 0 if stream_id is None else stream_id
1812
+ self.messages: list[bytes] = []
1813
+
1814
+ async def __call__(self, message: dict) -> None:
1815
+ typ = message.get('type')
1816
+ if typ != 'tigrcorn.stream.send':
1817
+ raise RuntimeError(f'unexpected custom quic send event: {typ!r}')
1818
+ data = bytes(message.get('data', b''))
1819
+ fin = not bool(message.get('more_data', False))
1820
+ self.messages.append(self.session.quic.send_stream_data(self.stream_id, data, fin=fin))
1821
+
1822
+ def flush(self) -> list[bytes]:
1823
+ return list(self.messages)