feat(web_ui): per-path reverse-proxy via HIVE_EXTRA_WEB_PROXIES

This commit is contained in:
damocles 2026-07-07 17:22:07 +02:00 committed by mara
commit 89ce8790ff
4 changed files with 193 additions and 2 deletions

View file

@ -0,0 +1,150 @@
//! Extra web proxies (`HIVE_EXTRA_WEB_PROXIES` / `hyperhive.extraWebProxies`).
//!
//! Each declared entry mounts a transparent reverse-proxy at `/extra/<name>/`
//! in the per-agent web UI, forwarding every request (method, headers, body)
//! to the configured upstream. The `/extra/` namespace keeps user-declared
//! proxies from ever colliding with the native agent endpoints (`/api/*`,
//! `/events/*`, …). Response bodies are buffered whole (MVP): an SSE upstream
//! appears as one large response rather than streaming.
use std::collections::HashMap;
use std::sync::Arc;
use axum::{
Router,
body::Bytes,
extract::State,
http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri},
response::{IntoResponse, Response},
};
use super::AppState;
/// Hop-by-hop headers stripped on both the request and response side — they
/// describe a single transport hop and must not be forwarded to the upstream
/// or back to the client.
const HOP_BY_HOP: [&str; 6] = [
"connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
];
/// Nest every proxy declared in `HIVE_EXTRA_WEB_PROXIES` (a JSON object
/// `{"<name>": "<upstream_url>"}`) under `/extra/<name>/` on `app`. Absent /
/// blank / invalid JSON is a no-op (logged). Called from [`super::serve`]
/// before the static-dir fallback so `/extra/*` wins over `ServeDir`.
pub(super) fn mount_extra_proxies(mut app: Router<AppState>) -> Router<AppState> {
let Ok(json) = std::env::var("HIVE_EXTRA_WEB_PROXIES") else {
return app;
};
let Ok(map) = serde_json::from_str::<HashMap<String, String>>(&json) else {
tracing::warn!("HIVE_EXTRA_WEB_PROXIES: invalid JSON — ignoring");
return app;
};
for (name, upstream) in &map {
if let Some(svc) = extra_proxy_service(upstream) {
let mount = format!("/extra/{}", name.trim_matches('/'));
tracing::info!(mount, upstream, "mounting extra web proxy");
app = app.nest_service(mount.as_str(), svc);
}
}
app
}
/// Build a transparent reverse-proxy service forwarding every request to
/// `upstream` (e.g. `http://127.0.0.1:3737`). The caller nests it at a path
/// prefix; axum strips the prefix before the service sees the request.
/// Returns `None` when `upstream` is blank or the reqwest client won't build.
fn extra_proxy_service(upstream: &str) -> Option<Router<()>> {
let upstream = upstream.trim();
if upstream.is_empty() {
return None;
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => Arc::new(c),
Err(e) => {
tracing::warn!("extra proxy: failed to build reqwest client: {e}");
return None;
}
};
let base = Arc::new(upstream.trim_end_matches('/').to_owned());
Some(
Router::new()
.fallback(proxy_handler)
.with_state((client, base)),
)
}
/// Forward the request to `{base}{stripped_path_and_query}`, strip hop-by-hop
/// headers both ways, buffer the full response body, and return it verbatim.
async fn proxy_handler(
State((client, base)): State<(Arc<reqwest::Client>, Arc<String>)>,
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
) -> Response {
let path_and_query = uri
.path_and_query()
.map_or("/", axum::http::uri::PathAndQuery::as_str);
let url = format!("{base}{path_and_query}");
let mut req_headers = headers;
for h in HOP_BY_HOP {
req_headers.remove(h);
}
let upstream_resp = match client
.request(
reqwest::Method::from_bytes(method.as_str().as_bytes()).unwrap_or(reqwest::Method::GET),
&url,
)
.headers(
req_headers
.iter()
.filter_map(|(k, v)| {
let n = reqwest::header::HeaderName::from_bytes(k.as_str().as_bytes()).ok()?;
let v = reqwest::header::HeaderValue::from_bytes(v.as_bytes()).ok()?;
Some((n, v))
})
.collect(),
)
.body(body.to_vec())
.send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(url, "proxy: upstream request failed: {e}");
return StatusCode::BAD_GATEWAY.into_response();
}
};
let status =
StatusCode::from_u16(upstream_resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let mut resp_headers = HeaderMap::new();
for (name, value) in upstream_resp.headers() {
if HOP_BY_HOP.contains(&name.as_str()) {
continue;
}
if let (Ok(n), Ok(v)) = (
HeaderName::from_bytes(name.as_str().as_bytes()),
HeaderValue::from_bytes(value.as_bytes()),
) {
resp_headers.insert(n, v);
}
}
match upstream_resp.bytes().await {
Ok(b) => (status, resp_headers, b).into_response(),
Err(e) => {
tracing::warn!(url, "proxy: failed to read upstream response: {e}");
StatusCode::BAD_GATEWAY.into_response()
}
}
}