boring-http 1.2.0__tar.gz → 2.0.0__tar.gz
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.
- {boring_http-1.2.0 → boring_http-2.0.0}/PKG-INFO +177 -33
- {boring_http-1.2.0 → boring_http-2.0.0}/README.md +176 -32
- {boring_http-1.2.0 → boring_http-2.0.0}/pyproject.toml +1 -1
- {boring_http-1.2.0 → boring_http-2.0.0}/pyproject.toml.orig +1 -1
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/__init__.py +7 -7
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/client.py +18 -8
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/http2.py +14 -35
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/http3.py +18 -30
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/connection.py +19 -35
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/codings.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/cookies.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/errors.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/hpack.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/hpack_tables.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/http1.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/message.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/pool.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/py.typed +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/qpack.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/qpack_tables.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/__init__.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/packet.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/protocol.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/recovery.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/quic/streams.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/revocation.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/timing.py +0 -0
- {boring_http-1.2.0 → boring_http-2.0.0}/src/boring_http/websocket.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: boring-http
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order
|
|
5
5
|
Requires-Dist: boring-ssl2>=1.2.0
|
|
6
6
|
Requires-Dist: ssl2>=1.2.0
|
|
@@ -21,11 +21,14 @@ uv add boring-http
|
|
|
21
21
|
```python
|
|
22
22
|
import asyncio
|
|
23
23
|
|
|
24
|
-
from boring_http import Client
|
|
24
|
+
from boring_http import Client, Http2Settings
|
|
25
|
+
from ssl2 import AkamaiFingerprint
|
|
26
|
+
|
|
27
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
async def main() -> None:
|
|
28
|
-
async with Client(headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
|
|
31
|
+
async with Client(http2=http2, headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
|
|
29
32
|
response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
|
|
30
33
|
print(response.status, response.version, response.content_type) # 200 h2 application/json
|
|
31
34
|
report = response.json()
|
|
@@ -46,11 +49,14 @@ Certificates are checked against the system trust store, or against `trust_store
|
|
|
46
49
|
```python
|
|
47
50
|
import asyncio
|
|
48
51
|
|
|
49
|
-
from boring_http import Client
|
|
52
|
+
from boring_http import Client, Http2Settings
|
|
53
|
+
from ssl2 import AkamaiFingerprint
|
|
54
|
+
|
|
55
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
50
56
|
|
|
51
57
|
|
|
52
58
|
async def main() -> None:
|
|
53
|
-
async with Client() as client:
|
|
59
|
+
async with Client(http2=http2) as client:
|
|
54
60
|
response = await client.get("https://httpbingo.org/redirect/2")
|
|
55
61
|
print(response.status, response.url.path, [step.status for step in response.history]) # 200 /get [302, 302]
|
|
56
62
|
|
|
@@ -71,11 +77,14 @@ asyncio.run(main())
|
|
|
71
77
|
```python
|
|
72
78
|
import asyncio
|
|
73
79
|
|
|
74
|
-
from boring_http import Client, Timeout, TimeoutError
|
|
80
|
+
from boring_http import Client, Http2Settings, Timeout, TimeoutError
|
|
81
|
+
from ssl2 import AkamaiFingerprint
|
|
82
|
+
|
|
83
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
75
84
|
|
|
76
85
|
|
|
77
86
|
async def main() -> None:
|
|
78
|
-
async with Client(timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
|
|
87
|
+
async with Client(http2=http2, timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
|
|
79
88
|
async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
|
|
80
89
|
size = sum([len(chunk) async for chunk in response.aiter_bytes()])
|
|
81
90
|
print(response.status, size) # 200 100000
|
|
@@ -95,17 +104,47 @@ A single number sets every phase and the total, so the request gives up after th
|
|
|
95
104
|
|
|
96
105
|
```python
|
|
97
106
|
import asyncio
|
|
98
|
-
|
|
99
|
-
|
|
107
|
+
from dataclasses import replace
|
|
108
|
+
|
|
109
|
+
from boring_http import Client, Http2Settings, Http3Settings, Parameter, QuicVersion, Setting, TransportParameters
|
|
110
|
+
from ssl2 import AkamaiFingerprint, PseudoHeader
|
|
111
|
+
|
|
112
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
113
|
+
http3 = Http3Settings(
|
|
114
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
115
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
116
|
+
transport=TransportParameters(
|
|
117
|
+
max_idle_timeout=30000,
|
|
118
|
+
max_udp_payload_size=1472,
|
|
119
|
+
initial_max_data=15728640,
|
|
120
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
121
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
122
|
+
initial_max_stream_data_uni=6291456,
|
|
123
|
+
initial_max_streams_bidi=100,
|
|
124
|
+
initial_max_streams_uni=100,
|
|
125
|
+
order=(
|
|
126
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
127
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
128
|
+
Parameter.INITIAL_MAX_DATA,
|
|
129
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
130
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
131
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
132
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
133
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
134
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
135
|
+
Parameter.VERSION_INFORMATION,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
100
139
|
|
|
101
140
|
|
|
102
141
|
async def main() -> None:
|
|
103
|
-
async with Client(versions=("h3", "h2", "h1")) as client:
|
|
142
|
+
async with Client(versions=("h3", "h2", "h1"), http2=http2, http3=http3) as client:
|
|
104
143
|
first = await client.get("https://pingly.us.kg/api/all")
|
|
105
144
|
second = await client.get("https://pingly.us.kg/api/all")
|
|
106
145
|
print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
|
|
107
146
|
|
|
108
|
-
async with Client(versions=("h3",), http3=
|
|
147
|
+
async with Client(versions=("h3",), http3=replace(http3, quic_version=QuicVersion.V2)) as client:
|
|
109
148
|
response = await client.get("https://pingly.us.kg/api/all")
|
|
110
149
|
print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
|
|
111
150
|
|
|
@@ -113,30 +152,58 @@ async def main() -> None:
|
|
|
113
152
|
asyncio.run(main())
|
|
114
153
|
```
|
|
115
154
|
|
|
116
|
-
The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with
|
|
155
|
+
The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order; `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends the round trip time measured last time on a resumed connection. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
|
|
117
156
|
|
|
118
157
|
## Proxies
|
|
119
158
|
|
|
120
159
|
```python
|
|
121
160
|
import asyncio
|
|
122
161
|
|
|
123
|
-
from boring_http import Client
|
|
124
|
-
from ssl2 import ProxyError
|
|
162
|
+
from boring_http import Client, Http2Settings, Http3Settings, Parameter, Setting, TransportParameters
|
|
163
|
+
from ssl2 import AkamaiFingerprint, ProxyError, PseudoHeader
|
|
164
|
+
|
|
165
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
166
|
+
http3 = Http3Settings(
|
|
167
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
168
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
169
|
+
transport=TransportParameters(
|
|
170
|
+
max_idle_timeout=30000,
|
|
171
|
+
max_udp_payload_size=1472,
|
|
172
|
+
initial_max_data=15728640,
|
|
173
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
174
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
175
|
+
initial_max_stream_data_uni=6291456,
|
|
176
|
+
initial_max_streams_bidi=100,
|
|
177
|
+
initial_max_streams_uni=100,
|
|
178
|
+
order=(
|
|
179
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
180
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
181
|
+
Parameter.INITIAL_MAX_DATA,
|
|
182
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
183
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
184
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
185
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
186
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
187
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
188
|
+
Parameter.VERSION_INFORMATION,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
)
|
|
125
192
|
|
|
126
193
|
|
|
127
194
|
async def main() -> None:
|
|
128
|
-
async with Client(proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
195
|
+
async with Client(http2=http2, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
129
196
|
response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 CONNECT, HTTP/2 inside
|
|
130
197
|
print(response.version, response.json()["http_version"]) # h2 HTTP/2.0
|
|
131
198
|
response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128") # per request
|
|
132
199
|
print(response.version) # h2
|
|
133
200
|
|
|
134
|
-
async with Client(versions=("h3",), proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
201
|
+
async with Client(versions=("h3",), http3=http3, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
135
202
|
response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
|
|
136
203
|
print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
|
|
137
204
|
|
|
138
205
|
try:
|
|
139
|
-
async with Client(proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
|
|
206
|
+
async with Client(http2=http2, proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
|
|
140
207
|
await client.get("https://pingly.us.kg/api/all")
|
|
141
208
|
except ProxyError as refusal:
|
|
142
209
|
print(refusal) # the SOCKS5 proxy rejected the username and password
|
|
@@ -152,14 +219,16 @@ asyncio.run(main())
|
|
|
152
219
|
```python
|
|
153
220
|
import asyncio
|
|
154
221
|
|
|
155
|
-
from boring_http import
|
|
222
|
+
from boring_http import Client, Http2Settings
|
|
156
223
|
from ssl2 import (
|
|
157
224
|
JA4,
|
|
225
|
+
AkamaiFingerprint,
|
|
158
226
|
CertificateCompressionAlgorithm,
|
|
159
227
|
CipherSuite,
|
|
160
228
|
ClientHelloBuilder,
|
|
161
229
|
ECPointFormat,
|
|
162
230
|
ExtensionType,
|
|
231
|
+
Http2Priority,
|
|
163
232
|
NamedGroup,
|
|
164
233
|
NextProtocol,
|
|
165
234
|
ProtocolVersion,
|
|
@@ -167,7 +236,7 @@ from ssl2 import (
|
|
|
167
236
|
SignatureScheme,
|
|
168
237
|
)
|
|
169
238
|
|
|
170
|
-
|
|
239
|
+
hello = (
|
|
171
240
|
ClientHelloBuilder()
|
|
172
241
|
.supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
|
|
173
242
|
.cipher_suites(
|
|
@@ -224,13 +293,17 @@ profile = (
|
|
|
224
293
|
.psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
|
|
225
294
|
.build()
|
|
226
295
|
)
|
|
296
|
+
http2 = Http2Settings(
|
|
297
|
+
AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"),
|
|
298
|
+
headers_priority=Http2Priority(stream_id=0, exclusive=True, dependency=0, weight=256),
|
|
299
|
+
)
|
|
227
300
|
|
|
228
301
|
|
|
229
302
|
async def main() -> None:
|
|
230
|
-
async with Client(tls=
|
|
303
|
+
async with Client(tls=hello, http2=http2) as client:
|
|
231
304
|
report = (await client.get("https://pingly.us.kg/api/all")).json()
|
|
232
|
-
print(report["tls"]["ja4"] == JA4.of(
|
|
233
|
-
print(report["http2"]["akamai_fingerprint"] ==
|
|
305
|
+
print(report["tls"]["ja4"] == JA4.of(hello).text, report["tls"]["ja4"]) # True t13d1516h2_8daaf6152771_d8a2da3f94cd
|
|
306
|
+
print(report["http2"]["akamai_fingerprint"] == http2.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
|
|
234
307
|
|
|
235
308
|
|
|
236
309
|
asyncio.run(main())
|
|
@@ -241,9 +314,8 @@ Over HTTP/3 the same holds, with the ClientHello Chrome 155 sends on QUIC: TLS 1
|
|
|
241
314
|
```python
|
|
242
315
|
import asyncio
|
|
243
316
|
|
|
244
|
-
from boring_http import Client,
|
|
317
|
+
from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
|
|
245
318
|
from ssl2 import (
|
|
246
|
-
CHROME_TRUST_ANCHOR_IDS,
|
|
247
319
|
JA4,
|
|
248
320
|
CertificateCompressionAlgorithm,
|
|
249
321
|
CipherSuite,
|
|
@@ -253,11 +325,12 @@ from ssl2 import (
|
|
|
253
325
|
NamedGroup,
|
|
254
326
|
NextProtocol,
|
|
255
327
|
ProtocolVersion,
|
|
328
|
+
PseudoHeader,
|
|
256
329
|
PskKeyExchangeMode,
|
|
257
330
|
SignatureScheme,
|
|
258
331
|
)
|
|
259
332
|
|
|
260
|
-
|
|
333
|
+
quic_hello = (
|
|
261
334
|
ClientHelloBuilder()
|
|
262
335
|
.supported_versions(ProtocolVersion.TLS_1_3)
|
|
263
336
|
.cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
|
|
@@ -293,37 +366,80 @@ quic_profile = (
|
|
|
293
366
|
.application_settings(NextProtocol.H3)
|
|
294
367
|
.certificate_compression(CertificateCompressionAlgorithm.brotli)
|
|
295
368
|
.psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
|
|
296
|
-
.trust_anchor_ids(
|
|
369
|
+
.trust_anchor_ids(
|
|
370
|
+
"44947.2.1", "44947.2.6", "44947.2.13", "44947.2.14", "44947.2.15", "44947.2.18", "44947.2.19", "44947.2.20",
|
|
371
|
+
"52580.200109.1.7", "52580.200109.1.8", "52580.200109.1.9", "52580.200109.1.10", "52580.200109.1.11", "52580.200109.1.12", "52580.200109.1.13", "52580.200109.1.18", "52580.200109.1.19",
|
|
372
|
+
"11129.9.1", "11129.9.4", "11129.9.5", "11129.9.6", "11129.9.7", "11129.9.8", "11129.9.10", "11129.9.11", "11129.9.12", "11129.9.13", "11129.9.15",
|
|
373
|
+
)
|
|
297
374
|
.grease()
|
|
298
375
|
.permute_extensions()
|
|
299
376
|
.build()
|
|
300
377
|
)
|
|
378
|
+
http3 = Http3Settings(
|
|
379
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100), (Setting.H3_DATAGRAM, 1)),
|
|
380
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
381
|
+
transport=TransportParameters(
|
|
382
|
+
max_idle_timeout=30000,
|
|
383
|
+
max_udp_payload_size=1472,
|
|
384
|
+
initial_max_data=15728640,
|
|
385
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
386
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
387
|
+
initial_max_stream_data_uni=6291456,
|
|
388
|
+
initial_max_streams_bidi=100,
|
|
389
|
+
initial_max_streams_uni=103,
|
|
390
|
+
order=(
|
|
391
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
392
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
393
|
+
Parameter.INITIAL_MAX_DATA,
|
|
394
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
395
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
396
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
397
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
398
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
399
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
400
|
+
Parameter.VERSION_INFORMATION,
|
|
401
|
+
Parameter.MAX_DATAGRAM_FRAME_SIZE,
|
|
402
|
+
Parameter.GOOGLE_CONNECTION_OPTIONS,
|
|
403
|
+
Parameter.INITIAL_ROUND_TRIP_TIME,
|
|
404
|
+
),
|
|
405
|
+
max_datagram_frame_size=65536,
|
|
406
|
+
google_connection_options=(b"ORIG",),
|
|
407
|
+
grease=True,
|
|
408
|
+
grease_version=True,
|
|
409
|
+
shuffle=True,
|
|
410
|
+
),
|
|
411
|
+
tls=quic_hello,
|
|
412
|
+
grease=True,
|
|
413
|
+
)
|
|
301
414
|
|
|
302
415
|
|
|
303
416
|
async def main() -> None:
|
|
304
|
-
async with Client(versions=("h3",), http3=
|
|
417
|
+
async with Client(versions=("h3",), http3=http3, headers=[("priority", "u=0, i")]) as client:
|
|
305
418
|
_ = await client.get("https://pingly.us.kg/api/all")
|
|
306
419
|
await client.close() # the next request opens a new connection, which resumes the session
|
|
307
420
|
report = (await client.get("https://pingly.us.kg/api/all")).json()
|
|
308
|
-
print(report["tls"]["ja4"] == JA4.of(
|
|
421
|
+
print(report["tls"]["ja4"] == JA4.of(quic_hello, JA4Transport.QUIC).text, report["tls"]["ja4"]) # True q13d0313h3_55b375c5d22e_b0954bf1abdf
|
|
309
422
|
print(report["http3"]["h3_text"]) # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p
|
|
310
423
|
|
|
311
424
|
|
|
312
425
|
asyncio.run(main())
|
|
313
426
|
```
|
|
314
427
|
|
|
315
|
-
`tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `
|
|
428
|
+
`tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in `versions` or in the ClientHello needs `http2`, and `h3` in `versions` needs `http3`.
|
|
316
429
|
|
|
317
430
|
## WebSocket
|
|
318
431
|
|
|
319
432
|
```python
|
|
320
433
|
import asyncio
|
|
321
434
|
|
|
322
|
-
from boring_http import Client
|
|
435
|
+
from boring_http import Client, Http2Settings
|
|
436
|
+
from ssl2 import AkamaiFingerprint
|
|
437
|
+
|
|
438
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
323
439
|
|
|
324
440
|
|
|
325
441
|
async def main() -> None:
|
|
326
|
-
async with Client() as client:
|
|
442
|
+
async with Client(http2=http2) as client:
|
|
327
443
|
websocket = await client.websocket("wss://echo.websocket.org/")
|
|
328
444
|
print((await websocket.receive())[:17]) # Request served by
|
|
329
445
|
await websocket.send("hello")
|
|
@@ -344,11 +460,39 @@ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extend
|
|
|
344
460
|
```python
|
|
345
461
|
import uvloop
|
|
346
462
|
|
|
347
|
-
from boring_http import Client
|
|
463
|
+
from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
|
|
464
|
+
from ssl2 import PseudoHeader
|
|
465
|
+
|
|
466
|
+
http3 = Http3Settings(
|
|
467
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
468
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
469
|
+
transport=TransportParameters(
|
|
470
|
+
max_idle_timeout=30000,
|
|
471
|
+
max_udp_payload_size=1472,
|
|
472
|
+
initial_max_data=15728640,
|
|
473
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
474
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
475
|
+
initial_max_stream_data_uni=6291456,
|
|
476
|
+
initial_max_streams_bidi=100,
|
|
477
|
+
initial_max_streams_uni=100,
|
|
478
|
+
order=(
|
|
479
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
480
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
481
|
+
Parameter.INITIAL_MAX_DATA,
|
|
482
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
483
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
484
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
485
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
486
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
487
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
488
|
+
Parameter.VERSION_INFORMATION,
|
|
489
|
+
),
|
|
490
|
+
),
|
|
491
|
+
)
|
|
348
492
|
|
|
349
493
|
|
|
350
494
|
async def main() -> None:
|
|
351
|
-
async with Client(versions=("h3",)) as client:
|
|
495
|
+
async with Client(versions=("h3",), http3=http3) as client:
|
|
352
496
|
print((await client.get("https://pingly.us.kg/api/all")).version) # h3
|
|
353
497
|
|
|
354
498
|
|
|
@@ -11,11 +11,14 @@ uv add boring-http
|
|
|
11
11
|
```python
|
|
12
12
|
import asyncio
|
|
13
13
|
|
|
14
|
-
from boring_http import Client
|
|
14
|
+
from boring_http import Client, Http2Settings
|
|
15
|
+
from ssl2 import AkamaiFingerprint
|
|
16
|
+
|
|
17
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
async def main() -> None:
|
|
18
|
-
async with Client(headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
|
|
21
|
+
async with Client(http2=http2, headers=[("User-Agent", "boring-http/0.1"), ("Accept", "application/json")]) as client:
|
|
19
22
|
response = await client.get("https://pingly.us.kg/api/all", params={"page": 2})
|
|
20
23
|
print(response.status, response.version, response.content_type) # 200 h2 application/json
|
|
21
24
|
report = response.json()
|
|
@@ -36,11 +39,14 @@ Certificates are checked against the system trust store, or against `trust_store
|
|
|
36
39
|
```python
|
|
37
40
|
import asyncio
|
|
38
41
|
|
|
39
|
-
from boring_http import Client
|
|
42
|
+
from boring_http import Client, Http2Settings
|
|
43
|
+
from ssl2 import AkamaiFingerprint
|
|
44
|
+
|
|
45
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
40
46
|
|
|
41
47
|
|
|
42
48
|
async def main() -> None:
|
|
43
|
-
async with Client() as client:
|
|
49
|
+
async with Client(http2=http2) as client:
|
|
44
50
|
response = await client.get("https://httpbingo.org/redirect/2")
|
|
45
51
|
print(response.status, response.url.path, [step.status for step in response.history]) # 200 /get [302, 302]
|
|
46
52
|
|
|
@@ -61,11 +67,14 @@ asyncio.run(main())
|
|
|
61
67
|
```python
|
|
62
68
|
import asyncio
|
|
63
69
|
|
|
64
|
-
from boring_http import Client, Timeout, TimeoutError
|
|
70
|
+
from boring_http import Client, Http2Settings, Timeout, TimeoutError
|
|
71
|
+
from ssl2 import AkamaiFingerprint
|
|
72
|
+
|
|
73
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
65
74
|
|
|
66
75
|
|
|
67
76
|
async def main() -> None:
|
|
68
|
-
async with Client(timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
|
|
77
|
+
async with Client(http2=http2, timeout=Timeout(connect=5, read=10, write=10, total=30)) as client:
|
|
69
78
|
async with client.stream("GET", "https://httpbingo.org/stream-bytes/100000") as response:
|
|
70
79
|
size = sum([len(chunk) async for chunk in response.aiter_bytes()])
|
|
71
80
|
print(response.status, size) # 200 100000
|
|
@@ -85,17 +94,47 @@ A single number sets every phase and the total, so the request gives up after th
|
|
|
85
94
|
|
|
86
95
|
```python
|
|
87
96
|
import asyncio
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
from dataclasses import replace
|
|
98
|
+
|
|
99
|
+
from boring_http import Client, Http2Settings, Http3Settings, Parameter, QuicVersion, Setting, TransportParameters
|
|
100
|
+
from ssl2 import AkamaiFingerprint, PseudoHeader
|
|
101
|
+
|
|
102
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
103
|
+
http3 = Http3Settings(
|
|
104
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
105
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
106
|
+
transport=TransportParameters(
|
|
107
|
+
max_idle_timeout=30000,
|
|
108
|
+
max_udp_payload_size=1472,
|
|
109
|
+
initial_max_data=15728640,
|
|
110
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
111
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
112
|
+
initial_max_stream_data_uni=6291456,
|
|
113
|
+
initial_max_streams_bidi=100,
|
|
114
|
+
initial_max_streams_uni=100,
|
|
115
|
+
order=(
|
|
116
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
117
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
118
|
+
Parameter.INITIAL_MAX_DATA,
|
|
119
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
120
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
121
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
122
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
123
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
124
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
125
|
+
Parameter.VERSION_INFORMATION,
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
90
129
|
|
|
91
130
|
|
|
92
131
|
async def main() -> None:
|
|
93
|
-
async with Client(versions=("h3", "h2", "h1")) as client:
|
|
132
|
+
async with Client(versions=("h3", "h2", "h1"), http2=http2, http3=http3) as client:
|
|
94
133
|
first = await client.get("https://pingly.us.kg/api/all")
|
|
95
134
|
second = await client.get("https://pingly.us.kg/api/all")
|
|
96
135
|
print(first.version, second.version) # h2 h3, the Alt-Svc header taught the client that this origin speaks HTTP/3
|
|
97
136
|
|
|
98
|
-
async with Client(versions=("h3",), http3=
|
|
137
|
+
async with Client(versions=("h3",), http3=replace(http3, quic_version=QuicVersion.V2)) as client:
|
|
99
138
|
response = await client.get("https://pingly.us.kg/api/all")
|
|
100
139
|
print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
|
|
101
140
|
|
|
@@ -103,30 +142,58 @@ async def main() -> None:
|
|
|
103
142
|
asyncio.run(main())
|
|
104
143
|
```
|
|
105
144
|
|
|
106
|
-
The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with
|
|
145
|
+
The default is `versions=("h2", "h1")`. With `"h3"` in the list the client switches an origin to QUIC after an `Alt-Svc: h3` header and falls back to TCP when the QUIC route stops answering; `versions=("h3",)` goes straight to QUIC. QUIC v1 is the default; `quic_version=QuicVersion.V2` offers v2 through compatible version negotiation (RFC 9368), so the first packet is still v1 and a server that speaks v2, such as aioquic, ngtcp2, haproxy or haskell quic, switches to it, while every other server simply stays on v1. Retry packets, version negotiation, loss recovery, pacing, flow control, key updates, path MTU probing from 1200 up to 1452 bytes with the don't-fragment flag set, QPACK with the dynamic table the SETTINGS allow on the decoding side and 0-RTT are handled; connection migration is not. A second connection to an origin resumes the TLS session and sends GET, HEAD and OPTIONS requests before the handshake has finished, under the limits and SETTINGS the server gave last time; other methods wait for the handshake, a 425 answer is retried after it, and a server that declines the early data gets it again under the 1-RTT keys. `TransportParameters` sends the parameters in `order`, in that order; `grease=True` adds a GREASE entry, `grease_version=True` a GREASE version, `shuffle=True` a fresh random order on every connection, and `INITIAL_ROUND_TRIP_TIME` in `order` sends the round trip time measured last time on a resumed connection. The control stream carries the SETTINGS of the `Http3Settings`, a GREASE entry and a GREASE frame with `grease=True`, and a PRIORITY_UPDATE for every request that has a `priority` header.
|
|
107
146
|
|
|
108
147
|
## Proxies
|
|
109
148
|
|
|
110
149
|
```python
|
|
111
150
|
import asyncio
|
|
112
151
|
|
|
113
|
-
from boring_http import Client
|
|
114
|
-
from ssl2 import ProxyError
|
|
152
|
+
from boring_http import Client, Http2Settings, Http3Settings, Parameter, Setting, TransportParameters
|
|
153
|
+
from ssl2 import AkamaiFingerprint, ProxyError, PseudoHeader
|
|
154
|
+
|
|
155
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
156
|
+
http3 = Http3Settings(
|
|
157
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
158
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
159
|
+
transport=TransportParameters(
|
|
160
|
+
max_idle_timeout=30000,
|
|
161
|
+
max_udp_payload_size=1472,
|
|
162
|
+
initial_max_data=15728640,
|
|
163
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
164
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
165
|
+
initial_max_stream_data_uni=6291456,
|
|
166
|
+
initial_max_streams_bidi=100,
|
|
167
|
+
initial_max_streams_uni=100,
|
|
168
|
+
order=(
|
|
169
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
170
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
171
|
+
Parameter.INITIAL_MAX_DATA,
|
|
172
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
173
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
174
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
175
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
176
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
177
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
178
|
+
Parameter.VERSION_INFORMATION,
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
)
|
|
115
182
|
|
|
116
183
|
|
|
117
184
|
async def main() -> None:
|
|
118
|
-
async with Client(proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
185
|
+
async with Client(http2=http2, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
119
186
|
response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 CONNECT, HTTP/2 inside
|
|
120
187
|
print(response.version, response.json()["http_version"]) # h2 HTTP/2.0
|
|
121
188
|
response = await client.get("https://pingly.us.kg/api/all", proxy="http://alice:secret@127.0.0.1:3128") # per request
|
|
122
189
|
print(response.version) # h2
|
|
123
190
|
|
|
124
|
-
async with Client(versions=("h3",), proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
191
|
+
async with Client(versions=("h3",), http3=http3, proxy="socks5h://alice:secret@127.0.0.1:1080") as client:
|
|
125
192
|
response = await client.get("https://pingly.us.kg/api/all") # SOCKS5 UDP ASSOCIATE, QUIC datagrams through the relay
|
|
126
193
|
print(response.version, response.json()["http_version"]) # h3 HTTP/3.0
|
|
127
194
|
|
|
128
195
|
try:
|
|
129
|
-
async with Client(proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
|
|
196
|
+
async with Client(http2=http2, proxy="socks5h://alice:wrong@127.0.0.1:1080") as client:
|
|
130
197
|
await client.get("https://pingly.us.kg/api/all")
|
|
131
198
|
except ProxyError as refusal:
|
|
132
199
|
print(refusal) # the SOCKS5 proxy rejected the username and password
|
|
@@ -142,14 +209,16 @@ asyncio.run(main())
|
|
|
142
209
|
```python
|
|
143
210
|
import asyncio
|
|
144
211
|
|
|
145
|
-
from boring_http import
|
|
212
|
+
from boring_http import Client, Http2Settings
|
|
146
213
|
from ssl2 import (
|
|
147
214
|
JA4,
|
|
215
|
+
AkamaiFingerprint,
|
|
148
216
|
CertificateCompressionAlgorithm,
|
|
149
217
|
CipherSuite,
|
|
150
218
|
ClientHelloBuilder,
|
|
151
219
|
ECPointFormat,
|
|
152
220
|
ExtensionType,
|
|
221
|
+
Http2Priority,
|
|
153
222
|
NamedGroup,
|
|
154
223
|
NextProtocol,
|
|
155
224
|
ProtocolVersion,
|
|
@@ -157,7 +226,7 @@ from ssl2 import (
|
|
|
157
226
|
SignatureScheme,
|
|
158
227
|
)
|
|
159
228
|
|
|
160
|
-
|
|
229
|
+
hello = (
|
|
161
230
|
ClientHelloBuilder()
|
|
162
231
|
.supported_versions(ProtocolVersion.TLS_1_3, ProtocolVersion.TLS_1_2)
|
|
163
232
|
.cipher_suites(
|
|
@@ -214,13 +283,17 @@ profile = (
|
|
|
214
283
|
.psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
|
|
215
284
|
.build()
|
|
216
285
|
)
|
|
286
|
+
http2 = Http2Settings(
|
|
287
|
+
AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"),
|
|
288
|
+
headers_priority=Http2Priority(stream_id=0, exclusive=True, dependency=0, weight=256),
|
|
289
|
+
)
|
|
217
290
|
|
|
218
291
|
|
|
219
292
|
async def main() -> None:
|
|
220
|
-
async with Client(tls=
|
|
293
|
+
async with Client(tls=hello, http2=http2) as client:
|
|
221
294
|
report = (await client.get("https://pingly.us.kg/api/all")).json()
|
|
222
|
-
print(report["tls"]["ja4"] == JA4.of(
|
|
223
|
-
print(report["http2"]["akamai_fingerprint"] ==
|
|
295
|
+
print(report["tls"]["ja4"] == JA4.of(hello).text, report["tls"]["ja4"]) # True t13d1516h2_8daaf6152771_d8a2da3f94cd
|
|
296
|
+
print(report["http2"]["akamai_fingerprint"] == http2.fingerprint.text, report["http2"]["akamai_fingerprint"]) # True 1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p
|
|
224
297
|
|
|
225
298
|
|
|
226
299
|
asyncio.run(main())
|
|
@@ -231,9 +304,8 @@ Over HTTP/3 the same holds, with the ClientHello Chrome 155 sends on QUIC: TLS 1
|
|
|
231
304
|
```python
|
|
232
305
|
import asyncio
|
|
233
306
|
|
|
234
|
-
from boring_http import Client,
|
|
307
|
+
from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
|
|
235
308
|
from ssl2 import (
|
|
236
|
-
CHROME_TRUST_ANCHOR_IDS,
|
|
237
309
|
JA4,
|
|
238
310
|
CertificateCompressionAlgorithm,
|
|
239
311
|
CipherSuite,
|
|
@@ -243,11 +315,12 @@ from ssl2 import (
|
|
|
243
315
|
NamedGroup,
|
|
244
316
|
NextProtocol,
|
|
245
317
|
ProtocolVersion,
|
|
318
|
+
PseudoHeader,
|
|
246
319
|
PskKeyExchangeMode,
|
|
247
320
|
SignatureScheme,
|
|
248
321
|
)
|
|
249
322
|
|
|
250
|
-
|
|
323
|
+
quic_hello = (
|
|
251
324
|
ClientHelloBuilder()
|
|
252
325
|
.supported_versions(ProtocolVersion.TLS_1_3)
|
|
253
326
|
.cipher_suites(CipherSuite.TLS_AES_128_GCM_SHA256, CipherSuite.TLS_AES_256_GCM_SHA384, CipherSuite.TLS_CHACHA20_POLY1305_SHA256)
|
|
@@ -283,37 +356,80 @@ quic_profile = (
|
|
|
283
356
|
.application_settings(NextProtocol.H3)
|
|
284
357
|
.certificate_compression(CertificateCompressionAlgorithm.brotli)
|
|
285
358
|
.psk_key_exchange_modes(PskKeyExchangeMode.psk_dhe_ke)
|
|
286
|
-
.trust_anchor_ids(
|
|
359
|
+
.trust_anchor_ids(
|
|
360
|
+
"44947.2.1", "44947.2.6", "44947.2.13", "44947.2.14", "44947.2.15", "44947.2.18", "44947.2.19", "44947.2.20",
|
|
361
|
+
"52580.200109.1.7", "52580.200109.1.8", "52580.200109.1.9", "52580.200109.1.10", "52580.200109.1.11", "52580.200109.1.12", "52580.200109.1.13", "52580.200109.1.18", "52580.200109.1.19",
|
|
362
|
+
"11129.9.1", "11129.9.4", "11129.9.5", "11129.9.6", "11129.9.7", "11129.9.8", "11129.9.10", "11129.9.11", "11129.9.12", "11129.9.13", "11129.9.15",
|
|
363
|
+
)
|
|
287
364
|
.grease()
|
|
288
365
|
.permute_extensions()
|
|
289
366
|
.build()
|
|
290
367
|
)
|
|
368
|
+
http3 = Http3Settings(
|
|
369
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100), (Setting.H3_DATAGRAM, 1)),
|
|
370
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
371
|
+
transport=TransportParameters(
|
|
372
|
+
max_idle_timeout=30000,
|
|
373
|
+
max_udp_payload_size=1472,
|
|
374
|
+
initial_max_data=15728640,
|
|
375
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
376
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
377
|
+
initial_max_stream_data_uni=6291456,
|
|
378
|
+
initial_max_streams_bidi=100,
|
|
379
|
+
initial_max_streams_uni=103,
|
|
380
|
+
order=(
|
|
381
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
382
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
383
|
+
Parameter.INITIAL_MAX_DATA,
|
|
384
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
385
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
386
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
387
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
388
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
389
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
390
|
+
Parameter.VERSION_INFORMATION,
|
|
391
|
+
Parameter.MAX_DATAGRAM_FRAME_SIZE,
|
|
392
|
+
Parameter.GOOGLE_CONNECTION_OPTIONS,
|
|
393
|
+
Parameter.INITIAL_ROUND_TRIP_TIME,
|
|
394
|
+
),
|
|
395
|
+
max_datagram_frame_size=65536,
|
|
396
|
+
google_connection_options=(b"ORIG",),
|
|
397
|
+
grease=True,
|
|
398
|
+
grease_version=True,
|
|
399
|
+
shuffle=True,
|
|
400
|
+
),
|
|
401
|
+
tls=quic_hello,
|
|
402
|
+
grease=True,
|
|
403
|
+
)
|
|
291
404
|
|
|
292
405
|
|
|
293
406
|
async def main() -> None:
|
|
294
|
-
async with Client(versions=("h3",), http3=
|
|
407
|
+
async with Client(versions=("h3",), http3=http3, headers=[("priority", "u=0, i")]) as client:
|
|
295
408
|
_ = await client.get("https://pingly.us.kg/api/all")
|
|
296
409
|
await client.close() # the next request opens a new connection, which resumes the session
|
|
297
410
|
report = (await client.get("https://pingly.us.kg/api/all")).json()
|
|
298
|
-
print(report["tls"]["ja4"] == JA4.of(
|
|
411
|
+
print(report["tls"]["ja4"] == JA4.of(quic_hello, JA4Transport.QUIC).text, report["tls"]["ja4"]) # True q13d0313h3_55b375c5d22e_b0954bf1abdf
|
|
299
412
|
print(report["http3"]["h3_text"]) # 1:65536;6:262144;7:100;51:1;GREASE|m,a,s,p
|
|
300
413
|
|
|
301
414
|
|
|
302
415
|
asyncio.run(main())
|
|
303
416
|
```
|
|
304
417
|
|
|
305
|
-
`tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `
|
|
418
|
+
`tls` is a `ssl2.ClientHello`; BoringSSL sends it byte for byte or the client refuses it with the reason. `http2` is an `Http2Settings` with the SETTINGS, WINDOW_UPDATE, priorities and pseudo-header order. `http3` is an `Http3Settings` with the HTTP/3 SETTINGS, pseudo-header order, QUIC transport parameters and QUIC version. Neither has a default: `h2` in `versions` or in the ClientHello needs `http2`, and `h3` in `versions` needs `http3`.
|
|
306
419
|
|
|
307
420
|
## WebSocket
|
|
308
421
|
|
|
309
422
|
```python
|
|
310
423
|
import asyncio
|
|
311
424
|
|
|
312
|
-
from boring_http import Client
|
|
425
|
+
from boring_http import Client, Http2Settings
|
|
426
|
+
from ssl2 import AkamaiFingerprint
|
|
427
|
+
|
|
428
|
+
http2 = Http2Settings(AkamaiFingerprint.parse("1:65536;2:0;4:6291456;6:262144|15663105|0|m,a,s,p"))
|
|
313
429
|
|
|
314
430
|
|
|
315
431
|
async def main() -> None:
|
|
316
|
-
async with Client() as client:
|
|
432
|
+
async with Client(http2=http2) as client:
|
|
317
433
|
websocket = await client.websocket("wss://echo.websocket.org/")
|
|
318
434
|
print((await websocket.receive())[:17]) # Request served by
|
|
319
435
|
await websocket.send("hello")
|
|
@@ -334,11 +450,39 @@ Over HTTP/1.1 this is the Upgrade handshake. Over HTTP/2 and HTTP/3 it is Extend
|
|
|
334
450
|
```python
|
|
335
451
|
import uvloop
|
|
336
452
|
|
|
337
|
-
from boring_http import Client
|
|
453
|
+
from boring_http import Client, Http3Settings, Parameter, Setting, TransportParameters
|
|
454
|
+
from ssl2 import PseudoHeader
|
|
455
|
+
|
|
456
|
+
http3 = Http3Settings(
|
|
457
|
+
settings=((Setting.QPACK_MAX_TABLE_CAPACITY, 65536), (Setting.MAX_FIELD_SECTION_SIZE, 262144), (Setting.QPACK_BLOCKED_STREAMS, 100)),
|
|
458
|
+
pseudo_header_order=(PseudoHeader.METHOD, PseudoHeader.AUTHORITY, PseudoHeader.SCHEME, PseudoHeader.PATH),
|
|
459
|
+
transport=TransportParameters(
|
|
460
|
+
max_idle_timeout=30000,
|
|
461
|
+
max_udp_payload_size=1472,
|
|
462
|
+
initial_max_data=15728640,
|
|
463
|
+
initial_max_stream_data_bidi_local=6291456,
|
|
464
|
+
initial_max_stream_data_bidi_remote=6291456,
|
|
465
|
+
initial_max_stream_data_uni=6291456,
|
|
466
|
+
initial_max_streams_bidi=100,
|
|
467
|
+
initial_max_streams_uni=100,
|
|
468
|
+
order=(
|
|
469
|
+
Parameter.MAX_IDLE_TIMEOUT,
|
|
470
|
+
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
471
|
+
Parameter.INITIAL_MAX_DATA,
|
|
472
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
473
|
+
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
474
|
+
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
475
|
+
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
476
|
+
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
477
|
+
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
478
|
+
Parameter.VERSION_INFORMATION,
|
|
479
|
+
),
|
|
480
|
+
),
|
|
481
|
+
)
|
|
338
482
|
|
|
339
483
|
|
|
340
484
|
async def main() -> None:
|
|
341
|
-
async with Client(versions=("h3",)) as client:
|
|
485
|
+
async with Client(versions=("h3",), http3=http3) as client:
|
|
342
486
|
print((await client.get("https://pingly.us.kg/api/all")).version) # h3
|
|
343
487
|
|
|
344
488
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "boring-http"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Asyncio HTTP client on BoringSSL: HTTP/1.1, HTTP/2 and HTTP/3 over QUIC v1 and v2, WebSocket on all three, HTTP and SOCKS proxies, headers in your order"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
@@ -14,8 +14,8 @@ from boring_http.errors import (
|
|
|
14
14
|
TooManyRedirects,
|
|
15
15
|
WebSocketClosed,
|
|
16
16
|
)
|
|
17
|
-
from boring_http.http2 import
|
|
18
|
-
from boring_http.http3 import
|
|
17
|
+
from boring_http.http2 import Http2Settings
|
|
18
|
+
from boring_http.http3 import Http3Settings, Setting
|
|
19
19
|
from boring_http.message import (
|
|
20
20
|
URL,
|
|
21
21
|
Body,
|
|
@@ -25,14 +25,12 @@ from boring_http.message import (
|
|
|
25
25
|
Request,
|
|
26
26
|
Response,
|
|
27
27
|
)
|
|
28
|
-
from boring_http.quic.connection import TransportParameters
|
|
28
|
+
from boring_http.quic.connection import Parameter, TransportParameters
|
|
29
29
|
from boring_http.revocation import Revocation
|
|
30
30
|
from boring_http.timing import Timeout
|
|
31
31
|
from boring_http.websocket import WebSocket
|
|
32
32
|
|
|
33
33
|
__all__ = (
|
|
34
|
-
"CHROME_HTTP2",
|
|
35
|
-
"CHROME_HTTP3",
|
|
36
34
|
"URL",
|
|
37
35
|
"Body",
|
|
38
36
|
"Client",
|
|
@@ -43,8 +41,9 @@ __all__ = (
|
|
|
43
41
|
"HTTPStatusError",
|
|
44
42
|
"HTTPVersion",
|
|
45
43
|
"Headers",
|
|
46
|
-
"
|
|
47
|
-
"
|
|
44
|
+
"Http2Settings",
|
|
45
|
+
"Http3Settings",
|
|
46
|
+
"Parameter",
|
|
48
47
|
"ProtocolError",
|
|
49
48
|
"QuicVersion",
|
|
50
49
|
"RemoteDisconnected",
|
|
@@ -52,6 +51,7 @@ __all__ = (
|
|
|
52
51
|
"Response",
|
|
53
52
|
"Revocation",
|
|
54
53
|
"RevocationError",
|
|
54
|
+
"Setting",
|
|
55
55
|
"StreamRefused",
|
|
56
56
|
"StreamResetError",
|
|
57
57
|
"Timeout",
|
|
@@ -35,8 +35,8 @@ from boring_http.errors import (
|
|
|
35
35
|
TooManyRedirects,
|
|
36
36
|
)
|
|
37
37
|
from boring_http.http1 import Http1Connection
|
|
38
|
-
from boring_http.http2 import
|
|
39
|
-
from boring_http.http3 import
|
|
38
|
+
from boring_http.http2 import Http2Connection, Http2Settings
|
|
39
|
+
from boring_http.http3 import Http3Connection, Http3Settings
|
|
40
40
|
from boring_http.message import (
|
|
41
41
|
URL,
|
|
42
42
|
Body,
|
|
@@ -94,8 +94,8 @@ class Client:
|
|
|
94
94
|
timeout: float | Timeout | None = DEFAULT_TIMEOUT,
|
|
95
95
|
proxy: Proxy | str | None = None,
|
|
96
96
|
tls: ClientHello | None = None,
|
|
97
|
-
http2:
|
|
98
|
-
http3:
|
|
97
|
+
http2: Http2Settings | None = None,
|
|
98
|
+
http3: Http3Settings | None = None,
|
|
99
99
|
versions: Sequence[HTTPVersion | str] = (
|
|
100
100
|
HTTPVersion.HTTP_2,
|
|
101
101
|
HTTPVersion.HTTP_1,
|
|
@@ -119,6 +119,13 @@ class Client:
|
|
|
119
119
|
self._http3 = http3
|
|
120
120
|
self._alt_svc: dict[tuple[str, int], float] = {}
|
|
121
121
|
self._versions = tuple(HTTPVersion(version) for version in versions)
|
|
122
|
+
if http2 is None and (
|
|
123
|
+
HTTPVersion.HTTP_2 in self._versions
|
|
124
|
+
or (tls is not None and NextProtocol.H2.value in tls.application_protocols)
|
|
125
|
+
):
|
|
126
|
+
raise ValueError("h2 is offered, so http2= needs an Http2Settings")
|
|
127
|
+
if http3 is None and HTTPVersion.HTTP_3 in self._versions:
|
|
128
|
+
raise ValueError("h3 is in versions, so http3= needs an Http3Settings")
|
|
122
129
|
self._trust_store = trust_store
|
|
123
130
|
self._verify = verify
|
|
124
131
|
self._revocations = Revocations(revocation, self._fetch_crl)
|
|
@@ -418,7 +425,7 @@ class Client:
|
|
|
418
425
|
if protocols not in self._contexts:
|
|
419
426
|
hello = (
|
|
420
427
|
self._http3.tls
|
|
421
|
-
if protocols == (NextProtocol.H3,)
|
|
428
|
+
if protocols == (NextProtocol.H3,) and self._http3 is not None
|
|
422
429
|
else self._adapted(protocols)
|
|
423
430
|
)
|
|
424
431
|
if hello is None:
|
|
@@ -451,7 +458,7 @@ class Client:
|
|
|
451
458
|
) -> ClientHello | None:
|
|
452
459
|
if self._tls is None or protocols is None:
|
|
453
460
|
return self._tls
|
|
454
|
-
# an HTTP/1.1-only connection keeps the
|
|
461
|
+
# an HTTP/1.1-only connection keeps the ClientHello but can offer only its own protocol
|
|
455
462
|
names = tuple(protocol.value for protocol in protocols)
|
|
456
463
|
return replace(
|
|
457
464
|
self._tls,
|
|
@@ -483,7 +490,7 @@ class Client:
|
|
|
483
490
|
http1_only: bool = False,
|
|
484
491
|
) -> Connection:
|
|
485
492
|
try:
|
|
486
|
-
if quic:
|
|
493
|
+
if quic and self._http3 is not None:
|
|
487
494
|
memory = self._memory.setdefault(url.address, OriginMemory())
|
|
488
495
|
client = await QuicClient.connect(
|
|
489
496
|
self._context((NextProtocol.H3,)),
|
|
@@ -511,7 +518,10 @@ class Client:
|
|
|
511
518
|
proxy=proxy,
|
|
512
519
|
)
|
|
513
520
|
await self._checked(stream, url.authority)
|
|
514
|
-
if
|
|
521
|
+
if (
|
|
522
|
+
stream.negotiated_protocol() is NextProtocol.H2
|
|
523
|
+
and self._http2 is not None
|
|
524
|
+
):
|
|
515
525
|
connection = Http2Connection(stream, self._http2)
|
|
516
526
|
await connection.start()
|
|
517
527
|
return connection
|
|
@@ -8,7 +8,6 @@ from ssl2 import (
|
|
|
8
8
|
AkamaiFingerprint,
|
|
9
9
|
AsyncTransport,
|
|
10
10
|
Http2Priority,
|
|
11
|
-
Http2Setting,
|
|
12
11
|
Http2SettingIdentifier,
|
|
13
12
|
PseudoHeader,
|
|
14
13
|
)
|
|
@@ -78,7 +77,7 @@ class ErrorCode(IntEnum):
|
|
|
78
77
|
|
|
79
78
|
|
|
80
79
|
@dataclass(frozen=True, slots=True)
|
|
81
|
-
class
|
|
80
|
+
class Http2Settings:
|
|
82
81
|
fingerprint: AkamaiFingerprint
|
|
83
82
|
headers_priority: Http2Priority | None = None
|
|
84
83
|
|
|
@@ -93,29 +92,6 @@ class Http2Profile:
|
|
|
93
92
|
)
|
|
94
93
|
|
|
95
94
|
|
|
96
|
-
CHROME_HTTP2 = Http2Profile(
|
|
97
|
-
AkamaiFingerprint(
|
|
98
|
-
settings=(
|
|
99
|
-
Http2Setting(Http2SettingIdentifier.HEADER_TABLE_SIZE, 65536),
|
|
100
|
-
Http2Setting(Http2SettingIdentifier.ENABLE_PUSH, 0),
|
|
101
|
-
Http2Setting(Http2SettingIdentifier.INITIAL_WINDOW_SIZE, 6291456),
|
|
102
|
-
Http2Setting(Http2SettingIdentifier.MAX_HEADER_LIST_SIZE, 262144),
|
|
103
|
-
),
|
|
104
|
-
window_update=15663105,
|
|
105
|
-
priorities=(),
|
|
106
|
-
pseudo_header_order=(
|
|
107
|
-
PseudoHeader.METHOD,
|
|
108
|
-
PseudoHeader.AUTHORITY,
|
|
109
|
-
PseudoHeader.SCHEME,
|
|
110
|
-
PseudoHeader.PATH,
|
|
111
|
-
),
|
|
112
|
-
),
|
|
113
|
-
headers_priority=Http2Priority(
|
|
114
|
-
stream_id=0, exclusive=True, dependency=0, weight=256
|
|
115
|
-
),
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
|
|
119
95
|
class Http2Stream:
|
|
120
96
|
def __init__(self, identifier: int, send_window: int) -> None:
|
|
121
97
|
self.identifier = identifier
|
|
@@ -156,15 +132,15 @@ class Http2Stream:
|
|
|
156
132
|
|
|
157
133
|
|
|
158
134
|
class Http2Connection:
|
|
159
|
-
def __init__(self, transport: AsyncTransport,
|
|
135
|
+
def __init__(self, transport: AsyncTransport, settings: Http2Settings) -> None:
|
|
160
136
|
self._transport = transport
|
|
161
|
-
self.
|
|
137
|
+
self._local_settings = settings
|
|
162
138
|
self._encoder = Encoder()
|
|
163
139
|
self._decoder = Decoder(
|
|
164
|
-
|
|
140
|
+
settings.setting(
|
|
165
141
|
Http2SettingIdentifier.HEADER_TABLE_SIZE, DEFAULT_TABLE_SIZE
|
|
166
142
|
),
|
|
167
|
-
|
|
143
|
+
settings.setting(
|
|
168
144
|
Http2SettingIdentifier.MAX_HEADER_LIST_SIZE, DEFAULT_HEADER_LIST_SIZE
|
|
169
145
|
),
|
|
170
146
|
)
|
|
@@ -177,10 +153,10 @@ class Http2Connection:
|
|
|
177
153
|
self._peer_frame_size = DEFAULT_FRAME_SIZE
|
|
178
154
|
self._peer_window = DEFAULT_WINDOW
|
|
179
155
|
self._peer_max_streams = 2**31
|
|
180
|
-
self._local_window =
|
|
156
|
+
self._local_window = settings.setting(
|
|
181
157
|
Http2SettingIdentifier.INITIAL_WINDOW_SIZE, DEFAULT_WINDOW
|
|
182
158
|
)
|
|
183
|
-
self._connection_window = DEFAULT_WINDOW +
|
|
159
|
+
self._connection_window = DEFAULT_WINDOW + settings.fingerprint.window_update
|
|
184
160
|
self._connection_received = 0
|
|
185
161
|
self._goaway: int | None = None
|
|
186
162
|
self._reader: asyncio.Task[None] | None = None
|
|
@@ -208,7 +184,7 @@ class Http2Connection:
|
|
|
208
184
|
pass
|
|
209
185
|
|
|
210
186
|
async def start(self) -> None:
|
|
211
|
-
fingerprint = self.
|
|
187
|
+
fingerprint = self._local_settings.fingerprint
|
|
212
188
|
settings = b"".join(
|
|
213
189
|
struct.pack("!HI", int(setting.identifier), setting.value)
|
|
214
190
|
for setting in fingerprint.settings
|
|
@@ -241,9 +217,11 @@ class Http2Connection:
|
|
|
241
217
|
Flag.END_STREAM if request.body is None else Flag(0)
|
|
242
218
|
)
|
|
243
219
|
payload = block
|
|
244
|
-
if self.
|
|
220
|
+
if self._local_settings.headers_priority is not None:
|
|
245
221
|
flags |= Flag.PRIORITY
|
|
246
|
-
payload =
|
|
222
|
+
payload = (
|
|
223
|
+
self._priority_bytes(self._local_settings.headers_priority) + block
|
|
224
|
+
)
|
|
247
225
|
try:
|
|
248
226
|
async with deadline.phase("write"):
|
|
249
227
|
await self._write_headers(stream.identifier, payload, flags)
|
|
@@ -359,7 +337,8 @@ class Http2Connection:
|
|
|
359
337
|
PseudoHeader.PATH: (b":path", request.url.target.encode()),
|
|
360
338
|
}
|
|
361
339
|
fields = [
|
|
362
|
-
pseudo[header]
|
|
340
|
+
pseudo[header]
|
|
341
|
+
for header in self._local_settings.fingerprint.pseudo_header_order
|
|
363
342
|
]
|
|
364
343
|
if protocol is not None:
|
|
365
344
|
fields.insert(1, (b":protocol", protocol))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import secrets
|
|
3
3
|
from collections.abc import AsyncGenerator, Callable
|
|
4
|
-
from dataclasses import dataclass
|
|
4
|
+
from dataclasses import dataclass
|
|
5
5
|
from enum import IntEnum
|
|
6
6
|
|
|
7
7
|
from boring_ssl2 import QuicVersion
|
|
@@ -68,32 +68,20 @@ class ErrorCode(IntEnum):
|
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
@dataclass(frozen=True, slots=True)
|
|
71
|
-
class
|
|
72
|
-
settings: tuple[tuple[int, int], ...]
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
class Http3Settings:
|
|
72
|
+
settings: tuple[tuple[int, int], ...]
|
|
73
|
+
pseudo_header_order: tuple[PseudoHeader, ...]
|
|
74
|
+
transport: TransportParameters
|
|
75
|
+
quic_version: QuicVersion = QuicVersion.V1
|
|
76
|
+
tls: ClientHello | None = None
|
|
77
|
+
grease: bool = False
|
|
78
78
|
|
|
79
79
|
def setting(self, identifier: Setting, default: int) -> int:
|
|
80
80
|
return next(
|
|
81
81
|
(value for key, value in self.settings if key == identifier), default
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
-
pseudo_header_order: tuple[PseudoHeader, ...] = (
|
|
85
|
-
PseudoHeader.METHOD,
|
|
86
|
-
PseudoHeader.AUTHORITY,
|
|
87
|
-
PseudoHeader.SCHEME,
|
|
88
|
-
PseudoHeader.PATH,
|
|
89
|
-
)
|
|
90
|
-
transport: TransportParameters = field(default_factory=TransportParameters)
|
|
91
|
-
quic_version: QuicVersion = QuicVersion.V1
|
|
92
|
-
tls: ClientHello | None = None
|
|
93
|
-
grease: bool = True
|
|
94
|
-
|
|
95
84
|
|
|
96
|
-
CHROME_HTTP3 = Http3Profile()
|
|
97
85
|
# RFC 8470 section 5.2: the requests a client may send before the handshake, since a server may see them twice
|
|
98
86
|
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
99
87
|
|
|
@@ -152,12 +140,12 @@ class Http3Connection:
|
|
|
152
140
|
def __init__(
|
|
153
141
|
self,
|
|
154
142
|
quic: QuicClient,
|
|
155
|
-
|
|
143
|
+
settings: Http3Settings,
|
|
156
144
|
memory: OriginMemory | None = None,
|
|
157
145
|
) -> None:
|
|
158
146
|
self._quic = quic
|
|
159
|
-
self.
|
|
160
|
-
self._qpack = Qpack(
|
|
147
|
+
self._local_settings = settings
|
|
148
|
+
self._qpack = Qpack(settings.setting(Setting.QPACK_MAX_TABLE_CAPACITY, 0))
|
|
161
149
|
self._control_stream = -1
|
|
162
150
|
self._decoder_stream = -1
|
|
163
151
|
self._peer_settings: dict[int, int] = {}
|
|
@@ -197,9 +185,9 @@ class Http3Connection:
|
|
|
197
185
|
writer = Writer()
|
|
198
186
|
writer.varint(StreamType.CONTROL)
|
|
199
187
|
settings = Writer()
|
|
200
|
-
entries = list(self.
|
|
201
|
-
if self.
|
|
202
|
-
# RFC 9114 section 7.2.4.1: a reserved identifier the peer must ignore, placed at random
|
|
188
|
+
entries = list(self._local_settings.settings)
|
|
189
|
+
if self._local_settings.grease:
|
|
190
|
+
# RFC 9114 section 7.2.4.1: a reserved identifier the peer must ignore, placed at random
|
|
203
191
|
entries.insert(
|
|
204
192
|
secrets.randbelow(len(entries) + 1),
|
|
205
193
|
(0x1F * secrets.randbelow(1 << 16) + 0x21, secrets.randbelow(1 << 20)),
|
|
@@ -210,8 +198,8 @@ class Http3Connection:
|
|
|
210
198
|
writer.varint(FrameType.SETTINGS)
|
|
211
199
|
writer.varint(len(settings))
|
|
212
200
|
writer.put(settings.finish())
|
|
213
|
-
if self.
|
|
214
|
-
# RFC 9114 section 7.2.8: an empty frame of a reserved type right after SETTINGS
|
|
201
|
+
if self._local_settings.grease:
|
|
202
|
+
# RFC 9114 section 7.2.8: an empty frame of a reserved type right after SETTINGS
|
|
215
203
|
writer.varint(0x1F * secrets.randbelow(1 << 32) + 0x21)
|
|
216
204
|
writer.varint(0)
|
|
217
205
|
self._quic.send(self._control_stream, writer.finish())
|
|
@@ -252,7 +240,7 @@ class Http3Connection:
|
|
|
252
240
|
stream_id = self._quic.open_stream()
|
|
253
241
|
priority = request.headers.get("priority")
|
|
254
242
|
if priority is not None:
|
|
255
|
-
# RFC 9218 section 7.2:
|
|
243
|
+
# RFC 9218 section 7.2: a request's priority goes on the control stream before its HEADERS
|
|
256
244
|
self._quic.send(
|
|
257
245
|
self._control_stream,
|
|
258
246
|
self._frame(
|
|
@@ -341,7 +329,7 @@ class Http3Connection:
|
|
|
341
329
|
PseudoHeader.SCHEME: (b":scheme", scheme.encode()),
|
|
342
330
|
PseudoHeader.PATH: (b":path", request.url.target.encode()),
|
|
343
331
|
}
|
|
344
|
-
fields = [pseudo[header] for header in self.
|
|
332
|
+
fields = [pseudo[header] for header in self._local_settings.pseudo_header_order]
|
|
345
333
|
if protocol is not None:
|
|
346
334
|
fields.insert(1, (b":protocol", protocol))
|
|
347
335
|
return fields + [(name.encode(), value.encode()) for name, value in wire]
|
|
@@ -48,7 +48,7 @@ from boring_http.quic.recovery import GRANULARITY, Recovery, SentPacket
|
|
|
48
48
|
from boring_http.quic.streams import ReceiveStream, SendStream
|
|
49
49
|
|
|
50
50
|
DATAGRAM_SIZE = 1200
|
|
51
|
-
# RFC 9000 section 14.2: what a 1500 byte link carries under IPv6 and UDP headers
|
|
51
|
+
# RFC 9000 section 14.2: what a 1500 byte link carries under IPv6 and UDP headers
|
|
52
52
|
PROBED_DATAGRAM_SIZE = 1452
|
|
53
53
|
TAG_LENGTH = 16
|
|
54
54
|
SAMPLE_LENGTH = 16
|
|
@@ -117,40 +117,25 @@ NEVER_REMEMBERED = (
|
|
|
117
117
|
|
|
118
118
|
@dataclass(frozen=True, slots=True)
|
|
119
119
|
class TransportParameters:
|
|
120
|
-
max_idle_timeout: int
|
|
121
|
-
max_udp_payload_size: int
|
|
122
|
-
initial_max_data: int
|
|
123
|
-
initial_max_stream_data_bidi_local: int
|
|
124
|
-
initial_max_stream_data_bidi_remote: int
|
|
125
|
-
initial_max_stream_data_uni: int
|
|
126
|
-
initial_max_streams_bidi: int
|
|
127
|
-
initial_max_streams_uni: int
|
|
120
|
+
max_idle_timeout: int
|
|
121
|
+
max_udp_payload_size: int
|
|
122
|
+
initial_max_data: int
|
|
123
|
+
initial_max_stream_data_bidi_local: int
|
|
124
|
+
initial_max_stream_data_bidi_remote: int
|
|
125
|
+
initial_max_stream_data_uni: int
|
|
126
|
+
initial_max_streams_bidi: int
|
|
127
|
+
initial_max_streams_uni: int
|
|
128
|
+
order: tuple[Parameter, ...]
|
|
129
|
+
# RFC 9000 section 18.2 and RFC 9221 section 3: the values a peer assumes when a parameter is absent
|
|
128
130
|
ack_delay_exponent: int = 3
|
|
129
131
|
max_ack_delay: int = 25
|
|
130
|
-
active_connection_id_limit: int =
|
|
131
|
-
max_datagram_frame_size: int =
|
|
132
|
-
|
|
133
|
-
google_connection_options: tuple[bytes, ...] = (b"ORIG",)
|
|
132
|
+
active_connection_id_limit: int = 2
|
|
133
|
+
max_datagram_frame_size: int = 0
|
|
134
|
+
google_connection_options: tuple[bytes, ...] = ()
|
|
134
135
|
initial_round_trip_time: int | None = None
|
|
135
|
-
grease: bool =
|
|
136
|
-
grease_version: bool =
|
|
137
|
-
shuffle: bool =
|
|
138
|
-
# the set Chrome 155 sends: the parameters at their defaults and grease_quic_bit are left out
|
|
139
|
-
order: tuple[Parameter, ...] = (
|
|
140
|
-
Parameter.MAX_IDLE_TIMEOUT,
|
|
141
|
-
Parameter.MAX_UDP_PAYLOAD_SIZE,
|
|
142
|
-
Parameter.INITIAL_MAX_DATA,
|
|
143
|
-
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_LOCAL,
|
|
144
|
-
Parameter.INITIAL_MAX_STREAM_DATA_BIDI_REMOTE,
|
|
145
|
-
Parameter.INITIAL_MAX_STREAM_DATA_UNI,
|
|
146
|
-
Parameter.INITIAL_MAX_STREAMS_BIDI,
|
|
147
|
-
Parameter.INITIAL_MAX_STREAMS_UNI,
|
|
148
|
-
Parameter.INITIAL_SOURCE_CONNECTION_ID,
|
|
149
|
-
Parameter.VERSION_INFORMATION,
|
|
150
|
-
Parameter.MAX_DATAGRAM_FRAME_SIZE,
|
|
151
|
-
Parameter.GOOGLE_CONNECTION_OPTIONS,
|
|
152
|
-
Parameter.INITIAL_ROUND_TRIP_TIME,
|
|
153
|
-
)
|
|
136
|
+
grease: bool = False
|
|
137
|
+
grease_version: bool = False
|
|
138
|
+
shuffle: bool = False
|
|
154
139
|
|
|
155
140
|
def encode(
|
|
156
141
|
self,
|
|
@@ -183,7 +168,7 @@ class TransportParameters:
|
|
|
183
168
|
elif parameter is Parameter.VERSION_INFORMATION:
|
|
184
169
|
available: list[int] = list(versions)
|
|
185
170
|
if self.grease_version:
|
|
186
|
-
# RFC 9368 section 2.1
|
|
171
|
+
# RFC 9368 section 2.1: a reserved version at a random place among the available ones
|
|
187
172
|
available.insert(
|
|
188
173
|
secrets.randbelow(len(available) + 1),
|
|
189
174
|
secrets.randbits(32) & 0xF0F0F0F0 | 0x0A0A0A0A,
|
|
@@ -204,7 +189,7 @@ class TransportParameters:
|
|
|
204
189
|
raise ValueError(f"{parameter.name} is not a parameter a client sends")
|
|
205
190
|
pairs.append((parameter, value))
|
|
206
191
|
if self.grease:
|
|
207
|
-
# RFC 9000 section 18.1: a reserved identifier the peer must ignore
|
|
192
|
+
# RFC 9000 section 18.1: a reserved identifier the peer must ignore
|
|
208
193
|
pairs.insert(
|
|
209
194
|
secrets.randbelow(len(pairs) + 1),
|
|
210
195
|
(
|
|
@@ -213,7 +198,6 @@ class TransportParameters:
|
|
|
213
198
|
),
|
|
214
199
|
)
|
|
215
200
|
if self.shuffle:
|
|
216
|
-
# Chrome sends its transport parameters in a fresh random order on every connection
|
|
217
201
|
secrets.SystemRandom().shuffle(pairs)
|
|
218
202
|
for identifier, value in pairs:
|
|
219
203
|
writer.varint(identifier)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|