From ead183dd2c8445e37f2d957a49d027ab14bf759e Mon Sep 17 00:00:00 2001 From: Ben Sully Date: Mon, 27 Feb 2023 10:20:44 +0000 Subject: [PATCH] Add minimal proxy capabilities to `dioxus serve` This adds an MVP of some proxying capabilities to the `dioxus serve` server. The config is similar to that of `trunk serve`: the user can specify one or more proxy backends under `[[web.proxy]]` in Dioxus.toml, and the server will intercept requests targeted at the _path_ of that configured backend and forward them to the backend server. Example ------- For example, if the dev server is serving on port 8080 with this config: ``` [[web.proxy]] backend = "http://localhost:9000/api" ``` then requests to http://localhost:8080/api, http://localhost:8080/api/ and http://localhost:8080/api/any-subpath to be forwarded to the respective paths on http://localhost:9000. This PR doesn't handle path rewriting or anything yet but it would be fairly simple to add in future if anyone needs it. --- Cargo.lock | 15 ++++ Cargo.toml | 1 + docs/src/configure.md | 13 ++++ src/config.rs | 7 ++ src/error.rs | 6 ++ src/server/mod.rs | 39 +++++++--- src/server/proxy.rs | 171 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 src/server/proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 56d12181cc..d423ef3a61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -715,6 +715,7 @@ dependencies = [ "headers", "html_parser", "hyper", + "hyper-rustls", "indicatif", "lazy_static", "log", @@ -1329,7 +1330,9 @@ checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" dependencies = [ "http", "hyper", + "log", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", ] @@ -2330,6 +2333,18 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0167bac7a9f490495f3c33013e7722b53cb087ecbe082fb0c6387c96f634ea50" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index d949e67015..54beea7ba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ regex = "1.5.4" chrono = "0.4.19" anyhow = "1.0.53" hyper = "0.14.17" +hyper-rustls = "0.23.2" indicatif = "0.17.0-rc.11" subprocess = "0.2.9" diff --git a/docs/src/configure.md b/docs/src/configure.md index ac922f5a61..95408580cb 100644 --- a/docs/src/configure.md +++ b/docs/src/configure.md @@ -119,6 +119,16 @@ Only include resources at `Dev` mode. ] ``` +### Web.Proxy + +Proxy requests matching a path to a backend server. + +1. ***backend*** - the URL to the backend server. + ``` + backend = "http://localhost:8000/api/" + ``` + This will cause any requests made to the dev server with prefix /api/ to be redirected to the backend server at http://localhost:8000. The path and query parameters will be passed on as-is (path rewriting is not currently supported). + ## Config example ```toml @@ -168,4 +178,7 @@ style = [] # Javascript code file script = [] + +[[web.proxy]] +backend = "http://localhost:8000/api/" ``` diff --git a/src/config.rs b/src/config.rs index 0d9767b975..171d50a630 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,6 +63,7 @@ impl Default for DioxusConfig { title: Some("dioxus | ⛺".into()), base_path: None, }, + proxy: Some(vec![]), watcher: WebWatcherConfig { watch_path: Some(vec![PathBuf::from("src")]), reload_html: Some(false), @@ -97,6 +98,7 @@ pub struct ApplicationConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebConfig { pub app: WebAppConfig, + pub proxy: Option>, pub watcher: WebWatcherConfig, pub resource: WebResourceConfig, } @@ -107,6 +109,11 @@ pub struct WebAppConfig { pub base_path: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebProxyConfig { + pub backend: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebWatcherConfig { pub watch_path: Option>, diff --git a/src/error.rs b/src/error.rs index c3a276ad08..7832ab3265 100644 --- a/src/error.rs +++ b/src/error.rs @@ -32,6 +32,12 @@ pub enum Error { #[error("{0}")] CustomError(String), + #[error("Invalid proxy URL: {0}")] + InvalidProxy(#[from] hyper::http::uri::InvalidUri), + + #[error("Error proxying request: {0}")] + ProxyRequestError(hyper::Error), + #[error(transparent)] Other(#[from] anyhow::Error), } diff --git a/src/server/mod.rs b/src/server/mod.rs index b08e693f76..5ac8b6997c 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -23,6 +23,8 @@ use tokio::sync::broadcast; use tower::ServiceBuilder; use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody}; +mod proxy; + pub struct BuildManager { config: CrateConfig, reload_tx: broadcast::Sender<()>, @@ -284,16 +286,18 @@ pub async fn startup_hot_reload(ip: String, port: u16, config: CrateConfig) -> R ) .service(ServeDir::new(config.crate_dir.join(&dist_path))); - let router = Router::new() - .route("/_dioxus/ws", get(ws_handler)) - .fallback( - get_service(file_service).handle_error(|error: std::io::Error| async move { - ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Unhandled internal error: {}", error), - ) - }), - ); + let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); + for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() { + router = proxy::add_proxy(router, &proxy_config )?; + } + router = router.fallback(get_service(file_service).handle_error( + |error: std::io::Error| async move { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Unhandled internal error: {}", error), + ) + }, + )); let router = router .route("/_dioxus/hot_reload", get(hot_reload_handler)) @@ -427,8 +431,9 @@ pub async fn startup_default(ip: String, port: u16, config: CrateConfig) -> Resu ) .service(ServeDir::new(config.crate_dir.join(&dist_path))); - let router = Router::new() - .route("/_dioxus/ws", get(ws_handler)) + let mut router = Router::new().route("/_dioxus/ws", get(ws_handler)); + + router = router .fallback( get_service(file_service).handle_error(|error: std::io::Error| async move { ( @@ -498,6 +503,8 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre "False" }; + let proxies = config.dioxus_config.web.proxy.as_ref(); + if options.changed.is_empty() { println!( "{} @ v{} [{}] \n", @@ -528,6 +535,14 @@ fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: Pre println!(""); println!("\t> Profile : {}", profile.green()); println!("\t> Hot Reload : {}", hot_reload.cyan()); + if let Some(proxies) = proxies { + if !proxies.is_empty() { + println!("\t> Proxies :"); + for proxy in proxies { + println!("\t\t- {}", proxy.backend.blue()); + } + } + } println!("\t> Index Template : {}", custom_html_file.green()); println!("\t> URL Rewrite [index_on_404] : {}", url_rewrite.purple()); println!(""); diff --git a/src/server/proxy.rs b/src/server/proxy.rs new file mode 100644 index 0000000000..8de65f4dbe --- /dev/null +++ b/src/server/proxy.rs @@ -0,0 +1,171 @@ +use crate::{Result, WebProxyConfig}; + +use anyhow::Context; +use axum::{http::StatusCode, routing::any, Router}; +use hyper::{Request, Response, Uri}; + +#[derive(Debug, Clone)] +struct ProxyClient { + inner: hyper::Client>, + url: Uri, +} + +impl ProxyClient { + fn new(url: Uri) -> Self { + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .https_or_http() + .enable_http1() + .build(); + Self { + inner: hyper::Client::builder().build(https), + url, + } + } + + async fn send( + &self, + mut req: Request, + ) -> Result> { + let mut uri_parts = req.uri().clone().into_parts(); + uri_parts.authority = self.url.authority().cloned(); + uri_parts.scheme = self.url.scheme().cloned(); + *req.uri_mut() = Uri::from_parts(uri_parts).context("Invalid URI parts")?; + self.inner + .request(req) + .await + .map_err(crate::error::Error::ProxyRequestError) + } +} + +/// Add routes to the router handling the specified proxy config. +/// +/// We will proxy requests directed at either: +/// +/// - the exact path of the proxy config's backend URL, e.g. /api +/// - the exact path with a trailing slash, e.g. /api/ +/// - any subpath of the backend URL, e.g. /api/foo/bar +pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result { + let url: Uri = proxy.backend.parse()?; + let path = url.path().to_string(); + let client = ProxyClient::new(url); + + // We also match everything after the path using a wildcard matcher. + let wildcard_client = client.clone(); + + router = router.route( + // Always remove trailing /'s so that the exact route + // matches. + path.trim_end_matches('/'), + any(move |req| async move { + client + .send(req) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + }), + ); + + // Wildcard match anything else _after_ the backend URL's path. + // Note that we know `path` ends with a trailing `/` in this branch, + // so `wildcard` will look like `http://localhost/api/*proxywildcard`. + let wildcard = format!("{}/*proxywildcard", path.trim_end_matches('/')); + router = router.route( + &wildcard, + any(move |req| async move { + wildcard_client + .send(req) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + }), + ); + Ok(router) +} + +#[cfg(test)] +mod test { + + use super::*; + + use axum::{extract::Path, Router}; + + fn setup_servers( + mut config: WebProxyConfig, + ) -> ( + tokio::task::JoinHandle<()>, + tokio::task::JoinHandle<()>, + String, + ) { + let backend_router = Router::new().route( + "/*path", + any(|path: Path| async move { format!("backend: {}", path.0) }), + ); + let backend_server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap()) + .serve(backend_router.into_make_service()); + let backend_addr = backend_server.local_addr(); + let backend_handle = tokio::spawn(async move { backend_server.await.unwrap() }); + config.backend = format!("http://{}{}", backend_addr, config.backend); + let router = super::add_proxy(Router::new(), &config); + let server = axum::Server::bind(&"127.0.0.1:0".parse().unwrap()) + .serve(router.unwrap().into_make_service()); + let server_addr = server.local_addr(); + let server_handle = tokio::spawn(async move { server.await.unwrap() }); + (backend_handle, server_handle, server_addr.to_string()) + } + + async fn test_proxy_requests(path: String) { + let config = WebProxyConfig { + // Normally this would be an absolute URL including scheme/host/port, + // but in these tests we need to let the OS choose the port so tests + // don't conflict, so we'll concatenate the final address and this + // path together. + // So in day to day usage, use `http://localhost:8000/api` instead! + backend: path, + }; + let (backend_handle, server_handle, server_addr) = setup_servers(config); + let resp = hyper::Client::new() + .get(format!("http://{}/api", server_addr).parse().unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + hyper::body::to_bytes(resp.into_body()).await.unwrap(), + "backend: /api" + ); + + let resp = hyper::Client::new() + .get(format!("http://{}/api/", server_addr).parse().unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + hyper::body::to_bytes(resp.into_body()).await.unwrap(), + "backend: /api/" + ); + + let resp = hyper::Client::new() + .get( + format!("http://{}/api/subpath", server_addr) + .parse() + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + hyper::body::to_bytes(resp.into_body()).await.unwrap(), + "backend: /api/subpath" + ); + backend_handle.abort(); + server_handle.abort(); + } + + #[tokio::test] + async fn add_proxy() { + test_proxy_requests("/api".to_string()).await; + } + + #[tokio::test] + async fn add_proxy_trailing_slash() { + test_proxy_requests("/api/".to_string()).await; + } +}