Skip to main content

forge_agent/
http_trust.rs

1//! Reqwest builder helper that extends the TLS trust store with
2//! certs from `NODE_EXTRA_CA_CERTS` (with `SSL_CERT_FILE` as fallback)
3//! to match native `claude`'s TLS trust behaviour.
4//!
5//! Forge makes outbound HTTPS from several call sites (oauth usage
6//! probe, service-status check, CLI-version probe).
7//! These use reqwest, which by default trusts webpki-roots + system
8//! roots only - neither covers a mitmproxy / Zscaler / Palo Alto /
9//! corporate CA setup that the user has wired up via
10//! `NODE_EXTRA_CA_CERTS`. The native `claude` binary (Node) honours
11//! that env var unconditionally; forge needs the same behaviour for
12//! interoperability with the corporate CA configurations users
13//! already have running.
14//!
15//! Apply at every reqwest client construction site via
16//! `with_extra_roots(reqwest::Client::builder().timeout(…))`.
17
18use std::fs;
19
20/// Extend a reqwest `ClientBuilder` with PEM certs from the
21/// `NODE_EXTRA_CA_CERTS` env var (falls back to `SSL_CERT_FILE`).
22///
23/// Unset, empty, unreadable, or unparseable paths are logged as
24/// `warn` and the builder is returned unchanged. This mirrors how
25/// Node's TLS layer treats `NODE_EXTRA_CA_CERTS` as a best-effort
26/// extension - a malformed bundle doesn't kill the program.
27pub fn with_extra_roots(mut b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
28    for var in ["NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE"] {
29        let Ok(path) = std::env::var(var) else { continue };
30        if path.trim().is_empty() {
31            continue;
32        }
33        let (next, added) = with_roots_from(b, &path, var);
34        b = next;
35        // First env var that yields certs wins; don't double-load if both
36        // NODE_EXTRA_CA_CERTS and SSL_CERT_FILE name the same bundle.
37        if added > 0 {
38            break;
39        }
40    }
41    b
42}
43
44/// Load one bundle into `b`, returning the builder and how many certificates
45/// it added. Unreadable or unparseable bundles add nothing.
46fn with_roots_from(
47    mut b: reqwest::ClientBuilder,
48    path: &str,
49    var: &str,
50) -> (reqwest::ClientBuilder, usize) {
51    let pem = match fs::read(path) {
52        Ok(pem) => pem,
53        Err(error) => {
54            tracing::warn!(path = %path, var, %error, "extra CA bundle unreadable");
55            return (b, 0);
56        }
57    };
58    match reqwest::Certificate::from_pem_bundle(&pem) {
59        Ok(certs) => {
60            let added = certs.len();
61            for cert in certs {
62                b = b.add_root_certificate(cert);
63            }
64            tracing::info!(path = %path, var, added, "loaded extra CA roots into reqwest");
65            (b, added)
66        }
67        Err(error) => {
68            tracing::warn!(path = %path, var, %error, "failed to parse CA bundle");
69            (b, 0)
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    /// The loader the production clients use adds the bundle's certificates.
79    #[test]
80    fn a_valid_bundle_adds_its_certificates() {
81        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/extra-root.pem");
82
83        let (_, added) = with_roots_from(reqwest::Client::builder(), path, "NODE_EXTRA_CA_CERTS");
84
85        assert_eq!(added, 1);
86    }
87
88    /// An unreadable bundle is best-effort, like Node's own handling.
89    #[test]
90    fn an_unreadable_bundle_adds_nothing() {
91        let (_, added) =
92            with_roots_from(reqwest::Client::builder(), "/nonexistent/ca.pem", "SSL_CERT_FILE");
93
94        assert_eq!(added, 0);
95    }
96
97    /// The added root reaches the client: a handshake against a server using
98    /// the fixture certificate succeeds only if the extra root was trusted.
99    #[tokio::test]
100    async fn the_extra_roots_reach_the_client() {
101        use std::io::BufReader;
102        use tokio::io::{AsyncReadExt, AsyncWriteExt};
103        use tokio_rustls::TlsAcceptor;
104        use tokio_rustls::rustls::ServerConfig;
105
106        let fixtures = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures");
107        let root_path = format!("{fixtures}/extra-root.pem");
108        let cert_path = format!("{fixtures}/server.pem");
109        let key_path = format!("{fixtures}/server-key.pem");
110        let certs = rustls_pemfile::certs(&mut BufReader::new(
111            std::fs::File::open(&cert_path).expect("the certificate"),
112        ))
113        .collect::<Result<Vec<_>, _>>()
114        .expect("the certificate parses");
115        let key = rustls_pemfile::private_key(&mut BufReader::new(
116            std::fs::File::open(&key_path).expect("the key"),
117        ))
118        .expect("the key parses")
119        .expect("the key exists");
120        // Name the provider rather than relying on a process default: the
121        // workspace builds with more than one rustls provider feature.
122        let config = ServerConfig::builder_with_provider(
123            tokio_rustls::rustls::crypto::aws_lc_rs::default_provider().into(),
124        )
125        .with_safe_default_protocol_versions()
126        .expect("the protocol versions")
127        .with_no_client_auth()
128        .with_single_cert(certs, key)
129        .expect("the server config");
130        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
131        let address = listener.local_addr().expect("addr");
132        let acceptor = TlsAcceptor::from(std::sync::Arc::new(config));
133        let server = tokio::spawn(async move {
134            let (socket, _) = listener.accept().await.expect("accept");
135            let mut stream = acceptor.accept(socket).await.expect("the handshake");
136            let mut buf = [0u8; 1024];
137            let _ = stream.read(&mut buf).await.expect("the request");
138            let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok").await;
139        });
140
141        let (builder, added) =
142            with_roots_from(reqwest::Client::builder(), &root_path, "NODE_EXTRA_CA_CERTS");
143        assert_eq!(added, 1);
144        let client = builder.build().expect("the client");
145        let response = client
146            .get(format!("https://127.0.0.1:{}/", address.port()))
147            .send()
148            .await
149            .expect("the extra root is trusted");
150
151        assert_eq!(response.text().await.expect("the body"), "ok");
152        server.await.expect("the server");
153    }
154}