Skip to main content

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}