use axum::{
body::{Body, Bytes},
extract::{Path, Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use reqwest::Method;
use tracing::{debug, trace, warn};
use crate::server::AppState;
static HOP_BY_HOP: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
];
fn full_id(service_key: &str) -> Option<&'static str> {
match service_key {
"search" => Some("trusty-search"),
"memory" => Some("trusty-memory"),
"analyze" => Some("trusty-analyze"),
"review" => Some("trusty-review"),
"mpm" => Some("trusty-mpm"),
_ => None,
}
}
fn is_local_upstream(url: &str) -> bool {
url.starts_with("http://127.")
|| url.starts_with("http://[::1]")
|| url.starts_with("http://localhost")
}
pub fn normalize_base_url(url: &str) -> String {
if url.contains("https://") {
warn!(
"proxy: upstream base URL contains https:// — stripping to http:// \
(loopback upstream connections are HTTP only). \
Check the service's http_addr discovery file."
);
}
format!("http://{}", crate::url_util::strip_schemes(url))
}
pub fn build_upstream_url(base_url: &str, subpath: &str, query: Option<&str>) -> String {
let base = base_url.trim_end_matches('/');
let path = subpath.trim_start_matches('/');
let url = if path.is_empty() {
format!("{base}/")
} else {
format!("{base}/{path}")
};
match query {
Some(q) if !q.is_empty() => format!("{url}?{q}"),
_ => url,
}
}
fn filter_headers(headers: &HeaderMap) -> HeaderMap {
let mut out = HeaderMap::new();
for (name, value) in headers {
if !HOP_BY_HOP.contains(&name.as_str()) {
out.append(name.clone(), value.clone());
}
}
out
}
fn error_response(status: StatusCode, body: &'static str) -> Response {
Response::builder()
.status(status)
.header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
.body(Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
const CONNECT_RETRY_DELAYS: &[std::time::Duration] = &[
std::time::Duration::from_millis(300),
std::time::Duration::from_millis(700),
];
async fn send_with_connect_retry(
client: &reqwest::Client,
method: Method,
url: &str,
headers: HeaderMap,
body: Bytes,
retry_delays: &[std::time::Duration],
) -> Result<reqwest::Response, reqwest::Error> {
let mut attempt = 0usize;
loop {
match client
.request(method.clone(), url)
.headers(headers.clone())
.body(body.clone())
.send()
.await
{
Ok(resp) => return Ok(resp),
Err(e) if e.is_connect() && attempt < retry_delays.len() => {
let delay = retry_delays[attempt];
debug!(
"proxy: upstream connect failed (attempt {}), retrying after {delay:?}: {e}",
attempt + 1
);
tokio::time::sleep(delay).await;
attempt += 1;
}
Err(e) => return Err(e),
}
}
}
pub async fn proxy_handler(
State(state): State<AppState>,
Path((service_key, subpath)): Path<(String, String)>,
req: Request,
) -> Response {
if service_key.as_str() == "console" {
warn!(
"proxy: service_key 'console' is reserved and cannot be proxied (routing invariant violated)"
);
return error_response(StatusCode::BAD_REQUEST, "reserved service key");
}
let Some(full_service_id) = full_id(&service_key) else {
warn!("proxy: unknown service key '{service_key}'");
return error_response(StatusCode::BAD_REQUEST, "unknown daemon");
};
let base_url = {
let snap = state.poller_cache().snapshot().await;
match snap {
None => {
warn!("proxy: cache not yet populated for '{service_key}'");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "cache not ready");
}
Some(s) => {
let map = s.url_map();
match map.get(full_service_id).cloned() {
Some(url) => url,
None => {
warn!("proxy: service '{service_key}' is not running");
return error_response(StatusCode::BAD_GATEWAY, "daemon not running");
}
}
}
}
};
let base_url = normalize_base_url(&base_url);
if !is_local_upstream(&base_url) {
warn!("proxy: upstream '{base_url}' is not a local address — rejecting (SSRF guard)");
return error_response(StatusCode::BAD_GATEWAY, "upstream not local");
}
let (parts, body) = req.into_parts();
let query = parts.uri.query();
let upstream_url = build_upstream_url(&base_url, &subpath, query);
debug!("proxy: {service_key} → {upstream_url}");
let method = match Method::from_bytes(parts.method.as_str().as_bytes()) {
Ok(m) => m,
Err(_) => {
return error_response(StatusCode::BAD_REQUEST, "unsupported method");
}
};
let safe_headers = filter_headers(&parts.headers);
const BODY_LIMIT: usize = 64 * 1024 * 1024;
let body_bytes: Bytes = match axum::body::to_bytes(body, BODY_LIMIT).await {
Ok(b) => b,
Err(e) => {
warn!("proxy: failed to read request body: {e}");
return error_response(
StatusCode::PAYLOAD_TOO_LARGE,
"request body exceeds proxy limit of 64 MiB",
);
}
};
let client = state.http_client();
let upstream_resp = match send_with_connect_retry(
&client,
method,
&upstream_url,
safe_headers,
body_bytes,
CONNECT_RETRY_DELAYS,
)
.await
{
Ok(r) => r,
Err(e) => {
warn!("proxy: upstream request failed for '{service_key}': {e}");
return error_response(StatusCode::BAD_GATEWAY, "upstream request failed");
}
};
let status = StatusCode::from_u16(upstream_resp.status().as_u16())
.unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let mut resp_builder = Response::builder().status(status);
for (name, value) in upstream_resp.headers() {
if !HOP_BY_HOP.contains(&name.as_str())
&& let Ok(n) = HeaderName::from_bytes(name.as_str().as_bytes())
&& let Ok(v) = HeaderValue::from_bytes(value.as_bytes())
{
resp_builder = resp_builder.header(n, v);
}
}
let resp_body = match upstream_resp.bytes().await {
Ok(b) => Body::from(b),
Err(e) => {
warn!("proxy: failed to read upstream body: {e}");
Body::from("upstream body error")
}
};
resp_builder
.body(resp_body)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
pub async fn deprecated_proxy_handler(
State(state): State<AppState>,
Path((service_key, subpath)): Path<(String, String)>,
req: Request,
) -> Response {
trace!("proxy: DEPRECATED /proxy/{service_key}/… — use /api/{service_key}/… instead (#1849)");
proxy_handler(State(state), Path((service_key, subpath)), req).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_build_upstream_url_simple_path() {
assert_eq!(
build_upstream_url("http://127.0.0.1:7878", "health", None),
"http://127.0.0.1:7878/health"
);
}
#[test]
fn test_build_upstream_url_with_query() {
assert_eq!(
build_upstream_url(
"http://127.0.0.1:7879",
"indexes/abc/complexity_hotspots",
Some("top_k=5")
),
"http://127.0.0.1:7879/indexes/abc/complexity_hotspots?top_k=5"
);
}
#[test]
fn test_build_upstream_url_empty_path() {
assert_eq!(
build_upstream_url("http://127.0.0.1:7070", "", None),
"http://127.0.0.1:7070/"
);
}
#[test]
fn test_build_upstream_url_base_trailing_slash() {
assert_eq!(
build_upstream_url("http://127.0.0.1:7878/", "health", None),
"http://127.0.0.1:7878/health"
);
}
#[test]
fn test_build_upstream_url_empty_query_omitted() {
assert_eq!(
build_upstream_url("http://127.0.0.1:7878", "health", Some("")),
"http://127.0.0.1:7878/health"
);
}
#[test]
fn test_normalize_base_url_idempotent_on_correct_url() {
assert_eq!(
normalize_base_url("http://127.0.0.1:7878"),
"http://127.0.0.1:7878"
);
}
#[test]
fn test_normalize_base_url_collapses_double_http_scheme() {
assert_eq!(
normalize_base_url("http://http://127.0.0.1:7878"),
"http://127.0.0.1:7878"
);
}
#[test]
fn test_normalize_base_url_replaces_https_with_http() {
assert_eq!(
normalize_base_url("https://127.0.0.1:7878"),
"http://127.0.0.1:7878"
);
}
#[test]
fn test_is_local_upstream_accepted() {
assert!(is_local_upstream("http://127.0.0.1:7878"));
assert!(is_local_upstream("http://127.0.0.1:7878/health"));
assert!(is_local_upstream("http://127.1.2.3:9000"));
assert!(is_local_upstream("http://[::1]:8080"));
assert!(is_local_upstream("http://localhost:7070"));
assert!(is_local_upstream("http://localhost"));
}
#[test]
fn test_is_local_upstream_rejected() {
assert!(!is_local_upstream("http://192.168.1.1:7878"));
assert!(!is_local_upstream("http://10.0.0.1:7879"));
assert!(!is_local_upstream("http://evil.example.com/steal"));
assert!(!is_local_upstream("https://127.0.0.1:7878")); assert!(!is_local_upstream("http://0.0.0.0:7878"));
}
#[test]
fn test_service_key_mapping() {
assert_eq!(full_id("search"), Some("trusty-search"));
assert_eq!(full_id("memory"), Some("trusty-memory"));
assert_eq!(full_id("analyze"), Some("trusty-analyze"));
assert_eq!(full_id("review"), Some("trusty-review"));
assert_eq!(full_id("mpm"), Some("trusty-mpm"));
assert_eq!(full_id("unknown"), None);
}
#[test]
fn test_is_local_upstream_rejects_double_scheme() {
assert!(
!is_local_upstream("http://http://127.0.0.1:7878"),
"double-scheme URL must not pass the loopback guard"
);
}
#[test]
fn test_console_key_not_in_allowlist() {
assert_eq!(
full_id("console"),
None,
"console must never appear in the proxy allowlist"
);
}
#[test]
fn test_filter_headers_strips_hop_by_hop() {
let mut h = HeaderMap::new();
h.insert("connection", HeaderValue::from_static("keep-alive"));
h.insert("x-custom", HeaderValue::from_static("hello"));
let filtered = filter_headers(&h);
assert!(!filtered.contains_key("connection"));
assert!(filtered.contains_key("x-custom"));
}
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
async fn reserve_free_port() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.expect("bind");
let port = l.local_addr().expect("local_addr").port();
drop(l);
port
}
async fn serve_one_ok(port: u16) {
let l = TcpListener::bind(("127.0.0.1", port))
.await
.expect("rebind test server");
let (mut sock, _) = l.accept().await.expect("accept");
let mut buf = [0u8; 4096];
let _ = sock.read(&mut buf).await;
let resp = b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok";
let _ = sock.write_all(resp).await;
let _ = sock.shutdown().await;
}
#[tokio::test]
async fn test_connect_retry_recovers() {
let port = reserve_free_port().await;
let url = format!("http://127.0.0.1:{port}/api/v1/sessions/managed");
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(120)).await;
serve_one_ok(port).await;
});
let client = reqwest::Client::builder()
.pool_max_idle_per_host(0)
.build()
.expect("client");
let resp = send_with_connect_retry(
&client,
Method::POST,
&url,
HeaderMap::new(),
Bytes::from_static(b"{}"),
&[Duration::from_millis(300), Duration::from_millis(700)],
)
.await
.expect("retry should recover once upstream is back");
assert_eq!(resp.status().as_u16(), 200);
}
#[tokio::test]
async fn test_connect_retry_recovers_on_second_attempt() {
let port = reserve_free_port().await;
let url = format!("http://127.0.0.1:{port}/api/v1/sessions/managed");
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(400)).await;
serve_one_ok(port).await;
});
let client = reqwest::Client::builder()
.pool_max_idle_per_host(0)
.build()
.expect("client");
let resp = send_with_connect_retry(
&client,
Method::POST,
&url,
HeaderMap::new(),
Bytes::from_static(b"{}"),
&[Duration::from_millis(100), Duration::from_millis(300)],
)
.await
.expect("second scheduled retry should recover once upstream is back");
assert_eq!(resp.status().as_u16(), 200);
}
#[tokio::test]
async fn test_connect_retry_gives_up() {
let port = reserve_free_port().await;
let url = format!("http://127.0.0.1:{port}/health");
let client = reqwest::Client::builder()
.pool_max_idle_per_host(0)
.build()
.expect("client");
let err = send_with_connect_retry(
&client,
Method::POST,
&url,
HeaderMap::new(),
Bytes::from_static(b"{}"),
&[Duration::from_millis(20), Duration::from_millis(20)],
)
.await
.expect_err("no upstream should yield an error");
assert!(err.is_connect(), "expected a connect error, got: {err}");
}
}