diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index 817727a0..fa35f39a 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -402,6 +402,16 @@ shaped). Transparent to any RFB variant. VNC port comes from the `HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness service when `hyperhive.gui.enable`; see `weston-vnc.nix`). +- `GET|POST /extra//…` — **extra web proxies** declared via + `hyperhive.extraWebProxies` in `agent.nix` (serialised to the + `HIVE_EXTRA_WEB_PROXIES` env var as a JSON object + `{"": ""}`). Each entry mounts a transparent + reverse-proxy at `/extra//` that forwards every request (method, + headers, body) to the configured upstream, strips hop-by-hop headers + on both sides, and buffers the full response body (MVP — SSE + connections will appear as one large response rather than streaming). + The `/extra/` namespace ensures user-declared proxies can never + conflict with native agent endpoints. Implemented in `web_ui/proxy.rs`. Bus events (new vocabulary on `/events/stream`): diff --git a/hive-ag3nt/src/web_ui/mod.rs b/hive-ag3nt/src/web_ui/mod.rs index ec99fccb..7eb137d1 100644 --- a/hive-ag3nt/src/web_ui/mod.rs +++ b/hive-ag3nt/src/web_ui/mod.rs @@ -11,6 +11,7 @@ mod actions; mod auth; +mod proxy; mod screen; mod state; mod stats; @@ -99,7 +100,7 @@ pub async fn serve( socket, gui_vnc_port, }; - let app = Router::new() + let app: Router = Router::new() .route("/api/state", get(state::api_state)) .route("/api/dashboard-state", get(state::api_dashboard_state)) .route("/events/stream", get(stream::events_stream)) @@ -118,7 +119,10 @@ pub async fn serve( .route("/api/bash-tasks", get(stats::api_bash_tasks)) .route("/api/stats", get(stats::api_stats)) .route("/screen/ws", get(screen::screen_ws)) - .route("/icon", get(screen::serve_icon)) + .route("/icon", get(screen::serve_icon)); + // Mount any `hyperhive.extraWebProxies` under `/extra//` before the + // static fallback so declared proxies win over `ServeDir`. + let app = proxy::mount_extra_proxies(app) // Anything else (`/`, `/stats`, `/screen`, `/static/*`) // falls through to the merged dist. ServeDir auto-appends // `.html` when the URL is a bare path that matches a file diff --git a/hive-ag3nt/src/web_ui/proxy.rs b/hive-ag3nt/src/web_ui/proxy.rs new file mode 100644 index 00000000..79c752de --- /dev/null +++ b/hive-ag3nt/src/web_ui/proxy.rs @@ -0,0 +1,150 @@ +//! Extra web proxies (`HIVE_EXTRA_WEB_PROXIES` / `hyperhive.extraWebProxies`). +//! +//! Each declared entry mounts a transparent reverse-proxy at `/extra//` +//! 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 +/// `{"": ""}`) under `/extra//` 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) -> Router { + let Ok(json) = std::env::var("HIVE_EXTRA_WEB_PROXIES") else { + return app; + }; + let Ok(map) = serde_json::from_str::>(&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> { + 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, Arc)>, + 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() + } + } +} diff --git a/nix/templates/harness-base.nix b/nix/templates/harness-base.nix index 437d7440..6b238238 100644 --- a/nix/templates/harness-base.nix +++ b/nix/templates/harness-base.nix @@ -813,6 +813,27 @@ in ''; }; + options.hyperhive.extraWebProxies = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = { }; + example = lib.literalExpression ''{ "stats" = "http://127.0.0.1:3737"; }''; + description = '' + Transparent reverse-proxies mounted under `/extra/` in the per-agent web UI. + Each attribute name becomes the sub-path and the value is the upstream URL. + E.g. `{ "stats" = "http://127.0.0.1:3737"; }` mounts a proxy at + `/agent//extra/stats/` that forwards to port 3737 with the prefix + stripped. All user-declared proxies live under `/extra/` so they can + never conflict with native agent endpoints (`/api/*`, `/events/*`, etc.). + + Intended for agents that run secondary web servers in the same container. + Static assets served by the secondary app must use relative paths to + resolve correctly under the sub-path prefix. + + Sets the `HIVE_EXTRA_WEB_PROXIES` environment variable (JSON object) + on the harness service unit. + ''; + }; + # Internal accumulator for shell snippets that should land in # `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via # `lib.mkIf` gated on their own option; the lines type merges @@ -1994,6 +2015,12 @@ in # so the same value for every gui agent is fine. See # nix/templates/weston-vnc.nix::hyperhive.gui.vncPort. HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort; + } + // lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) { + # JSON object {"": ""} for the transparent + # reverse-proxies. See `hyperhive.extraWebProxies` option + # and `web_ui/proxy.rs::extra_proxy_service`. + HIVE_EXTRA_WEB_PROXIES = builtins.toJSON config.hyperhive.extraWebProxies; }; serviceConfig = { ExecStart = "${pkgs.hyperhive}/bin/${binary}";