Files
fluxer/tools/dev/src/proxy.rs
T

811 lines
25 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::manifest::{
ANY_HOST, DEV_PROXY_GATEWAY_PORTS_ENV, DEV_PROXY_PORT, GATEWAY_PORT, LOCAL_APP_URL,
PROXY_ROUTES, ProxyRoute,
};
use anyhow::{Context, Result, bail};
use axum::{
Router,
body::Body,
extract::{ConnectInfo, State},
http::{HeaderMap, HeaderValue, Method, Request, Response, StatusCode, header},
response::IntoResponse,
routing::any,
};
use hyper_util::rt::TokioIo;
use std::collections::HashMap;
use std::env;
use std::net::SocketAddr;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use url::Url;
static ROUTE_CURSORS: LazyLock<Mutex<HashMap<&'static str, usize>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Clone)]
struct ProxyState {
http_client: reqwest::Client,
gateway_ports: Arc<[u16]>,
}
const BLOCKED_REQUEST_HEADERS: &[&str] = &[
"connection",
"content-length",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
const BLOCKED_RESPONSE_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for";
pub async fn run_proxy(host: &str, port: u16) -> Result<()> {
let bind = format!("{host}:{port}");
let listener = TcpListener::bind(&bind).await?;
println!("Fluxer dev proxy listening on {}", listener.local_addr()?);
let http_client = reqwest::Client::builder()
.pool_max_idle_per_host(64)
.pool_idle_timeout(Duration::from_secs(90))
.redirect(reqwest::redirect::Policy::none())
.tcp_nodelay(true)
.build()
.context("failed to build dev proxy HTTP client")?;
let gateway_ports = gateway_proxy_ports()?;
println!(
"Fluxer dev proxy gateway ports: {}",
gateway_ports
.iter()
.map(u16::to_string)
.collect::<Vec<_>>()
.join(",")
);
let app = Router::new()
.fallback(any(proxy_request))
.with_state(ProxyState {
http_client,
gateway_ports,
});
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("dev proxy server exited unexpectedly")
}
async fn proxy_request(
State(state): State<ProxyState>,
ConnectInfo(client_addr): ConnectInfo<SocketAddr>,
request: Request<Body>,
) -> Response<Body> {
let request_head = request_head_from_request(&request);
if let Some(location) = tunnel_public_redirect_location(&request_head) {
return redirect_response(&request_head, &location).into_response();
}
let route = route_for_path(&request_head.path);
let (target_host, target_port) = target_for_route(route, &state.gateway_ports);
if is_upgrade_request(&request_head) {
return proxy_upgrade(
request,
request_head,
route,
target_host,
target_port,
client_addr,
)
.await;
}
proxy_http(
state,
request,
request_head,
route,
target_host,
target_port,
client_addr,
)
.await
}
async fn proxy_http(
state: ProxyState,
request: Request<Body>,
request_head: RequestHead,
route: &'static ProxyRoute,
target_host: &'static str,
target_port: u16,
client_addr: SocketAddr,
) -> Response<Body> {
let target_url = upstream_http_url(&request_head.path, route, target_host, target_port);
let (parts, body) = request.into_parts();
let method = parts.method.clone();
let mut request_builder = state.http_client.request(method.clone(), &target_url);
if method != Method::GET && method != Method::HEAD {
request_builder = request_builder.body(reqwest::Body::wrap_stream(body.into_data_stream()));
}
let mut saw_forwarded_for = false;
for (name, value) in &parts.headers {
let name_str = name.as_str();
if BLOCKED_REQUEST_HEADERS.contains(&name_str) {
continue;
}
if name.as_str().eq_ignore_ascii_case(X_FORWARDED_FOR_HEADER) {
saw_forwarded_for = true;
if let Some(value) = append_forwarded_for(value, client_addr) {
request_builder = request_builder.header(name.clone(), value);
}
} else {
request_builder = request_builder.header(name.clone(), value.clone());
}
}
if !saw_forwarded_for {
request_builder =
request_builder.header(X_FORWARDED_FOR_HEADER, client_addr.ip().to_string());
}
let upstream_response = match request_builder.send().await {
Ok(response) => response,
Err(error) => {
eprintln!("dev proxy HTTP request failed for {target_url}: {error:#}");
return bad_gateway_response(error);
}
};
let status = upstream_response.status();
let headers = upstream_response.headers().clone();
let mut response = Response::new(Body::from_stream(upstream_response.bytes_stream()));
*response.status_mut() = status;
copy_response_headers(&headers, response.headers_mut());
response
}
async fn proxy_upgrade(
mut request: Request<Body>,
request_head: RequestHead,
route: &'static ProxyRoute,
target_host: &'static str,
target_port: u16,
client_addr: SocketAddr,
) -> Response<Body> {
let on_upgrade = hyper::upgrade::on(&mut request);
let mut target = match TcpStream::connect((target_host, target_port)).await {
Ok(target) => target,
Err(error) => return bad_gateway_response(error),
};
if let Err(error) = target
.write_all(&rewrite_head(
&request_head,
route,
Some(target_host),
Some(target_port),
Some(&client_addr.ip().to_string()),
))
.await
{
return bad_gateway_response(error);
}
let (head, body_prefix) = match read_http_head(&mut target).await {
Ok(value) => value,
Err(error) => return bad_gateway_response(error),
};
let upstream_response = match parse_response_head(&head) {
Ok(value) => value,
Err(error) => return bad_gateway_response(error),
};
let mut response = Response::new(Body::empty());
*response.status_mut() = upstream_response.status;
copy_upgrade_response_headers(&upstream_response.headers, response.headers_mut());
if upstream_response.status != StatusCode::SWITCHING_PROTOCOLS {
*response.body_mut() = Body::from(body_prefix);
return response;
}
tokio::spawn(async move {
let result: Result<()> = async move {
let upgraded = on_upgrade.await?;
let mut client = TokioIo::new(upgraded);
if !body_prefix.is_empty() {
client.write_all(&body_prefix).await?;
}
tokio::io::copy_bidirectional(&mut client, &mut target).await?;
Ok(())
}
.await;
if let Err(error) = result {
eprintln!("dev proxy upgrade bridge failed: {error:#}");
}
});
response
}
fn upstream_http_url(
request_path: &str,
route: &ProxyRoute,
target_host: &str,
target_port: u16,
) -> String {
format!(
"http://{target_host}:{target_port}{}",
rewrite_path(request_path, route)
)
}
fn request_head_from_request(request: &Request<Body>) -> RequestHead {
RequestHead {
method: request.method().as_str().to_owned(),
path: request
.uri()
.path_and_query()
.map(|value| value.as_str().to_owned())
.unwrap_or_else(|| request.uri().to_string()),
version: format!("{:?}", request.version()),
headers: request
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_owned(),
value.to_str().unwrap_or_default().to_owned(),
)
})
.collect(),
}
}
fn append_forwarded_for(value: &HeaderValue, client_addr: SocketAddr) -> Option<HeaderValue> {
let value = value.to_str().ok()?;
HeaderValue::from_str(&format!("{value}, {}", client_addr.ip())).ok()
}
fn copy_response_headers(source: &HeaderMap, target: &mut HeaderMap) {
for (name, value) in source {
if BLOCKED_RESPONSE_HEADERS.contains(&name.as_str()) {
continue;
}
target.append(name.clone(), value.clone());
}
}
fn copy_upgrade_response_headers(source: &[(String, String)], target: &mut HeaderMap) {
for (name, value) in source {
let Ok(name) = axum::http::HeaderName::from_bytes(name.as_bytes()) else {
continue;
};
let Ok(value) = HeaderValue::from_str(value) else {
continue;
};
target.append(name, value);
}
}
fn bad_gateway_response(error: impl std::fmt::Display) -> Response<Body> {
(
StatusCode::BAD_GATEWAY,
[(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
)],
format!("{error}\n"),
)
.into_response()
}
async fn read_http_head(stream: &mut TcpStream) -> Result<(Vec<u8>, Vec<u8>)> {
let mut head = Vec::new();
let mut buf = [0_u8; 4096];
loop {
let count = stream.read(&mut buf).await?;
if count == 0 {
bail!("connection closed before response head");
}
head.extend_from_slice(&buf[..count]);
if head.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
if head.len() > 64 * 1024 {
bail!("response head exceeded 64KiB");
}
}
let header_end = head
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
.context("missing response header terminator")?;
let body_prefix = head.split_off(header_end);
Ok((head, body_prefix))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResponseHead {
status: StatusCode,
headers: Vec<(String, String)>,
}
fn parse_response_head(head: &[u8]) -> Result<ResponseHead> {
let text = String::from_utf8_lossy(head);
let mut lines = text.split("\r\n");
let status_line = lines.next().unwrap_or_default();
let parts = status_line.splitn(3, ' ').collect::<Vec<_>>();
if parts.len() < 2 {
bail!("invalid response status line: {status_line:?}");
}
let status_code = parts[1]
.parse::<u16>()
.with_context(|| format!("invalid response status code: {:?}", parts[1]))?;
let status = StatusCode::from_u16(status_code)
.with_context(|| format!("unsupported response status code: {status_code}"))?;
let headers = lines
.filter_map(|line| {
if line.is_empty() {
return None;
}
let (name, value) = line.split_once(':')?;
(!name.is_empty()).then(|| (name.to_owned(), value.trim().to_owned()))
})
.collect();
Ok(ResponseHead { status, headers })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestHead {
pub method: String,
pub path: String,
pub version: String,
pub headers: Vec<(String, String)>,
}
pub fn parse_request_head(head: &[u8]) -> Result<RequestHead> {
let text = String::from_utf8_lossy(head);
let mut lines = text.split("\r\n");
let request_line = lines.next().unwrap_or_default();
let parts = request_line.splitn(3, ' ').collect::<Vec<_>>();
if parts.len() != 3 {
bail!("invalid request line: {request_line:?}");
}
let headers = lines
.filter_map(|line| {
if line.is_empty() {
return None;
}
let (name, value) = line.split_once(':')?;
(!name.is_empty()).then(|| (name.to_owned(), value.trim().to_owned()))
})
.collect();
Ok(RequestHead {
method: parts[0].to_owned(),
path: parts[1].to_owned(),
version: parts[2].to_owned(),
headers,
})
}
pub fn tunnel_public_redirect_location(request: &RequestHead) -> Option<String> {
let public_url = env::var("FLUXER_PUBLIC_URL").ok()?;
redirect_location_for_public_url(request, &public_url)
}
pub fn redirect_location_for_public_url(request: &RequestHead, public_url: &str) -> Option<String> {
if !request.method.eq_ignore_ascii_case("GET") && !request.method.eq_ignore_ascii_case("HEAD") {
return None;
}
if !is_local_dev_host(request) || !accepts_html(request) {
return None;
}
let parsed_public_url = Url::parse(public_url).ok()?;
if is_local_url(&parsed_public_url) {
return None;
}
let path = normalize_request_target(&request.path);
Some(format!(
"{}{}",
parsed_public_url.as_str().trim_end_matches('/'),
path
))
}
fn redirect_response(request: &RequestHead, location: &str) -> Response<Body> {
let body = if request.method.eq_ignore_ascii_case("HEAD") {
String::new()
} else {
format!("Redirecting to {location}\n")
};
let mut response = Response::new(Body::from(body));
*response.status_mut() = StatusCode::FOUND;
if let Ok(value) = HeaderValue::from_str(location) {
response.headers_mut().insert(header::LOCATION, value);
}
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
response
}
fn is_local_dev_host(request: &RequestHead) -> bool {
let Some(host) = header_value(request, "host") else {
return false;
};
matches!(
host_without_port(host).as_deref(),
Some("localhost" | "127.0.0.1" | "::1" | "0.0.0.0")
)
}
fn is_local_url(url: &Url) -> bool {
if url.as_str().trim_end_matches('/') == LOCAL_APP_URL {
return true;
}
matches!(
url.host_str(),
Some("localhost" | "127.0.0.1" | "::1" | "0.0.0.0")
)
}
fn accepts_html(request: &RequestHead) -> bool {
header_value(request, "accept")
.map(|value| value.to_ascii_lowercase().contains("text/html"))
.unwrap_or(false)
}
fn is_upgrade_request(request: &RequestHead) -> bool {
let connection_upgrade = header_value(request, "connection")
.map(|value| value.to_ascii_lowercase().contains("upgrade"))
.unwrap_or(false);
connection_upgrade && header_value(request, "upgrade").is_some()
}
fn header_value<'a>(request: &'a RequestHead, name: &str) -> Option<&'a str> {
request
.headers
.iter()
.find(|(header_name, _)| header_name.eq_ignore_ascii_case(name))
.map(|(_, value)| value.as_str())
}
fn host_without_port(host: &str) -> Option<String> {
let host = host.trim().to_ascii_lowercase();
if host.is_empty() {
return None;
}
if let Some(rest) = host.strip_prefix('[') {
return rest.split_once(']').map(|(host, _)| host.to_owned());
}
Some(host.split(':').next().unwrap_or(&host).to_owned())
}
pub fn route_for_path(path: &str) -> &'static ProxyRoute {
let normalized_path = normalize_request_target(path);
let parsed_path = Url::parse(&format!("http://fluxer.local{normalized_path}"))
.map(|url| url.path().to_owned())
.unwrap_or_else(|_| {
normalized_path
.split('?')
.next()
.unwrap_or(&normalized_path)
.to_owned()
});
for route in PROXY_ROUTES {
if route.prefix == "/" {
return route;
}
if parsed_path == route.prefix
|| parsed_path.starts_with(&format!("{}/", route.prefix))
|| (route.prefix.ends_with('-') && parsed_path.starts_with(route.prefix))
{
return route;
}
}
PROXY_ROUTES.last().expect("proxy has fallback route")
}
pub fn target_for_route(route: &'static ProxyRoute, gateway_ports: &[u16]) -> (&'static str, u16) {
let uses_gateway_ports = route.prefix == "/gateway";
let port_count = if uses_gateway_ports {
gateway_ports.len()
} else {
route.alternate_ports.len() + 1
};
assert!(
port_count > 0,
"proxy route must have at least one target port"
);
if port_count == 1 {
if uses_gateway_ports {
return (route.host, gateway_ports[0]);
}
return (route.host, route.port);
}
let mut cursors = ROUTE_CURSORS.lock().expect("route cursor lock poisoned");
let cursor = *cursors.get(route.prefix).unwrap_or(&0);
cursors.insert(route.prefix, (cursor + 1) % port_count);
let port = if uses_gateway_ports {
gateway_ports[cursor]
} else if cursor == 0 {
route.port
} else {
route.alternate_ports[cursor - 1]
};
(route.host, port)
}
fn gateway_proxy_ports() -> Result<Arc<[u16]>> {
let raw = env::var(DEV_PROXY_GATEWAY_PORTS_ENV).unwrap_or_else(|_| GATEWAY_PORT.to_string());
let mut ports = Vec::new();
for token in raw.split(',') {
let token = token.trim();
if token.is_empty() {
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} contains an empty port");
}
let port = token
.parse::<u16>()
.with_context(|| format!("Invalid port {token:?} in {DEV_PROXY_GATEWAY_PORTS_ENV}"))?;
if port == 0 {
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} ports must be greater than zero");
}
if ports.contains(&port) {
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} contains duplicate port {port}");
}
ports.push(port);
}
if ports.len() > 3 {
bail!("{DEV_PROXY_GATEWAY_PORTS_ENV} supports at most three ports");
}
Ok(ports.into())
}
pub fn rewrite_path(path: &str, route: &ProxyRoute) -> String {
let normalized_path = normalize_request_target(path);
if !route.strip_prefix {
return normalized_path;
}
let (parsed_path, query) = split_path_query(&normalized_path);
let next_path = parsed_path
.strip_prefix(route.prefix)
.unwrap_or(parsed_path);
let next_path = if next_path.is_empty() { "/" } else { next_path };
match query {
Some(query) => format!("{next_path}?{query}"),
None => next_path.to_owned(),
}
}
fn normalize_request_target(request_target: &str) -> String {
let Ok(url) = Url::parse(request_target) else {
return request_target.to_owned();
};
if url.scheme() != "http" && url.scheme() != "https" {
return request_target.to_owned();
}
let mut path = url.path().to_owned();
if path.is_empty() {
path.push('/');
}
if let Some(query) = url.query() {
path.push('?');
path.push_str(query);
}
path
}
fn split_path_query(path: &str) -> (&str, Option<&str>) {
path.split_once('?')
.map(|(path, query)| (path, Some(query)))
.unwrap_or((path, None))
}
pub fn rewrite_head(
request: &RequestHead,
route: &ProxyRoute,
target_host: Option<&str>,
target_port: Option<u16>,
client_ip: Option<&str>,
) -> Vec<u8> {
let host = target_host.unwrap_or(route.host);
let port = target_port.unwrap_or(route.port);
let is_upgrade = request
.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("upgrade"));
let mut lines = vec![format!(
"{} {} {}",
request.method,
rewrite_path(&request.path, route),
request.version
)];
let mut saw_forwarded_for = false;
let mut saw_host = false;
let mut saw_connection = false;
for (name, value) in &request.headers {
if name.eq_ignore_ascii_case("host") {
saw_host = true;
lines.push(format!("Host: {host}:{port}"));
} else if name.eq_ignore_ascii_case("connection") {
saw_connection = true;
lines.push(format!(
"Connection: {}",
if is_upgrade { "Upgrade" } else { "close" }
));
} else if name.eq_ignore_ascii_case("x-forwarded-for") {
saw_forwarded_for = true;
if let Some(client_ip) = client_ip {
lines.push(format!("{name}: {value}, {client_ip}"));
} else {
lines.push(format!("{name}: {value}"));
}
} else {
lines.push(format!("{name}: {value}"));
}
}
if !saw_host {
lines.push(format!("Host: {host}:{port}"));
}
if !saw_connection {
lines.push(format!(
"Connection: {}",
if is_upgrade { "Upgrade" } else { "close" }
));
}
if let Some(client_ip) = client_ip
&& !saw_forwarded_for
{
lines.push(format!("X-Forwarded-For: {client_ip}"));
}
lines.push(String::new());
lines.push(String::new());
lines.join("\r\n").into_bytes()
}
#[allow(dead_code)]
pub fn default_bind() -> (&'static str, u16) {
(ANY_HOST, DEV_PROXY_PORT)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn routes_and_rewrites_prefixes() {
let api = route_for_path("/api/users?x=1");
assert_eq!(api.prefix, "/api");
assert_eq!(rewrite_path("/api/users?x=1", api), "/users?x=1");
let asset = route_for_path("/assets/app.js");
assert_eq!(rewrite_path("/assets/app.js", asset), "/assets/app.js");
let lazy = route_for_path("/lazy-compilation-using-foo");
assert_eq!(lazy.prefix, "/lazy-compilation-using-");
let devmail = route_for_path("/devmail/messages");
assert_eq!(devmail.prefix, "/devmail");
assert_eq!(
rewrite_path("/devmail/messages?x=1", devmail),
"/devmail/messages?x=1"
);
let admin = route_for_path("/admin/users");
assert_eq!(admin.prefix, "/admin");
assert_eq!(rewrite_path("/admin/users", admin), "/users");
let absolute_gateway = route_for_path("https://dev.example.test/gateway?v=1&encoding=json");
assert_eq!(absolute_gateway.prefix, "/gateway");
assert_eq!(
rewrite_path(
"https://dev.example.test/gateway?v=1&encoding=json",
absolute_gateway
),
"/?v=1&encoding=json"
);
}
#[test]
fn rewrites_host_connection_and_forwarded_for() {
let request = RequestHead {
method: "GET".to_owned(),
path: "/api/_health".to_owned(),
version: "HTTP/1.1".to_owned(),
headers: vec![
("Host".to_owned(), "localhost:8088".to_owned()),
("Connection".to_owned(), "keep-alive".to_owned()),
("X-Forwarded-For".to_owned(), "1.1.1.1".to_owned()),
],
};
let head = String::from_utf8(rewrite_head(
&request,
route_for_path("/api/_health"),
Some("api"),
Some(8080),
Some("2.2.2.2"),
))
.unwrap();
assert!(head.contains("GET /_health HTTP/1.1\r\n"));
assert!(head.contains("Host: api:8080\r\n"));
assert!(head.contains("Connection: close\r\n"));
assert!(head.contains("X-Forwarded-For: 1.1.1.1, 2.2.2.2\r\n"));
}
#[test]
fn redirects_local_html_navigation_to_public_tunnel_url() {
let request = RequestHead {
method: "GET".to_owned(),
path: "/channels/@me?x=1".to_owned(),
version: "HTTP/1.1".to_owned(),
headers: vec![
("Host".to_owned(), "localhost:8088".to_owned()),
(
"Accept".to_owned(),
"text/html,application/xhtml+xml".to_owned(),
),
],
};
assert_eq!(
redirect_location_for_public_url(&request, "https://dev.example.test"),
Some("https://dev.example.test/channels/@me?x=1".to_owned())
);
}
#[test]
fn public_tunnel_redirect_ignores_assets_and_local_public_url() {
let asset = RequestHead {
method: "GET".to_owned(),
path: "/avatars/4.png".to_owned(),
version: "HTTP/1.1".to_owned(),
headers: vec![
("Host".to_owned(), "localhost:8088".to_owned()),
(
"Accept".to_owned(),
"image/avif,image/webp,image/apng,image/*,*/*;q=0.8".to_owned(),
),
],
};
assert_eq!(
redirect_location_for_public_url(&asset, "https://dev.example.test"),
None
);
let local_public_url = RequestHead {
method: "GET".to_owned(),
path: "/channels/@me".to_owned(),
version: "HTTP/1.1".to_owned(),
headers: vec![
("Host".to_owned(), "localhost:8088".to_owned()),
("Accept".to_owned(), "text/html".to_owned()),
],
};
assert_eq!(
redirect_location_for_public_url(&local_public_url, LOCAL_APP_URL),
None
);
}
}