use async_trait::async_trait;
use http;
use http::Uri;
use std::borrow::Cow;
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};
pub type ProxyResolveFn = dyn Fn(
&Uri,
) -> Pin<
Box<
dyn Future<Output = Result<Option<String>, Box<dyn std::error::Error + Send + Sync + 'static>>>
+ Send
+ 'static,
>,
> + Send
+ Sync;
#[non_exhaustive]
#[derive(Clone)]
pub enum ProxyResolver {
HostMapping(HashMap<String, String>),
Custom(Arc<ProxyResolveFn>),
}
impl ProxyResolver {
pub fn host_mapping<I, K, V>(mapping: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
Self::HostMapping(
mapping
.into_iter()
.map(|(host, target)| (host.into(), target.into()))
.collect(),
)
}
pub fn custom<F, Fut>(resolve_fn: F) -> Self
where
F: Fn(&Uri) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<Option<String>, Box<dyn std::error::Error + Send + Sync + 'static>>>
+ Send
+ 'static,
{
Self::Custom(Arc::new(move |uri| Box::pin(resolve_fn(uri))))
}
pub async fn resolve(&self, uri: &Uri) -> crate::Result<Option<String>> {
match self {
Self::HostMapping(mapping) => match uri.host() {
Some(host) => Ok(mapping.get(host).map(|x| x.to_string()).to_owned()),
None => Ok(None),
},
Self::Custom(resolver) => {
let resolved = resolver(uri)
.await
.map_err(|_| crate::Error::CannotResolveProxyServer)?;
Ok(resolved)
}
}
}
}
fn proxy_url(target: &str, uri: &Uri) -> String {
format!(
"{}/{}{}",
target.trim_end_matches('/'),
uri.path().trim_start_matches('/'),
match uri.query() {
Some(query) => format!("?{query}"),
None => String::new(),
}
)
}
pub struct ProxyProtocol {
resolver: ProxyResolver,
client: OnceLock<reqwest::Client>,
}
impl ProxyProtocol {
pub fn new(resolver: ProxyResolver) -> Self {
Self {
resolver,
client: OnceLock::new(),
}
}
fn client(&self) -> crate::Result<&reqwest::Client> {
if let Some(client) = self.client.get() {
return Ok(client);
}
let client = reqwest::ClientBuilder::new().build()?;
Ok(self.client.get_or_init(|| client))
}
}
#[async_trait]
impl super::Protocol for ProxyProtocol {
#[cfg_attr(feature = "tracing", tracing::instrument(
skip_all,
fields(request.method = request.method().to_string(), request.uri = request.uri().to_string()),
err(level = "error")
))]
async fn handle(
&self,
request: http::Request<Vec<u8>>,
) -> crate::Result<http::Response<Cow<'static, [u8]>>> {
let target = self
.resolver
.resolve(request.uri())
.await?
.ok_or(crate::Error::CannotResolveProxyServer)?;
let url = proxy_url(&target, request.uri());
#[cfg(feature = "tracing")]
tracing::info!(localhost_uri = url);
let mut builder = http::Response::builder();
let mut proxy_builder = self
.client()?
.request(request.method().clone(), &url)
.headers(request.headers().clone());
proxy_builder = proxy_builder.body(request.body().clone());
let response = proxy_builder.send().await?;
let status = response.status();
for (name, value) in response.headers() {
builder = builder.header(name, value);
}
let body = response.bytes().await?;
let resp = builder.status(status).body(body.to_vec().into())?;
#[cfg(feature = "tracing")]
{
use crate::protocol::http_ext::HttpHeadersTracingInfo;
tracing::info!(
response.status = resp.status().as_u16(),
response.headers = resp.headers().tracing_info()
);
}
Ok(resp)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::Protocol;
use http;
use std::collections::HashMap;
use std::net::{SocketAddr, TcpListener};
use tiny_http::{Header as TinyHeader, Method, Response as TinyResponse, Server as TinyServer};
fn uri(s: &str) -> Uri {
s.parse().unwrap()
}
fn server() -> (SocketAddr, std::thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = TinyServer::from_listener(listener, None).unwrap();
let handle = std::thread::spawn(move || {
for request in server.incoming_requests() {
let known = request
.headers()
.iter()
.any(|header| header.field.equiv("If-None-Match") && header.value == "\"v1\"");
if request.method() == &Method::Get && request.url().starts_with("/index.html") {
if known {
let mut resp = TinyResponse::empty(304);
resp.add_header(TinyHeader::from_bytes("ETag", "\"v1\"").unwrap());
let _ = request.respond(resp);
} else {
let mut resp = TinyResponse::from_string("Hello World");
resp.add_header(TinyHeader::from_bytes("Content-Type", "text/plain").unwrap());
resp.add_header(TinyHeader::from_bytes("ETag", "\"v1\"").unwrap());
let _ = request.respond(resp);
}
} else {
let _ = request.respond(TinyResponse::empty(404));
}
}
});
(addr, handle)
}
#[tokio::test]
async fn proxy_host_mapping() {
let r = ProxyResolver::host_mapping([("app.wvb", "http://localhost:3000")]);
assert_eq!(
r.resolve(&uri("https://app.wvb/index.html"))
.await
.unwrap()
.as_deref(),
Some("http://localhost:3000")
);
assert_eq!(r.resolve(&uri("https://other.wvb/")).await.unwrap(), None);
let mapping = HashMap::from([("app.wvb".to_owned(), "http://localhost:3000".to_owned())]);
let r = ProxyResolver::host_mapping(mapping);
assert_eq!(
r.resolve(&uri("https://app.wvb/"))
.await
.unwrap()
.as_deref(),
Some("http://localhost:3000")
);
}
#[tokio::test]
async fn proxy_custom() {
let r = ProxyResolver::custom(|uri| {
let host = uri.host().map(str::to_owned);
async move { Ok(host.map(|host| format!("http://{host}:3000"))) }
});
assert_eq!(
r.resolve(&uri("https://app.wvb/index.html"))
.await
.unwrap()
.as_deref(),
Some("http://app.wvb:3000")
);
let r = ProxyResolver::custom(|_| async move {
let port = "not-a-port".parse::<u16>()?;
Ok(Some(format!("http://localhost:{port}")))
});
let err = r.resolve(&uri("https://app.wvb/")).await.unwrap_err();
assert!(matches!(err, crate::Error::CannotResolveProxyServer));
}
#[test]
fn proxy_url_joins_path_and_query() {
assert_eq!(
proxy_url(
"http://localhost:3000",
&uri("app://myapp/api/data?foo=bar")
),
"http://localhost:3000/api/data?foo=bar"
);
assert_eq!(
proxy_url("http://localhost:3000/", &uri("app://myapp/")),
"http://localhost:3000/"
);
}
#[test]
fn proxy_url_keeps_the_path_encoded() {
assert_eq!(
proxy_url(
"http://localhost:3000",
&uri("app://myapp/api%3Fdebug=true")
),
"http://localhost:3000/api%3Fdebug=true"
);
assert_eq!(
proxy_url("http://localhost:3000", &uri("app://myapp/a%2Fb/c%20d.js")),
"http://localhost:3000/a%2Fb/c%20d.js"
);
}
#[tokio::test]
async fn smoke() {
let (addr, _) = server();
let protocol = ProxyProtocol::new(ProxyResolver::host_mapping([(
"app.wvb",
format!("http://{addr}"),
)]));
let request = http::Request::builder()
.uri("scheme://app.wvb/index.html")
.method("GET")
.body(Vec::new())
.unwrap();
let response = protocol.handle(request).await.unwrap();
assert_eq!(response.status(), 200);
assert_eq!(
response.headers().get("content-type").unwrap(),
"text/plain"
);
assert_eq!(response.headers().get("etag").unwrap(), "\"v1\"");
assert_eq!(response.body().as_ref(), b"Hello World");
let conditional = http::Request::builder()
.uri("scheme://app.wvb/index.html")
.method("GET")
.header("If-None-Match", "\"v1\"")
.body(Vec::new())
.unwrap();
let response = protocol.handle(conditional).await.unwrap();
assert_eq!(response.status(), 304);
assert!(response.body().is_empty());
}
}