feat(web_ui): per-path reverse-proxy via HIVE_EXTRA_WEB_PROXIES
This commit is contained in:
parent
6da7835ad9
commit
89ce8790ff
4 changed files with 193 additions and 2 deletions
|
|
@ -402,6 +402,16 @@ shaped).
|
||||||
Transparent to any RFB variant. VNC port comes from the
|
Transparent to any RFB variant. VNC port comes from the
|
||||||
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
|
`HIVE_GUI_VNC_PORT` env var (a fixed port set on the harness
|
||||||
service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
|
service when `hyperhive.gui.enable`; see `weston-vnc.nix`).
|
||||||
|
- `GET|POST /extra/<name>/…` — **extra web proxies** declared via
|
||||||
|
`hyperhive.extraWebProxies` in `agent.nix` (serialised to the
|
||||||
|
`HIVE_EXTRA_WEB_PROXIES` env var as a JSON object
|
||||||
|
`{"<name>": "<upstream_url>"}`). Each entry mounts a transparent
|
||||||
|
reverse-proxy at `/extra/<name>/` 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`):
|
Bus events (new vocabulary on `/events/stream`):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
mod actions;
|
mod actions;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod proxy;
|
||||||
mod screen;
|
mod screen;
|
||||||
mod state;
|
mod state;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
|
@ -99,7 +100,7 @@ pub async fn serve(
|
||||||
socket,
|
socket,
|
||||||
gui_vnc_port,
|
gui_vnc_port,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app: Router<AppState> = Router::new()
|
||||||
.route("/api/state", get(state::api_state))
|
.route("/api/state", get(state::api_state))
|
||||||
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
.route("/api/dashboard-state", get(state::api_dashboard_state))
|
||||||
.route("/events/stream", get(stream::events_stream))
|
.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/bash-tasks", get(stats::api_bash_tasks))
|
||||||
.route("/api/stats", get(stats::api_stats))
|
.route("/api/stats", get(stats::api_stats))
|
||||||
.route("/screen/ws", get(screen::screen_ws))
|
.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/<name>/` before the
|
||||||
|
// static fallback so declared proxies win over `ServeDir`.
|
||||||
|
let app = proxy::mount_extra_proxies(app)
|
||||||
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
// Anything else (`/`, `/stats`, `/screen`, `/static/*`)
|
||||||
// falls through to the merged dist. ServeDir auto-appends
|
// falls through to the merged dist. ServeDir auto-appends
|
||||||
// `.html` when the URL is a bare path that matches a file
|
// `.html` when the URL is a bare path that matches a file
|
||||||
|
|
|
||||||
150
hive-ag3nt/src/web_ui/proxy.rs
Normal file
150
hive-ag3nt/src/web_ui/proxy.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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/<name>/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
|
# Internal accumulator for shell snippets that should land in
|
||||||
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
|
# `/etc/hyperhive/bash-env.sh`. Per-feature hooks set this via
|
||||||
# `lib.mkIf` gated on their own option; the lines type merges
|
# `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
|
# so the same value for every gui agent is fine. See
|
||||||
# nix/templates/weston-vnc.nix::hyperhive.gui.vncPort.
|
# nix/templates/weston-vnc.nix::hyperhive.gui.vncPort.
|
||||||
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
HIVE_GUI_VNC_PORT = toString config.hyperhive.gui.vncPort;
|
||||||
|
}
|
||||||
|
// lib.optionalAttrs (config.hyperhive.extraWebProxies != { }) {
|
||||||
|
# JSON object {"<path>": "<upstream>"} 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 = {
|
serviceConfig = {
|
||||||
ExecStart = "${pkgs.hyperhive}/bin/${binary}";
|
ExecStart = "${pkgs.hyperhive}/bin/${binary}";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue