didbot_http/lib.rs
1//! One constructor for every outbound `reqwest` client this workspace
2//! builds. A client with no timeout waits forever on a black-holed route.
3//!
4//! The workspace's `clippy.toml` disallows `reqwest`'s own constructors, so
5//! a client built anywhere else fails the lint. A caller that needs another
6//! total deadline, such as a held poll, sets it on the builder this returns;
7//! the connect timeout stays.
8//!
9//! [`read_bounded`] is the other half: a `timeout` bounds how long a
10//! response may take, not how much of it a caller will hold in memory.
11//! `.json()` on an unbounded body is a memory sink against any server this
12//! process does not run -- an operator's PDS, or whoever a DID document
13//! points at.
14//!
15//! [`REFUSED_BLOCKS`] is the third: which addresses an outbound fetch may
16//! reach at all. A URL named by a stranger, or by a document this server
17//! just fetched, is aimed by somebody else; [`resolve_public`] and
18//! [`pinned_client`] are how it stays aimed at the public internet.
19//!
20//! [`EXTRA_CA_CERTS`] is the fourth: which authorities a client here
21//! trusts on top of the ones it is built with.
22//!
23//! [`doh`] is DNS over HTTPS, one GET through a pinned client, for the
24//! records a stub resolver has no API for.
25//!
26//! [`lookup_host`] is how every client here resolves a name: RFC 6761's
27//! `localhost` names go to loopback, and the rest go to the system.
28
29mod address;
30pub mod doh;
31mod resolve;
32
33pub use address::{
34 is_loopback, is_public_address, pinned_client, refuse_private_host, resolve_public,
35 AddressError, PrivateAddresses, REFUSED_BLOCKS,
36};
37pub use resolve::{is_localhost_name, lookup_host};
38
39use std::time::Duration;
40
41/// How long establishing a connection -- DNS, then the TCP and TLS
42/// handshake -- may take, independent of [`DEFAULT_TIMEOUT`].
43///
44/// Set apart from the total deadline so a route that never completes a
45/// handshake is a different fact, in a log line, from a server that
46/// connects and then answers too slowly: the first is a network problem,
47/// the second is the remote server's.
48pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
49
50/// The total deadline -- connect, send the request, and read the response --
51/// for a request to a server this deployment does not run: an operator's
52/// PDS, a relay, the ACME directory, a client's own metadata document.
53///
54/// Ten seconds, the value this workspace already used at its two busiest
55/// outbound call sites (the relay announcement and the OAuth client
56/// metadata fetch) before this crate existed. Long enough that an ordinary
57/// server answers comfortably inside it; short enough that whatever is
58/// waiting on the request -- a poll tick, a boot sequence -- finds out a
59/// peer has stalled in single-digit seconds rather than never.
60pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
61
62/// The environment variable naming a PEM file of certificate authorities
63/// every client built here trusts, on top of the ones it is built with.
64///
65/// This workspace's `reqwest` verifies against a bundled root store, which
66/// means it ignores the machine's own — so a server serving a certificate
67/// from `didbot_tls::local`, which is what a `.localhost` zone serves, is a
68/// server these clients would otherwise refuse. Naming the authority's
69/// certificate here is how a development stack is reached. The spelling
70/// matches Node's `NODE_EXTRA_CA_CERTS`, which does the same job for the
71/// Node tools beside these.
72///
73/// Additive, and only additive: nothing here can remove a root, and there
74/// is no way to switch verification off.
75pub const EXTRA_CA_CERTS: &str = "DIDBOT_EXTRA_CA_CERTS";
76
77/// A [`reqwest::ClientBuilder`] pre-configured with
78/// [`DEFAULT_CONNECT_TIMEOUT`] and [`DEFAULT_TIMEOUT`], for a caller that
79/// needs to layer something else on top -- a redirect policy, a user agent
80/// -- before building.
81#[allow(clippy::disallowed_methods)]
82pub fn builder() -> reqwest::ClientBuilder {
83 let mut builder = reqwest::Client::builder()
84 .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
85 .timeout(DEFAULT_TIMEOUT)
86 .dns_resolver(std::sync::Arc::new(resolve::Resolver));
87 for certificate in extra_root_certificates() {
88 builder = builder.add_root_certificate(certificate);
89 }
90 builder
91}
92
93/// Authorities a test added for this process; see
94/// [`test_support::trust`]. Empty in anything that is not a test, and read
95/// on every build so a test that adds one before its first request is
96/// enough.
97static PROCESS_ROOTS: std::sync::Mutex<Vec<reqwest::Certificate>> =
98 std::sync::Mutex::new(Vec::new());
99
100/// The authorities [`EXTRA_CA_CERTS`] names, or nothing if it is unset.
101///
102/// A file that cannot be read, or holds nothing a certificate parses out
103/// of, contributes no roots and says so at `warn`. Failing the build
104/// instead would take down a process over a development setting; failing
105/// silently would leave every request refused with a handshake error that
106/// names nothing.
107fn extra_root_certificates() -> Vec<reqwest::Certificate> {
108 let mut roots = PROCESS_ROOTS
109 .lock()
110 .unwrap_or_else(|poisoned| poisoned.into_inner())
111 .clone();
112 let Some(path) = std::env::var_os(EXTRA_CA_CERTS) else {
113 return roots;
114 };
115 let path = std::path::PathBuf::from(path);
116 let pem = match std::fs::read(&path) {
117 Ok(pem) => pem,
118 Err(error) => {
119 tracing::warn!(
120 %error,
121 path = %path.display(),
122 variable = EXTRA_CA_CERTS,
123 "could not read the extra certificate authorities; trusting only the built-in roots"
124 );
125 return roots;
126 }
127 };
128 match reqwest::Certificate::from_pem_bundle(&pem) {
129 Ok(certificates) => roots.extend(certificates),
130 Err(error) => tracing::warn!(
131 %error,
132 path = %path.display(),
133 variable = EXTRA_CA_CERTS,
134 "the extra certificate authorities did not parse; trusting only the built-in roots"
135 ),
136 }
137 roots
138}
139
140/// [`builder`]'s two timeouts on a [`reqwest::blocking::ClientBuilder`], for
141/// a caller with no async runtime to drive the async client on.
142///
143/// Build the client outside any async context: `reqwest`'s blocking client
144/// panics when built inside one.
145#[cfg(feature = "blocking")]
146#[allow(clippy::disallowed_methods)]
147pub fn blocking_builder() -> reqwest::blocking::ClientBuilder {
148 let mut builder = reqwest::blocking::Client::builder()
149 .connect_timeout(DEFAULT_CONNECT_TIMEOUT)
150 .timeout(DEFAULT_TIMEOUT)
151 .dns_resolver(std::sync::Arc::new(resolve::Resolver));
152 for certificate in extra_root_certificates() {
153 builder = builder.add_root_certificate(certificate);
154 }
155 builder
156}
157
158/// The default outbound client: [`builder`], built.
159///
160/// Falls back to a default-configured [`reqwest::Client`] if construction
161/// fails. Every caller of this function is on a path -- a boot sequence, a
162/// poll loop -- where refusing to start at all is worse than running that
163/// one process lifetime with `reqwest`'s own defaults instead of these.
164///
165/// The fallback says so at `warn`. Running a process lifetime on `reqwest`'s
166/// defaults is the deliberate choice; doing it silently is what makes the
167/// resulting hang -- a request with no deadline against a black-holed route
168/// -- something nothing in the log explains.
169pub fn client() -> reqwest::Client {
170 builder().build().unwrap_or_else(|error| {
171 tracing::warn!(
172 %error,
173 connect_timeout = ?DEFAULT_CONNECT_TIMEOUT,
174 timeout = ?DEFAULT_TIMEOUT,
175 "building the shared outbound HTTP client failed; falling back to reqwest's \
176 own defaults, which set no deadline at all"
177 );
178 reqwest::Client::default()
179 })
180}
181
182/// The most of one response body this workspace will hold in memory to read
183/// it, when the response comes from a server nothing here operates.
184///
185/// Two mebibytes -- the same magnitude `didbot-serve`'s own
186/// `MAX_REQUEST_BODY` already treats as a safe amount of one JSON payload
187/// to hold in memory, on the inbound side of this same server. A DID
188/// document or a page of an operator's own records has no business costing
189/// more than one write this server would itself accept costs.
190pub const MAX_JSON_BODY: usize = 2 * 1024 * 1024;
191
192/// Why [`read_bounded`] did not return a body.
193#[derive(Debug, thiserror::Error)]
194pub enum ReadBoundedError {
195 /// The body is larger than the bound it was read against -- either a
196 /// declared `Content-Length` over the bound, refused before reading
197 /// anything, or a body that crossed the bound while being read, which
198 /// happens the moment it does rather than after it finishes.
199 #[error("response body exceeds {max_bytes} bytes")]
200 TooLarge {
201 /// The bound the body exceeded.
202 max_bytes: usize,
203 },
204 /// The connection or the HTTP exchange itself failed while reading.
205 #[error("reading response body: {0}")]
206 Transport(#[from] reqwest::Error),
207 /// The same, for a blocking response, which reports through
208 /// [`std::io`] rather than through `reqwest`.
209 #[error("reading response body: {0}")]
210 Io(#[from] std::io::Error),
211}
212
213/// Reads at most `max_bytes` of `response`'s body.
214///
215/// Neither path this can take ever holds more than one chunk past
216/// `max_bytes` in memory: a declared `Content-Length` over the bound is
217/// refused before reading anything, and a body that lies about its length,
218/// or declines to declare one at all, is refused the instant it crosses the
219/// bound rather than once it finishes.
220pub async fn read_bounded(
221 mut response: reqwest::Response,
222 max_bytes: usize,
223) -> Result<Vec<u8>, ReadBoundedError> {
224 if response
225 .content_length()
226 .is_some_and(|len| len > max_bytes as u64)
227 {
228 return Err(ReadBoundedError::TooLarge { max_bytes });
229 }
230 let mut body = Vec::new();
231 while let Some(chunk) = response.chunk().await? {
232 if body.len() + chunk.len() > max_bytes {
233 return Err(ReadBoundedError::TooLarge { max_bytes });
234 }
235 body.extend_from_slice(&chunk);
236 }
237 Ok(body)
238}
239
240/// [`read_bounded`], for a caller with no async runtime.
241#[cfg(feature = "blocking")]
242pub fn read_bounded_blocking(
243 mut response: reqwest::blocking::Response,
244 max_bytes: usize,
245) -> Result<Vec<u8>, ReadBoundedError> {
246 use std::io::Read;
247
248 if response
249 .content_length()
250 .is_some_and(|len| len > max_bytes as u64)
251 {
252 return Err(ReadBoundedError::TooLarge { max_bytes });
253 }
254 // One byte past the bound, so a body that declares a small length and
255 // then sends more is caught by what arrived rather than by what it said.
256 let mut body = Vec::new();
257 response
258 .by_ref()
259 .take(max_bytes as u64 + 1)
260 .read_to_end(&mut body)?;
261 if body.len() > max_bytes {
262 return Err(ReadBoundedError::TooLarge { max_bytes });
263 }
264 Ok(body)
265}
266
267/// The body of a bounded read, as text.
268///
269/// Lossy, like `reqwest`'s own `text()`: a document that is not UTF-8 is
270/// already going to fail whatever parses it, and saying so there names the
271/// document rather than the encoding.
272pub fn body_text(body: Vec<u8>) -> String {
273 match String::from_utf8(body) {
274 Ok(text) => text,
275 Err(err) => String::from_utf8_lossy(err.as_bytes()).into_owned(),
276 }
277}
278
279/// A server that has stopped answering, for tests of a client's timeouts.
280#[cfg(feature = "test-support")]
281pub mod test_support {
282 use std::net::{SocketAddr, TcpListener};
283
284 /// Trusts `ca_pem` in every client this crate builds for the rest of
285 /// this process.
286 ///
287 /// [`super::EXTRA_CA_CERTS`] is how a running program is told, and a
288 /// test cannot use it: a client built inside this workspace — a pinned
289 /// one, or any caller's — is not a client a test can hand roots to, and
290 /// an environment variable is shared by every test in the binary.
291 ///
292 /// Additive, like the variable, and only for a test: a server a test
293 /// stood up signs from an authority it made, and this is how the code
294 /// under test reaches it.
295 pub fn trust(ca_pem: &str) {
296 let certificate =
297 reqwest::Certificate::from_pem(ca_pem.as_bytes()).expect("a test's own authority");
298 super::PROCESS_ROOTS
299 .lock()
300 .unwrap_or_else(|poisoned| poisoned.into_inner())
301 .push(certificate);
302 }
303
304 /// Binds a loopback listener that accepts every connection and never
305 /// sends a byte, and returns its address. It lives until the process
306 /// exits.
307 ///
308 /// It accepts on a thread, so it needs no runtime: a blocking client's
309 /// test has none, and an async test may pause its clock, which fires a
310 /// client's timeout the moment every task is idle.
311 pub fn hung_server() -> SocketAddr {
312 let listener = TcpListener::bind("127.0.0.1:0").expect("loopback binds");
313 let addr = listener
314 .local_addr()
315 .expect("a bound listener has an address");
316 std::thread::spawn(move || {
317 let mut held = Vec::new();
318 for stream in listener.incoming() {
319 match stream {
320 Ok(stream) => held.push(stream),
321 Err(_) => break,
322 }
323 }
324 });
325 addr
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn response_with_body(body: Vec<u8>) -> reqwest::Response {
334 http::Response::builder()
335 .status(200)
336 .body(body)
337 .unwrap()
338 .into()
339 }
340
341 #[tokio::test]
342 async fn a_body_within_the_bound_is_read_whole() {
343 let body = b"a modest json document".to_vec();
344 let read = read_bounded(response_with_body(body.clone()), MAX_JSON_BODY)
345 .await
346 .unwrap();
347 assert_eq!(read, body);
348 }
349
350 #[tokio::test]
351 async fn a_declared_length_over_the_bound_is_refused() {
352 let oversized = vec![b'x'; 1024];
353 let err = read_bounded(response_with_body(oversized), 1023)
354 .await
355 .unwrap_err();
356 assert!(matches!(
357 err,
358 ReadBoundedError::TooLarge { max_bytes: 1023 }
359 ));
360 }
361
362 #[tokio::test]
363 async fn a_body_exactly_at_the_bound_is_read_whole() {
364 let exact = vec![b'x'; 1024];
365 let read = read_bounded(response_with_body(exact.clone()), 1024)
366 .await
367 .unwrap();
368 assert_eq!(read, exact);
369 }
370
371 /// A loopback server that answers every request with a chunked body it
372 /// never finishes. Chunked means no `Content-Length`, so there is no
373 /// header to refuse up front and the bound is the only thing stopping
374 /// the read.
375 #[cfg(feature = "blocking")]
376 fn endless_chunked_server() -> std::net::SocketAddr {
377 use std::io::{Read, Write};
378
379 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("loopback binds");
380 let addr = listener
381 .local_addr()
382 .expect("a bound listener has an address");
383 std::thread::spawn(move || {
384 for mut stream in listener.incoming().flatten() {
385 // Read the request before answering it. A server that
386 // writes its response while the client is still writing its
387 // request gets the write reset under it, which the client
388 // reports as a failed request rather than as the body this
389 // test is about.
390 let mut request = [0u8; 1024];
391 if stream.read(&mut request).is_err() {
392 continue;
393 }
394 if stream
395 .write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
396 .is_err()
397 {
398 continue;
399 }
400 let chunk = format!("2000\r\n{}\r\n", "x".repeat(0x2000));
401 // Until the reader gives up and drops the connection.
402 while stream.write_all(chunk.as_bytes()).is_ok() {}
403 }
404 });
405 addr
406 }
407
408 #[cfg(feature = "blocking")]
409 #[test]
410 fn a_blocking_body_that_declares_no_length_is_still_cut_off_at_the_bound() {
411 let addr = endless_chunked_server();
412 let response = blocking_builder()
413 .build()
414 .expect("the blocking client builds")
415 .get(format!("http://{addr}/"))
416 .send()
417 .expect("the server answers");
418 assert!(response.content_length().is_none(), "chunked declares none");
419
420 let err = read_bounded_blocking(response, 64 * 1024).unwrap_err();
421 assert!(
422 matches!(err, ReadBoundedError::TooLarge { max_bytes } if max_bytes == 64 * 1024),
423 "{err:?}"
424 );
425 }
426
427 /// A server that accepts the connection and never answers costs the
428 /// default client exactly [`DEFAULT_TIMEOUT`], then a timeout error.
429 #[tokio::test(start_paused = true)]
430 async fn the_default_client_gives_up_on_a_server_that_never_answers() {
431 let addr = test_support::hung_server();
432 let started = tokio::time::Instant::now();
433 let request = client().get(format!("http://{addr}/")).send();
434 let err = tokio::time::timeout(DEFAULT_TIMEOUT * 2, request)
435 .await
436 .expect("the client's own timeout fires first")
437 .expect_err("nothing ever answers");
438 assert!(err.is_timeout(), "{err:?}");
439 assert_eq!(started.elapsed(), DEFAULT_TIMEOUT);
440 }
441
442 /// The blocking client runs its own runtime, so this waits in real time.
443 #[cfg(feature = "blocking")]
444 #[test]
445 fn the_blocking_client_gives_up_on_a_server_that_never_answers() {
446 let addr = test_support::hung_server();
447 let client = blocking_builder().build().expect("the client builds");
448 let started = std::time::Instant::now();
449 let err = client
450 .get(format!("http://{addr}/"))
451 .send()
452 .expect_err("nothing ever answers");
453 assert!(err.is_timeout(), "{err:?}");
454 assert!(
455 started.elapsed() >= DEFAULT_TIMEOUT,
456 "{:?}",
457 started.elapsed()
458 );
459 }
460}