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 tls;
10
11use std::collections::HashMap;
12use std::sync::{Arc, OnceLock};
13use std::time::Duration;
14
15use bytes::Bytes;
16use http_body_util::{BodyExt, Full};
17use hyper_rustls::HttpsConnector;
18use hyper_util::client::legacy::connect::HttpConnector;
19use hyper_util::client::legacy::Client;
20use hyper_util::rt::TokioExecutor;
21
22type PooledClient = Client<HttpsConnector<HttpConnector>, Full<Bytes>>;
23
24/// A single outbound request. `timeout` covers the whole call: connect,
25/// request write, and response body collection.
26pub struct OutboundRequest {
27    pub method: http::Method,
28    pub url: String,
29    /// Header name/value pairs; names may repeat for multi-value headers.
30    pub headers: Vec<(String, String)>,
31    pub body: Bytes,
32    /// Whole-call deadline. Callers should default to 3s for callouts.
33    pub timeout: Duration,
34    /// When false, TLS certificate verification is disabled (matching
35    /// APISIX's `ssl_verify: false`). Ignored for plain-http URLs and when
36    /// `tls` is set (the identity carries its own verify flag).
37    pub ssl_verify: bool,
38    /// Per-upstream TLS identity (client cert / private CA). `None` uses the
39    /// shared verified/insecure clients — today's behavior.
40    pub tls: Option<Arc<tls::UpstreamTls>>,
41}
42
43impl OutboundRequest {
44    /// A GET with the callout default timeout (3s) and TLS verification on.
45    #[allow(dead_code)] // convenience ctor; callers currently build the struct literally
46    pub fn new(method: http::Method, url: impl Into<String>) -> Self {
47        Self {
48            method,
49            url: url.into(),
50            headers: Vec::new(),
51            body: Bytes::new(),
52            timeout: Duration::from_secs(3),
53            ssl_verify: true,
54            tls: None,
55        }
56    }
57}
58
59/// A fully-buffered outbound response.
60pub struct OutboundResponse {
61    pub status: u16,
62    pub headers: HashMap<String, Vec<String>>,
63    pub body: Bytes,
64}
65
66/// Outbound call failure, distinguishing timeouts from transport errors so
67/// callers can map them to distinct gateway error codes.
68#[derive(Debug)]
69pub enum OutboundError {
70    Timeout(Duration),
71    /// Request could not be built (bad URL/header) — a config-shaped error.
72    InvalidRequest(String),
73    /// Connect/transport/body failure.
74    Transport(String),
75}
76
77impl std::fmt::Display for OutboundError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            Self::Timeout(d) => write!(f, "outbound request timed out after {:?}", d),
81            Self::InvalidRequest(m) => write!(f, "invalid outbound request: {}", m),
82            Self::Transport(m) => write!(f, "outbound transport error: {}", m),
83        }
84    }
85}
86
87/// Process-wide pooled HTTP client.
88pub struct OutboundClient {
89    verified: PooledClient,
90    /// Built on first `ssl_verify: false` request; never constructed on the
91    /// common path.
92    insecure: OnceLock<PooledClient>,
93    /// Clients for per-upstream TLS identities (mTLS / private CA), keyed by
94    /// the identity's content hash. Never evicted — bounded by the number of
95    /// distinct identities ever configured.
96    custom: std::sync::RwLock<HashMap<u64, PooledClient>>,
97}
98
99impl OutboundClient {
100    /// Builds the shared client. TLS uses the platform's native root store.
101    /// Advertises both HTTP/1.1 and HTTP/2 via ALPN, so TLS upstreams that
102    /// support h2 (e.g. gRPC backends) are served over HTTP/2 while plain-`http`
103    /// upstreams stay HTTP/1.1.
104    pub fn new() -> Self {
105        // Pin the process-level rustls provider to ring (both backends compile
106        // in through the dependency tree). Shared with the listeners.
107        crate::server::tls::install_crypto_provider();
108
109        let https = hyper_rustls::HttpsConnectorBuilder::new()
110            .with_native_roots()
111            .expect("failed to load native TLS roots")
112            .https_or_http()
113            .enable_http1()
114            .enable_http2()
115            .build();
116        Self {
117            verified: Client::builder(TokioExecutor::new()).build(https),
118            insecure: OnceLock::new(),
119            custom: std::sync::RwLock::new(HashMap::new()),
120        }
121    }
122
123    /// Performs the request, honoring `timeout` and `ssl_verify`.
124    pub async fn request(&self, req: OutboundRequest) -> Result<OutboundResponse, OutboundError> {
125        let mut builder = http::Request::builder().method(req.method).uri(&req.url);
126        for (name, value) in &req.headers {
127            builder = builder.header(name.as_str(), value.as_str());
128        }
129        let request = builder
130            .body(Full::new(req.body))
131            .map_err(|e| OutboundError::InvalidRequest(e.to_string()))?;
132
133        let custom_client;
134        let client = match &req.tls {
135            Some(identity) => {
136                custom_client = self.identity_client(identity)?;
137                &custom_client
138            }
139            None if req.ssl_verify => &self.verified,
140            None => self.insecure.get_or_init(build_insecure_client),
141        };
142
143        let deadline = req.timeout;
144        let call = async {
145            let response = client
146                .request(request)
147                .await
148                .map_err(|e| OutboundError::Transport(e.to_string()))?;
149
150            let status = response.status().as_u16();
151            let mut headers: HashMap<String, Vec<String>> = HashMap::new();
152            for (name, value) in response.headers() {
153                headers
154                    .entry(name.as_str().to_string())
155                    .or_default()
156                    .push(value.to_str().unwrap_or("").to_string());
157            }
158            let body = response
159                .into_body()
160                .collect()
161                .await
162                .map_err(|e| OutboundError::Transport(e.to_string()))?
163                .to_bytes();
164
165            Ok(OutboundResponse {
166                status,
167                headers,
168                body,
169            })
170        };
171
172        tokio::time::timeout(deadline, call)
173            .await
174            .map_err(|_| OutboundError::Timeout(deadline))?
175    }
176
177    /// Returns the pooled client for `identity`, building it on first use.
178    /// ALPN advertises h1+h2 like the default client (hyper-rustls sets the
179    /// protocols on the config in `enable_http1`/`enable_http2`).
180    fn identity_client(
181        &self,
182        identity: &Arc<tls::UpstreamTls>,
183    ) -> Result<PooledClient, OutboundError> {
184        if let Some(c) = self.custom.read().unwrap().get(&identity.cache_key()) {
185            return Ok(c.clone());
186        }
187        // Materials were validated at policy compile; failure here is
188        // config-shaped (e.g. native roots unavailable), not transport.
189        let config = identity
190            .client_config()
191            .map_err(OutboundError::InvalidRequest)?;
192        let https = hyper_rustls::HttpsConnectorBuilder::new()
193            .with_tls_config(config)
194            .https_or_http()
195            .enable_http1()
196            .enable_http2()
197            .build();
198        let client = Client::builder(TokioExecutor::new()).build(https);
199        // Race-safe: whoever loses uses the winner's client.
200        Ok(self
201            .custom
202            .write()
203            .unwrap()
204            .entry(identity.cache_key())
205            .or_insert(client)
206            .clone())
207    }
208}
209
210impl Default for OutboundClient {
211    fn default() -> Self {
212        Self::new()
213    }
214}
215
216fn build_insecure_client() -> PooledClient {
217    let config = rustls::ClientConfig::builder()
218        .dangerous()
219        .with_custom_certificate_verifier(Arc::new(NoVerification(
220            rustls::crypto::ring::default_provider(),
221        )))
222        .with_no_client_auth();
223    let https = hyper_rustls::HttpsConnectorBuilder::new()
224        .with_tls_config(config)
225        .https_or_http()
226        .enable_http1()
227        .build();
228    Client::builder(TokioExecutor::new()).build(https)
229}
230
231/// Builds a client TLS connector for a raw upstream WebSocket (`wss`) handshake.
232///
233/// ALPN is pinned to `http/1.1` (the WebSocket upgrade is HTTP/1.1). When
234/// `identity` is set, the connector presents that client certificate (and
235/// trusts its private CA, if any) — mirroring `OutboundClient::identity_client`
236/// — and is cached per identity (`UpstreamTls::cache_key`), never evicted. When
237/// `identity` is `None` and `verify` is false, certificate verification is
238/// disabled (matching `ssl_verify: false`). The verified connector is cached,
239/// since loading the platform's native root store on every connection is
240/// wasteful; the insecure connector is cheap and built on demand.
241pub fn client_tls_connector(
242    verify: bool,
243    identity: Option<&Arc<tls::UpstreamTls>>,
244) -> Result<tokio_rustls::TlsConnector, String> {
245    crate::server::tls::install_crypto_provider();
246
247    if let Some(id) = identity {
248        static CUSTOM: OnceLock<std::sync::RwLock<HashMap<u64, tokio_rustls::TlsConnector>>> =
249            OnceLock::new();
250        let cache = CUSTOM.get_or_init(|| std::sync::RwLock::new(HashMap::new()));
251        if let Some(c) = cache.read().unwrap().get(&id.cache_key()) {
252            return Ok(c.clone());
253        }
254        let mut config = id.client_config()?;
255        config.alpn_protocols = vec![b"http/1.1".to_vec()];
256        let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
257        return Ok(cache
258            .write()
259            .unwrap()
260            .entry(id.cache_key())
261            .or_insert(connector)
262            .clone());
263    }
264
265    if !verify {
266        let config = rustls::ClientConfig::builder()
267            .dangerous()
268            .with_custom_certificate_verifier(Arc::new(NoVerification(
269                rustls::crypto::ring::default_provider(),
270            )))
271            .with_no_client_auth();
272        let mut config = config;
273        config.alpn_protocols = vec![b"http/1.1".to_vec()];
274        return Ok(tokio_rustls::TlsConnector::from(Arc::new(config)));
275    }
276
277    static VERIFIED: OnceLock<tokio_rustls::TlsConnector> = OnceLock::new();
278    if let Some(c) = VERIFIED.get() {
279        return Ok(c.clone());
280    }
281
282    let mut roots = rustls::RootCertStore::empty();
283    let loaded = rustls_native_certs::load_native_certs();
284    let (added, _ignored) = roots.add_parsable_certificates(loaded.certs);
285    if added == 0 {
286        return Err(format!(
287            "no usable native root certificates ({} load error(s))",
288            loaded.errors.len()
289        ));
290    }
291    let mut config = rustls::ClientConfig::builder()
292        .with_root_certificates(roots)
293        .with_no_client_auth();
294    config.alpn_protocols = vec![b"http/1.1".to_vec()];
295    let connector = tokio_rustls::TlsConnector::from(Arc::new(config));
296    // Race-safe: whoever loses just uses the winner's connector.
297    Ok(VERIFIED.get_or_init(|| connector).clone())
298}
299
300/// Certificate verifier that accepts everything — only reachable via an
301/// explicit `ssl_verify: false` in plugin config.
302#[derive(Debug)]
303pub(crate) struct NoVerification(pub(crate) rustls::crypto::CryptoProvider);
304
305impl rustls::client::danger::ServerCertVerifier for NoVerification {
306    fn verify_server_cert(
307        &self,
308        _end_entity: &rustls::pki_types::CertificateDer<'_>,
309        _intermediates: &[rustls::pki_types::CertificateDer<'_>],
310        _server_name: &rustls::pki_types::ServerName<'_>,
311        _ocsp_response: &[u8],
312        _now: rustls::pki_types::UnixTime,
313    ) -> Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
314        Ok(rustls::client::danger::ServerCertVerified::assertion())
315    }
316
317    fn verify_tls12_signature(
318        &self,
319        message: &[u8],
320        cert: &rustls::pki_types::CertificateDer<'_>,
321        dss: &rustls::DigitallySignedStruct,
322    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
323        rustls::crypto::verify_tls12_signature(
324            message,
325            cert,
326            dss,
327            &self.0.signature_verification_algorithms,
328        )
329    }
330
331    fn verify_tls13_signature(
332        &self,
333        message: &[u8],
334        cert: &rustls::pki_types::CertificateDer<'_>,
335        dss: &rustls::DigitallySignedStruct,
336    ) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
337        rustls::crypto::verify_tls13_signature(
338            message,
339            cert,
340            dss,
341            &self.0.signature_verification_algorithms,
342        )
343    }
344
345    fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
346        self.0.signature_verification_algorithms.supported_schemes()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    #[test]
355    fn test_client_tls_connector_insecure_builds() {
356        // Insecure connector never touches the native store — fast/deterministic.
357        assert!(client_tls_connector(false, None).is_ok());
358    }
359
360    #[test]
361    fn test_client_tls_connector_verified_builds() {
362        // On a normal dev/CI host the native root store loads; if an
363        // environment has no parsable roots the helper returns Err gracefully.
364        assert!(client_tls_connector(true, None).is_ok());
365    }
366
367    #[test]
368    fn test_client_tls_connector_with_identity_builds_and_caches() {
369        // Reuse the identity written by the Task 1 helper — write PEMs inline
370        // here the same way (CA + leaf via rcgen, temp files).
371        let (cert, key, ca) = {
372            let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
373            ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
374            let ca_key = rcgen::KeyPair::generate().unwrap();
375            let ca_cert = ca_params.self_signed(&ca_key).unwrap();
376            let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
377            let leaf_key = rcgen::KeyPair::generate().unwrap();
378            let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
379            let dir = std::env::temp_dir();
380            let pid = std::process::id();
381            let cert = dir.join(format!("featherbit_wsid_{}.crt", pid));
382            let key = dir.join(format!("featherbit_wsid_{}.key", pid));
383            let ca = dir.join(format!("featherbit_wsid_{}.ca.crt", pid));
384            std::fs::write(&cert, leaf_cert.pem()).unwrap();
385            std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
386            std::fs::write(&ca, ca_cert.pem()).unwrap();
387            (
388                cert.to_str().unwrap().to_string(),
389                key.to_str().unwrap().to_string(),
390                ca.to_str().unwrap().to_string(),
391            )
392        };
393        let identity = tls::UpstreamTls::load(Some((&cert, &key)), Some(&ca), true).unwrap();
394        assert!(client_tls_connector(true, Some(&identity)).is_ok());
395        // Second call hits the connector cache — still fine.
396        assert!(client_tls_connector(true, Some(&identity)).is_ok());
397        // No identity: existing behavior, both variants still build.
398        assert!(client_tls_connector(true, None).is_ok());
399        assert!(client_tls_connector(false, None).is_ok());
400    }
401
402    /// Minimal one-shot HTTPS server that requires a client certificate and
403    /// answers any request with `HTTP/1.1 200 OK`. Returns its port.
404    async fn spawn_mtls_server(server_config: Arc<rustls::ServerConfig>) -> u16 {
405        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
406        let port = listener.local_addr().unwrap().port();
407        tokio::spawn(async move {
408            let acceptor = tokio_rustls::TlsAcceptor::from(server_config);
409            // Serve connections until the test ends; failed handshakes
410            // (missing client cert) just drop the connection.
411            loop {
412                let Ok((tcp, _)) = listener.accept().await else {
413                    break;
414                };
415                let acceptor = acceptor.clone();
416                tokio::spawn(async move {
417                    if let Ok(mut stream) = acceptor.accept(tcp).await {
418                        use tokio::io::{AsyncReadExt, AsyncWriteExt};
419                        let mut buf = [0u8; 4096];
420                        let _ = stream.read(&mut buf).await;
421                        let _ = stream
422                            .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok")
423                            .await;
424                        let _ = stream.shutdown().await;
425                    }
426                });
427            }
428        });
429        port
430    }
431
432    #[tokio::test]
433    async fn test_request_with_client_identity_reaches_mtls_backend() {
434        crate::server::tls::install_crypto_provider();
435
436        // CA, a server cert for "localhost", and a client cert — one CA for both.
437        let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
438        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
439        let ca_key = rcgen::KeyPair::generate().unwrap();
440        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
441
442        let server_params = rcgen::CertificateParams::new(vec!["localhost".to_string()]).unwrap();
443        let server_key = rcgen::KeyPair::generate().unwrap();
444        let server_cert = server_params
445            .signed_by(&server_key, &ca_cert, &ca_key)
446            .unwrap();
447
448        let client_params = rcgen::CertificateParams::new(vec!["gw".to_string()]).unwrap();
449        let client_key = rcgen::KeyPair::generate().unwrap();
450        let client_cert = client_params
451            .signed_by(&client_key, &ca_cert, &ca_key)
452            .unwrap();
453
454        // Server side: require a client cert signed by the CA.
455        let mut roots = rustls::RootCertStore::empty();
456        roots
457            .add(rustls::pki_types::CertificateDer::from(
458                ca_cert.der().to_vec(),
459            ))
460            .unwrap();
461        let verifier = rustls::server::WebPkiClientVerifier::builder(Arc::new(roots))
462            .build()
463            .unwrap();
464        let server_config = rustls::ServerConfig::builder()
465            .with_client_cert_verifier(verifier)
466            .with_single_cert(
467                vec![rustls::pki_types::CertificateDer::from(
468                    server_cert.der().to_vec(),
469                )],
470                rustls::pki_types::PrivateKeyDer::try_from(server_key.serialize_der()).unwrap(),
471            )
472            .unwrap();
473        let port = spawn_mtls_server(Arc::new(server_config)).await;
474
475        // Gateway side: identity = client cert + key, CA bundle for the server.
476        let dir = std::env::temp_dir();
477        let pid = std::process::id();
478        let cert_path = dir.join(format!("featherbit_ob_mtls_{}.crt", pid));
479        let key_path = dir.join(format!("featherbit_ob_mtls_{}.key", pid));
480        let ca_path = dir.join(format!("featherbit_ob_mtls_{}.ca.crt", pid));
481        std::fs::write(&cert_path, client_cert.pem()).unwrap();
482        std::fs::write(&key_path, client_key.serialize_pem()).unwrap();
483        std::fs::write(&ca_path, ca_cert.pem()).unwrap();
484
485        let identity = tls::UpstreamTls::load(
486            Some((cert_path.to_str().unwrap(), key_path.to_str().unwrap())),
487            Some(ca_path.to_str().unwrap()),
488            true,
489        )
490        .unwrap();
491
492        let client = OutboundClient::new();
493        let ok = client
494            .request(OutboundRequest {
495                method: http::Method::GET,
496                url: format!("https://localhost:{}/", port),
497                headers: Vec::new(),
498                body: Bytes::new(),
499                timeout: Duration::from_secs(5),
500                ssl_verify: true,
501                tls: Some(identity),
502            })
503            .await
504            .expect("mTLS request should succeed");
505        assert_eq!(ok.status, 200);
506
507        // Without a client cert (CA-only identity) the handshake is rejected.
508        let ca_only = tls::UpstreamTls::load(None, Some(ca_path.to_str().unwrap()), true).unwrap();
509        let err = client
510            .request(OutboundRequest {
511                method: http::Method::GET,
512                url: format!("https://localhost:{}/", port),
513                headers: Vec::new(),
514                body: Bytes::new(),
515                timeout: Duration::from_secs(5),
516                ssl_verify: true,
517                tls: Some(ca_only),
518            })
519            .await;
520        assert!(
521            matches!(err, Err(OutboundError::Transport(_))),
522            "got: {:?}",
523            err.map(|r| r.status)
524        );
525    }
526}