use crate::daemon::actor::{PmHandle, ProxyState};
use crate::error::ProxyError;
use crate::protocol::Response;
use http_body_util::{BodyExt, Full};
use hyper::body::{Bytes, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response as HyperResponse, StatusCode};
use hyper_util::client::legacy::Client;
use hyper_util::rt::TokioExecutor;
use hyper_util::rt::TokioIo;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::watch;
type BoxBody = http_body_util::combinators::BoxBody<Bytes, hyper::Error>;
pub fn bind_proxy_port(explicit: Option<u16>) -> Result<(std::net::TcpListener, u16), ProxyError> {
const PROXY_PORT_MIN: u16 = 9090;
const PROXY_PORT_MAX: u16 = 9190;
if let Some(port) = explicit {
match std::net::TcpListener::bind(("127.0.0.1", port)) {
Ok(listener) => return Ok((listener, port)),
Err(source) => return Err(ProxyError::PortUnavailable { port, source }),
}
}
for port in PROXY_PORT_MIN..=PROXY_PORT_MAX {
if let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) {
return Ok((listener, port));
}
}
Err(ProxyError::NoFreePort {
min: PROXY_PORT_MIN,
max: PROXY_PORT_MAX,
})
}
pub fn extract_subdomain(host: &str) -> Option<String> {
let hostname = host.split(':').next().unwrap_or(host);
let parts: Vec<&str> = hostname.split('.').collect();
if parts.len() < 2 {
return None;
}
let second_to_last = parts[parts.len() - 2];
if parts.last() == Some(&"localhost") && parts.len() >= 2 {
Some(second_to_last.to_string())
} else {
None
}
}
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Incoming>;
pub async fn start_proxy(
std_listener: std::net::TcpListener,
proxy_port: u16,
handle: PmHandle,
proxy_state_rx: watch::Receiver<ProxyState>,
shutdown: Arc<tokio::sync::Notify>,
) -> std::io::Result<()> {
std_listener.set_nonblocking(true)?;
let listener = TcpListener::from_std(std_listener)?;
let client: HttpClient = Client::builder(TokioExecutor::new()).build_http();
loop {
let (stream, _remote_addr) = tokio::select! {
result = listener.accept() => match result {
Ok(conn) => conn,
Err(e) => {
tracing::warn!(error = %e, "proxy accept error");
continue;
}
},
() = shutdown.notified() => {
return Ok(());
}
};
let handle = handle.clone();
let proxy_state_rx = proxy_state_rx.clone();
let client = client.clone();
let pp = proxy_port;
tokio::spawn(async move {
let io = TokioIo::new(stream);
let client = client.clone();
let svc = service_fn(move |req: Request<Incoming>| {
let handle = handle.clone();
let proxy_state_rx = proxy_state_rx.clone();
let client = client.clone();
async move { handle_proxy_request(req, &handle, &proxy_state_rx, client, pp).await }
});
if let Err(e) = http1::Builder::new()
.serve_connection(io, svc)
.with_upgrades()
.await
{
if !e.is_incomplete_message() {
tracing::warn!(error = %e, "proxy connection error");
}
}
});
}
}
async fn handle_proxy_request(
req: Request<Incoming>,
handle: &PmHandle,
proxy_state_rx: &watch::Receiver<ProxyState>,
client: HttpClient,
proxy_port: u16,
) -> Result<HyperResponse<BoxBody>, hyper::Error> {
let host = req
.headers()
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let subdomain = extract_subdomain(host);
let process_name = match subdomain {
Some(name) => name,
None => {
return Ok(status_page(handle, proxy_port).await);
}
};
let backend_port = {
let state = proxy_state_rx.borrow();
state.port_map.get(&process_name).copied()
};
let backend_port = match backend_port {
Some(port) => port,
None => {
let msg = format!(
"502 Bad Gateway: no running process named '{}' with a port. Visit http://localhost:{} to see available routes.",
process_name, proxy_port
);
return Ok(HyperResponse::builder()
.status(StatusCode::BAD_GATEWAY)
.body(text_body(msg))
.unwrap());
}
};
let method = req.method().clone();
let uri = req.uri().clone();
let path_and_query = uri
.path_and_query()
.map_or("/", hyper::http::uri::PathAndQuery::as_str);
let new_uri = format!("http://127.0.0.1:{}{}", backend_port, path_and_query);
let mut builder = Request::builder().method(method).uri(&new_uri);
for (key, value) in req.headers() {
if key == hyper::header::HOST {
builder = builder.header(hyper::header::HOST, format!("127.0.0.1:{}", backend_port));
} else {
builder = builder.header(key, value);
}
}
let forwarded_req = builder.body(req.into_body()).unwrap();
match client.request(forwarded_req).await {
Ok(resp) => {
let (parts, body) = resp.into_parts();
let boxed_body = body.boxed();
Ok(HyperResponse::from_parts(parts, boxed_body))
}
Err(e) => {
let msg = format!(
"502 Bad Gateway: failed to connect to backend '{}' on port {}: {}",
process_name, backend_port, e
);
Ok(HyperResponse::builder()
.status(StatusCode::BAD_GATEWAY)
.body(text_body(msg))
.unwrap())
}
}
}
fn text_body(s: String) -> BoxBody {
Full::new(Bytes::from(s))
.map_err(|never| match never {})
.boxed()
}
async fn status_page(handle: &PmHandle, proxy_port: u16) -> HyperResponse<BoxBody> {
let resp = handle.status_snapshot().await;
let mut body = format!("agent-procs proxy on port {}\n\n", proxy_port);
if let Response::Status { processes } = resp {
if processes.is_empty() {
body.push_str("No processes running.\n");
} else {
body.push_str("Routes:\n");
for p in &processes {
let state_str = p.state.to_string();
use std::fmt::Write;
if let Some(port) = p.port {
let _ = writeln!(
body,
" http://{}.localhost:{} -> 127.0.0.1:{} [{}]",
p.name, proxy_port, port, state_str
);
} else {
let _ = writeln!(body, " {} (no port) [{}]", p.name, state_str);
}
}
}
}
HyperResponse::builder()
.status(StatusCode::OK)
.header("content-type", "text/plain")
.body(text_body(body))
.unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_subdomain_simple() {
assert_eq!(extract_subdomain("api.localhost:9090"), Some("api".into()));
}
#[test]
fn test_extract_subdomain_nested() {
assert_eq!(
extract_subdomain("tenant.api.localhost:9090"),
Some("api".into())
);
}
#[test]
fn test_extract_subdomain_bare_localhost() {
assert_eq!(extract_subdomain("localhost:9090"), None);
}
#[test]
fn test_extract_subdomain_no_port() {
assert_eq!(extract_subdomain("api.localhost"), Some("api".into()));
}
}