Skip to main content

featherbit/outbound/
mod.rs

1//! Shared outbound HTTP client for plugin callouts and upstream proxying.
2//!
3//! One pooled hyper client pair lives in `PluginResources` for the process
4//! lifetime: plugins (forward-auth, opa, loggers, upstream, ...) reuse its
5//! connection pool instead of constructing a client per node or per request.
6//! Supports `http` and `https` (rustls, native roots); `ssl_verify: false`
7//! selects a lazily-built client with certificate verification disabled.
8
9pub mod idle;
10pub mod tls;
11
12use std::collections::HashMap;
13use std::sync::{Arc, OnceLock};
14use std::time::Duration;
15
16use bytes::Bytes;
17use http_body_util::combinators::BoxBody;
18use http_body_util::{BodyExt, Full};
19use hyper_rustls::HttpsConnector;
20use hyper_util::client::legacy::connect::HttpConnector;
21use hyper_util::client::legacy::Client;
22use hyper_util::rt::TokioExecutor;
23
24type PooledClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
25
26/// The error type carried by every streaming response body in this crate
27/// (`ResponseStream`, `OutboundStreamingResponse::body`, the `idle` module's
28/// wrappers). Deliberately *not* `hyper::Error`: that type has no public
29/// constructor anywhere in the `hyper` crate (every one of them is
30/// `pub(super)`), so nothing built on top of it can ever report its own
31/// failure (an idle timeout, a future size cap, a shutdown-drain cutoff) —
32/// only forward an error hyper already produced from a real connection. A
33/// boxed `std::error::Error` is strictly more permissive than what hyper's
34/// own `serve_connection` requires of a response body's error type
35/// (`Into<Box<dyn StdError + Send + Sync>>`), so this costs nothing on the
36/// send side while unblocking every synthetic error this crate needs to
37/// produce. `hyper::Error` itself satisfies `Into<BoxError>` via the
38/// standard library's blanket `From<E: Error + Send + Sync> for Box<dyn
39/// Error + Send + Sync>`, so forwarding a real hyper error through is a
40/// no-op conversion, not a loss of information.
41pub type BoxError = Box<dyn std::error::Error + Send + Sync>;
42
43/// A single outbound request. `timeout`'s meaning depends on which call
44/// consumes it: [`Client::request`] applies it to the whole call — connect,
45/// request write, and response body collection. [`Client::request_streaming`]
46/// applies it only to connect, request write, and response **headers**; once
47/// headers are in, the body is unbounded here and left to the caller's own
48/// idle bound (e.g. `stream_idle_timeout_ms` on the `upstream` node).
49pub struct OutboundRequest {
50    pub method: http::Method,
51    pub url: String,
52    /// Header name/value pairs; names may repeat for multi-value headers.
53    pub headers: Vec<(String, String)>,
54    pub body: Bytes,
55    /// Deadline — see the struct-level doc for what it covers, which differs
56    /// between `request` and `request_streaming`. Callers should default to
57    /// 3s for callouts.
58    pub timeout: Duration,
59    /// When false, TLS certificate verification is disabled (matching
60    /// APISIX's `ssl_verify: false`). Ignored for plain-http URLs and when
61    /// `tls` is set (the identity carries its own verify flag).
62    pub ssl_verify: bool,
63    /// Per-upstream TLS identity (client cert / private CA). `None` uses the
64    /// shared verified/insecure clients — today's behavior.
65    pub tls: Option<Arc<tls::UpstreamTls>>,
66}
67
68impl OutboundRequest {
69    /// A GET with the callout default timeout (3s) and TLS verification on.
70    #[allow(dead_code)] // convenience ctor; callers currently build the struct literally
71    pub fn new(method: http::Method, url: impl Into<String>) -> Self {
72        Self {
73            method,
74            url: url.into(),
75            headers: Vec::new(),
76            body: Bytes::new(),
77            timeout: Duration::from_secs(3),
78            ssl_verify: true,
79            tls: None,
80        }
81    }
82}
83
84/// A fully-buffered outbound response.
85pub struct OutboundResponse {
86    pub status: u16,
87    pub headers: HashMap<String, Vec<String>>,
88    pub body: Bytes,
89}
90
91/// A response whose headers have arrived and whose body is still streaming.
92pub struct OutboundStreamingResponse {
93    pub status: u16,
94    pub headers: HashMap<String, Vec<String>>,
95    pub body: BoxBody<Bytes, BoxError>,
96}
97
98/// Outbound call failure, distinguishing timeouts from transport errors so
99/// callers can map them to distinct gateway error codes.
100#[derive(Debug)]
101pub enum OutboundError {
102    Timeout(Duration),
103    /// Request could not be built (bad URL/header) — a config-shaped error.
104    InvalidRequest(String),
105    /// Connect/transport/body failure.
106    Transport(String),
107}
108
109impl std::fmt::Display for OutboundError {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Timeout(d) => write!(f, "outbound request timed out after {:?}", d),
113            Self::InvalidRequest(m) => write!(f, "invalid outbound request: {}", m),
114            Self::Transport(m) => write!(f, "outbound transport error: {}", m),
115        }
116    }
117}
118
119/// Process-wide pooled HTTP client.
120pub struct OutboundClient {
121    verified: PooledClient,
122    /// Built on first `ssl_verify: false` request; never constructed on the
123    /// common path.
124    insecure: OnceLock<PooledClient>,
125    /// Clients for per-upstream TLS identities (mTLS / private CA), keyed by
126    /// the identity's content hash. Never evicted — bounded by the number of
127    /// distinct identities ever configured.
128    custom: std::sync::RwLock<HashMap<u64, PooledClient>>,
129}
130
131impl OutboundClient {
132    /// Builds the shared client. TLS uses the platform's native root store.
133    /// Advertises both HTTP/1.1 and HTTP/2 via ALPN, so TLS upstreams that
134    /// support h2 (e.g. gRPC backends) are served over HTTP/2 while plain-`http`
135    /// upstreams stay HTTP/1.1.
136    pub fn new() -> Self {
137        // Pin the process-level rustls provider to ring (both backends compile
138        // in through the dependency tree). Shared with the listeners.
139        crate::server::tls::install_crypto_provider();
140
141        let https = hyper_rustls::HttpsConnectorBuilder::new()
142            .with_native_roots()
143            .expect("failed to load native TLS roots")
144            .https_or_http()
145            .enable_http1()
146            .enable_http2()
147            .build();
148        Self {
149            verified: Client::builder(TokioExecutor::new()).build(https),
150            insecure: OnceLock::new(),
151            custom: std::sync::RwLock::new(HashMap::new()),
152        }
153    }
154
155    /// Performs the request, honoring `timeout` and `ssl_verify`.
156    pub async fn request(&self, req: OutboundRequest) -> Result<OutboundResponse, OutboundError> {
157        let deadline = req.timeout;
158        let call = async {
159            let response = self.dispatch(&req).await?;
160
161            let status = response.status().as_u16();
162            let mut headers: HashMap<String, Vec<String>> = HashMap::new();
163            for (name, value) in response.headers() {
164                headers
165                    .entry(name.as_str().to_string())
166                    .or_default()
167                    .push(value.to_str().unwrap_or("").to_string());
168            }
169            let body = response
170                .into_body()
171                .collect()
172                .await
173                .map_err(|e| OutboundError::Transport(e.to_string()))?
174                .to_bytes();
175
176            Ok(OutboundResponse {
177                status,
178                headers,
179                body,
180            })
181        };
182
183        tokio::time::timeout(deadline, call)
184            .await
185            .map_err(|_| OutboundError::Timeout(deadline))?
186    }
187
188    /// Like [`request`](Self::request) but returns as soon as the response
189    /// headers arrive, leaving the body to stream. `req.timeout` bounds
190    /// connect + request + headers; the caller owns any idle bound on the body.
191    pub async fn request_streaming(
192        &self,
193        req: OutboundRequest,
194    ) -> Result<OutboundStreamingResponse, OutboundError> {
195        let deadline = req.timeout;
196        let call = async {
197            let response = self.dispatch(&req).await?;
198
199            let status = response.status().as_u16();
200            let mut headers: HashMap<String, Vec<String>> = HashMap::new();
201            for (name, value) in response.headers() {
202                headers
203                    .entry(name.as_str().to_string())
204                    .or_default()
205                    .push(value.to_str().unwrap_or("").to_string());
206            }
207            // `hyper::Error: Error + Send + Sync + 'static`, so this is the
208            // standard library's blanket `From` impl at work — a real
209            // transport/parse error from `Incoming` forwards unchanged, just
210            // re-wrapped as `BoxError` so this body composes with the idle
211            // timeout and other synthetic-error wrappers in `outbound::idle`.
212            let body: BoxBody<Bytes, BoxError> = response.into_body().map_err(Into::into).boxed();
213
214            Ok(OutboundStreamingResponse {
215                status,
216                headers,
217                body,
218            })
219        };
220
221        tokio::time::timeout(deadline, call)
222            .await
223            .map_err(|_| OutboundError::Timeout(deadline))?
224    }
225
226    /// Builds and dispatches the outbound request, yielding the response with
227    /// its body still unread. Shared by `request` and `request_streaming` so
228    /// the two can never drift in connector, TLS or header handling.
229    async fn dispatch(
230        &self,
231        req: &OutboundRequest,
232    ) -> Result<http::Response<hyper::body::Incoming>, OutboundError> {
233        let mut builder = http::Request::builder()
234            .method(req.method.clone())
235            .uri(&req.url);
236        for (name, value) in &req.headers {
237            builder = builder.header(name.as_str(), value.as_str());
238        }
239        let request = builder
240            .body(Full::new(req.body.clone()))
241            .map_err(|e| OutboundError::InvalidRequest(e.to_string()))?;
242
243        let custom_client;
244        let client = match &req.tls {
245            Some(identity) => {
246                custom_client = self.identity_client(identity)?;
247                &custom_client
248            }
249            None if req.ssl_verify => &self.verified,
250            None => self.insecure.get_or_init(build_insecure_client),
251        };
252
253        client
254            .request(request)
255            .await
256            .map_err(|e| OutboundError::Transport(e.to_string()))
257    }
258
259    /// Returns the pooled client for `identity`, building it on first use.
260    /// ALPN advertises h1+h2 like the default client (hyper-rustls sets the
261    /// protocols on the config in `enable_http1`/`enable_http2`).
262    fn identity_client(
263        &self,
264        identity: &Arc<tls::UpstreamTls>,
265    ) -> Result<PooledClient, OutboundError> {
266        if let Some(c) = self.custom.read().unwrap().get(&identity.cache_key()) {
267            return Ok(c.clone());
268        }
269        // Materials were validated at policy compile; failure here is
270        // config-shaped (e.g. native roots unavailable), not transport.
271        let config = identity
272            .client_config()
273            .map_err(OutboundError::InvalidRequest)?;
274        let https = hyper_rustls::HttpsConnectorBuilder::new()
275            .with_tls_config(config)
276            .https_or_http()
277            .enable_http1()
278            .enable_http2()
279            .build();
280        let client = Client::builder(TokioExecutor::new()).build(https);
281        // Race-safe: whoever loses uses the winner's client.
282        Ok(self
283            .custom
284            .write()
285            .unwrap()
286            .entry(identity.cache_key())
287            .or_insert(client)
288            .clone())
289    }
290}
291
292impl Default for OutboundClient {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298fn build_insecure_client() -> PooledClient {
299    let config = rustls::ClientConfig::builder()
300        .dangerous()
301        .with_custom_certificate_verifier(Arc::new(NoVerification(
302            rustls::crypto::ring::default_provider(),
303        )))
304        .with_no_client_auth();
305    let https = hyper_rustls::HttpsConnectorBuilder::new()
306        .with_tls_config(config)
307        .https_or_http()
308        .enable_http1()
309        .build();
310    Client::builder(TokioExecutor::new()).build(https)
311}
312
313/// Builds a client TLS connector for a raw upstream WebSocket (`wss`) handshake.
314///
315/// ALPN is pinned to `http/1.1` (the WebSocket upgrade is HTTP/1.1). When
316/// `identity` is set, the connector presents that client certificate (and
317/// trusts its private CA, if any) — mirroring `OutboundClient::identity_client`
318/// — and is cached per identity (`UpstreamTls::cache_key`), never evicted. When
319/// `identity` is `None` and `verify` is false, certificate verification is
320/// disabled (matching `ssl_verify: false`). The verified connector is cached,
321/// since loading the platform's native root store on every connection is
322/// wasteful; the insecure connector is cheap and built on demand.
323pub fn client_tls_connector(
324    verify: bool,
325    identity: Option<&Arc<tls::UpstreamTls>>,
326) -> Result<tokio_rustls::TlsConnector, String> {
327    crate::server::tls::install_crypto_provider();
328
329    if let Some(id) = identity {
330        static CUSTOM: OnceLock<std::sync::RwLock<HashMap<u64, tokio_rustls::TlsConnector>>> =
331            OnceLock::new();
332        let cache = CUSTOM.get_or_init(|| std::sync::RwLock::new(HashMap::new()));
333        if let Some(c) = cache.read().unwrap().get(&id.cache_key()) {
334            return Ok(c.clone());
335        }
336        let mut config = id.client_config()?;
337        config.alpn_protocols = vec![b"http/1.1".to_vec()];
338        let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
339        return Ok(cache
340            .write()
341            .unwrap()
342            .entry(id.cache_key())
343            .or_insert(connector)
344            .clone());
345    }
346
347    if !verify {
348        let config = rustls::ClientConfig::builder()
349            .dangerous()
350            .with_custom_certificate_verifier(Arc::new(NoVerification(
351                rustls::crypto::ring::default_provider(),
352            )))
353            .with_no_client_auth();
354        let mut config = config;
355        config.alpn_protocols = vec![b"http/1.1".to_vec()];
356        return Ok(tokio_rustls::TlsConnector::from(Arc::new(config)));
357    }
358
359    static VERIFIED: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
360    if let Some(c) = VERIFIED.get() {
361        return Ok(c.clone());
362    }
363
364    let mut roots = rustls::RootCertStore::empty();
365    let loaded = rustls_native_certs::load_native_certs();
366    let (added, _ignored) = roots.add_parsable_certificates(loaded.certs);
367    if added == 0 {
368        return Err(format!(
369            "no usable native root certificates ({} load error(s))",
370            loaded.errors.len()
371        ));
372    }
373    let mut config = rustls::ClientConfig::builder()
374        .with_root_certificates(roots)
375        .with_no_client_auth();
376    config.alpn_protocols = vec![b"http/1.1".to_vec()];
377    let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
378    // Race-safe: whoever loses just uses the winner's connector.
379    Ok(VERIFIED.get_or_init(|| connector).clone())
380}
381
382/// Certificate verifier that accepts everything — only reachable via an
383/// explicit `ssl_verify: false` in plugin config.
384#[derive(Debug)]
385pub(crate) struct NoVerification(pub(crate) rustls::crypto::CryptoProvider);
386
387impl rustls::client::danger::ServerCertVerifier for NoVerification {
388    fn verify_server_cert(
389        &self,
390        _end_entity: &rustls::pki_types::CertificateDer<'_>,
391        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
392        _server_name: &rustls::pki_types::ServerName<'_>,
393        _ocsp_response: &[u8],
394        _now: rustls::pki_types::UnixTime,
395    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
396        Ok(rustls::client::danger::ServerCertVerified::assertion())
397    }
398
399    fn verify_tls12_signature(
400        &self,
401        message: &[u8],
402        cert: &rustls::pki_types::CertificateDer<'_>,
403        dss: &rustls::DigitallySignedStruct,
404    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
405        rustls::crypto::verify_tls12_signature(
406            message,
407            cert,
408            dss,
409            &self.0.signature_verification_algorithms,
410        )
411    }
412
413    fn verify_tls13_signature(
414        &self,
415        message: &[u8],
416        cert: &rustls::pki_types::CertificateDer<'_>,
417        dss: &rustls::DigitallySignedStruct,
418    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
419        rustls::crypto::verify_tls13_signature(
420            message,
421            cert,
422            dss,
423            &self.0.signature_verification_algorithms,
424        )
425    }
426
427    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
428        self.0.signature_verification_algorithms.supported_schemes()
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    /// The streaming call's deadline covers connect + request + response
437    /// headers only. A server that sends headers and then stalls must still
438    /// yield a response promptly — the buffered `request()` would block on the
439    /// body until the whole-call deadline expired.
440    #[tokio::test]
441    async fn test_request_streaming_returns_after_headers() {
442        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
443        let port = listener.local_addr().unwrap().port();
444        tokio::spawn(async move {
445            if let Ok((mut stream, _)) = listener.accept().await {
446                use tokio::io::{AsyncReadExt, AsyncWriteExt};
447                let mut buf = [0u8; 4096];
448                let _ = stream.read(&mut buf).await;
449                // Headers only, chunked, then hold the connection open.
450                let _ = stream
451                    .write_all(
452                        b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
453                          transfer-encoding: chunked\r\n\r\n",
454                    )
455                    .await;
456                tokio::time::sleep(std::time::Duration::from_secs(30)).await;
457            }
458        });
459
460        let client = OutboundClient::new();
461        let req = OutboundRequest {
462            method: http::Method::GET,
463            url: format!("http://127.0.0.1:{port}/stream"),
464            headers: Vec::new(),
465            body: Bytes::new(),
466            timeout: std::time::Duration::from_secs(5),
467            ssl_verify: true,
468            tls: None,
469        };
470
471        let resp = tokio::time::timeout(
472            std::time::Duration::from_secs(3),
473            client.request_streaming(req),
474        )
475        .await
476        .expect("request_streaming must return before the body arrives")
477        .expect("streaming call succeeded");
478
479        assert_eq!(resp.status, 200);
480        assert_eq!(
481            resp.headers.get("content-type").map(|v| v[0].as_str()),
482            Some("text/event-stream")
483        );
484    }
485
486    #[test]
487    fn test_client_tls_connector_insecure_builds() {
488        // Insecure connector never touches the native store — fast/deterministic.
489        assert!(client_tls_connector(false, None).is_ok());
490    }
491
492    #[test]
493    fn test_client_tls_connector_verified_builds() {
494        // On a normal dev/CI host the native root store loads; if an
495        // environment has no parsable roots the helper returns Err gracefully.
496        assert!(client_tls_connector(true, None).is_ok());
497    }
498
499    #[test]
500    fn test_client_tls_connector_with_identity_builds_and_caches() {
501        // Reuse the identity written by the Task 1 helper — write PEMs inline
502        // here the same way (CA + leaf via rcgen, temp files).
503        let (cert, key, ca) = {
504            let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
505            ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
506            let ca_key = rcgen::KeyPair::generate().unwrap();
507            let ca_cert = ca_params.self_signed(&ca_key).unwrap();
508            let ca_issuer = rcgen::Issuer::from_ca_cert_der(ca_cert.der(), &ca_key).unwrap();
509            let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
510            let leaf_key = rcgen::KeyPair::generate().unwrap();
511            let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_issuer).unwrap();
512            let dir = std::env::temp_dir();
513            let pid = std::process::id();
514            let cert = dir.join(format!("featherbit_wsid_{}.crt", pid));
515            let key = dir.join(format!("featherbit_wsid_{}.key", pid));
516            let ca = dir.join(format!("featherbit_wsid_{}.ca.crt", pid));
517            std::fs::write(&cert, leaf_cert.pem()).unwrap();
518            std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
519            std::fs::write(&ca, ca_cert.pem()).unwrap();
520            (
521                cert.to_str().unwrap().to_string(),
522                key.to_str().unwrap().to_string(),
523                ca.to_str().unwrap().to_string(),
524            )
525        };
526        let identity = tls::UpstreamTls::load(Some((&cert, &key)), Some(&ca), true).unwrap();
527        assert!(client_tls_connector(true, Some(&identity)).is_ok());
528        // Second call hits the connector cache — still fine.
529        assert!(client_tls_connector(true, Some(&identity)).is_ok());
530        // No identity: existing behavior, both variants still build.
531        assert!(client_tls_connector(true, None).is_ok());
532        assert!(client_tls_connector(false, None).is_ok());
533    }
534
535    /// Minimal one-shot HTTPS server that requires a client certificate and
536    /// answers any request with `HTTP/1.1 200 OK`. Returns its port.
537    async fn spawn_mtls_server(server_config: Arc<rustls::ServerConfig>) -> u16 {
538        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
539        let port = listener.local_addr().unwrap().port();
540        tokio::spawn(async move {
541            let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
542            // Serve connections until the test ends; failed handshakes
543            // (missing client cert) just drop the connection.
544            loop {
545                let Ok((tcp, _)) = listener.accept().await else {
546                    break;
547                };
548                let acceptor = acceptor.clone();
549                tokio::spawn(async move {
550                    if let Ok(mut stream) = acceptor.accept(tcp).await {
551                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
552                        let mut buf = [0u8; 4096];
553                        let _ = stream.read(&mut buf).await;
554                        let _ = stream
555                            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
556                            .await;
557                        let _ = stream.shutdown().await;
558                    }
559                });
560            }
561        });
562        port
563    }
564
565    #[tokio::test]
566    async fn test_request_with_client_identity_reaches_mtls_backend() {
567        crate::server::tls::install_crypto_provider();
568
569        // CA, a server cert for "localhost", and a client cert — one CA for both.
570        let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
571        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
572        let ca_key = rcgen::KeyPair::generate().unwrap();
573        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
574        let ca_issuer = rcgen::Issuer::from_ca_cert_der(ca_cert.der(), &ca_key).unwrap();
575
576        let server_params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap();
577        let server_key = rcgen::KeyPair::generate().unwrap();
578        let server_cert = server_params.signed_by(&server_key, &ca_issuer).unwrap();
579
580        let client_params = rcgen::CertificateParams::new(vec!["gw".to_string()]).unwrap();
581        let client_key = rcgen::KeyPair::generate().unwrap();
582        let client_cert = client_params.signed_by(&client_key, &ca_issuer).unwrap();
583
584        // Server side: require a client cert signed by the CA.
585        let mut roots = rustls::RootCertStore::empty();
586        roots
587            .add(rustls::pki_types::CertificateDer::from(
588                ca_cert.der().to_vec(),
589            ))
590            .unwrap();
591        let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
592            .build()
593            .unwrap();
594        let server_config = rustls::ServerConfig::builder()
595            .with_client_cert_verifier(verifier)
596            .with_single_cert(
597                vec![rustls::pki_types::CertificateDer::from(
598                    server_cert.der().to_vec(),
599                )],
600                rustls::pki_types::PrivateKeyDer::try_from(server_key.serialize_der()).unwrap(),
601            )
602            .unwrap();
603        let port = spawn_mtls_server(Arc::new(server_config)).await;
604
605        // Gateway side: identity = client cert + key, CA bundle for the server.
606        let dir = std::env::temp_dir();
607        let pid = std::process::id();
608        let cert_path = dir.join(format!("featherbit_ob_mtls_{}.crt", pid));
609        let key_path = dir.join(format!("featherbit_ob_mtls_{}.key", pid));
610        let ca_path = dir.join(format!("featherbit_ob_mtls_{}.ca.crt", pid));
611        std::fs::write(&cert_path, client_cert.pem()).unwrap();
612        std::fs::write(&key_path, client_key.serialize_pem()).unwrap();
613        std::fs::write(&ca_path, ca_cert.pem()).unwrap();
614
615        let identity = tls::UpstreamTls::load(
616            Some((cert_path.to_str().unwrap(), key_path.to_str().unwrap())),
617            Some(ca_path.to_str().unwrap()),
618            true,
619        )
620        .unwrap();
621
622        let client = OutboundClient::new();
623        let ok = client
624            .request(OutboundRequest {
625                method: http::Method::GET,
626                url: format!("https://localhost:{}/", port),
627                headers: Vec::new(),
628                body: Bytes::new(),
629                timeout: Duration::from_secs(5),
630                ssl_verify: true,
631                tls: Some(identity),
632            })
633            .await
634            .expect("mTLS request should succeed");
635        assert_eq!(ok.status, 200);
636
637        // Without a client cert (CA-only identity) the handshake is rejected.
638        let ca_only = tls::UpstreamTls::load(None, Some(ca_path.to_str().unwrap()), true).unwrap();
639        let err = client
640            .request(OutboundRequest {
641                method: http::Method::GET,
642                url: format!("https://localhost:{}/", port),
643                headers: Vec::new(),
644                body: Bytes::new(),
645                timeout: Duration::from_secs(5),
646                ssl_verify: true,
647                tls: Some(ca_only),
648            })
649            .await;
650        assert!(
651            matches!(err, Err(OutboundError::Transport(_))),
652            "got: {:?}",
653            err.map(|r| r.status)
654        );
655    }
656}