use std::{fmt, net::SocketAddr}; use http::{HeaderMap, HeaderName, Method, StatusCode}; use reqwest::{Certificate, redirect::Policy}; use crate::{CanonicalUrl, OutboundError, OutboundPolicy, Resolver}; const REDIRECTS: [StatusCode; 5] = [ StatusCode::MOVED_PERMANENTLY, StatusCode::FOUND, StatusCode::SEE_OTHER, StatusCode::TEMPORARY_REDIRECT, StatusCode::PERMANENT_REDIRECT, ]; #[derive(Clone, Debug)] pub struct OutboundClient { resolver: R, policy: OutboundPolicy, roots: Vec, } #[derive(Clone, Debug)] pub struct OutboundClientBuilder { client: OutboundClient, } pub struct OutboundRequest { method: Method, url: CanonicalUrl, headers: HeaderMap, credential_headers: HeaderMap, } #[derive(Debug)] pub struct OutboundResponse { final_url: CanonicalUrl, response: reqwest::Response, } impl OutboundClient { #[must_use] pub fn builder(resolver: R, policy: OutboundPolicy) -> OutboundClientBuilder { OutboundClientBuilder { client: Self { resolver, policy, roots: Vec::new(), }, } } pub async fn execute( &self, mut request: OutboundRequest, ) -> Result { let mut redirects = 0; loop { let endpoint = self.policy.authorize(&request.url, &self.resolver).await?; let client = self.client_for(&request.url, &endpoint)?; let mut headers = request.headers.clone(); headers.extend(request.credential_headers.clone()); let response = client .request(request.method.clone(), request.url.as_url().clone()) .headers(headers) .send() .await .map_err(|_| OutboundError::Transport)?; if !REDIRECTS.contains(&response.status()) { return Ok(OutboundResponse { final_url: request.url, response, }); } if redirects == self.policy.max_redirects() { return Err(OutboundError::RedirectLimit); } let location = response .headers() .get(http::header::LOCATION) .ok_or(OutboundError::InvalidRedirect)? .to_str() .map_err(|_| OutboundError::InvalidRedirect)?; let next = request.url.join(location)?; if request.url.scheme() == "https" && next.scheme() == "http" { return Err(OutboundError::HttpsDowngrade); } if request.url.origin()? != next.origin()? { request.credential_headers.clear(); } request.url = next; redirects += 1; } } fn client_for( &self, url: &CanonicalUrl, endpoint: &crate::AllowedEndpoint, ) -> Result { let mut builder = reqwest::Client::builder() .redirect(Policy::none()) .no_proxy(); if url.literal_address().is_none() { builder = builder.resolve( url.host_str(), SocketAddr::new(endpoint.address(), endpoint.port()), ); } for root in &self.roots { builder = builder.add_root_certificate(root.clone()); } builder.build().map_err(|_| OutboundError::Transport) } } impl OutboundClientBuilder { #[must_use] pub fn add_root_certificate(mut self, certificate: Certificate) -> Self { self.client.roots.push(certificate); self } #[must_use] pub fn build(self) -> OutboundClient { self.client } } impl OutboundRequest { pub fn get(url: &str) -> Result { Ok(Self { method: Method::GET, url: CanonicalUrl::parse(url)?, headers: HeaderMap::new(), credential_headers: HeaderMap::new(), }) } pub fn head(url: &str) -> Result { Ok(Self { method: Method::HEAD, url: CanonicalUrl::parse(url)?, headers: HeaderMap::new(), credential_headers: HeaderMap::new(), }) } pub fn with_public_headers(mut self, headers: HeaderMap) -> Result { if headers .keys() .any(|name| !is_public_header(name) || self.credential_headers.contains_key(name)) { return Err(OutboundError::InvalidHeaders); } self.headers = headers; Ok(self) } pub fn with_credential_headers(mut self, headers: HeaderMap) -> Result { if headers .keys() .any(|name| is_connection_controlled_header(name) || self.headers.contains_key(name)) { return Err(OutboundError::InvalidHeaders); } self.credential_headers = headers; Ok(self) } } impl fmt::Debug for OutboundRequest { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("OutboundRequest") .field("method", &self.method) .field("header_count", &self.headers.len()) .field("credential_header_count", &self.credential_headers.len()) .finish_non_exhaustive() } } impl OutboundResponse { #[must_use] pub fn final_url(&self) -> &CanonicalUrl { &self.final_url } #[must_use] pub fn response(&self) -> &reqwest::Response { &self.response } #[must_use] pub fn into_response(self) -> reqwest::Response { self.response } } fn is_public_header(name: &HeaderName) -> bool { matches!( name.as_str(), "accept" | "accept-charset" | "accept-encoding" | "accept-language" | "cache-control" | "if-match" | "if-modified-since" | "if-none-match" | "if-range" | "if-unmodified-since" | "pragma" | "range" | "user-agent" ) } fn is_connection_controlled_header(name: &HeaderName) -> bool { matches!( name.as_str(), "connection" | "content-length" | "host" | "keep-alive" | "proxy-authenticate" | "proxy-authorization" | "te" | "trailer" | "transfer-encoding" | "upgrade" ) }