From 8ecf05b106c8c4530ea8928de3865eef722e5e55 Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 1 May 2026 19:29:46 +0200 Subject: [PATCH] feat: HTTP-over-rendezvous fallback (HttpProxyRequest) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the M4 plan. When `OPTION_USE_RAW_TCP_FOR_API=Y` (typical in locked-down networks where direct HTTPS to port 21114 is blocked), the client wraps every /api/* request in an HttpProxyRequest protobuf and ships it over the already-encrypted rendezvous TCP channel. We now decode those messages on hbbs and dispatch them through the *same* axum Router the HTTPS listener uses — so every existing handler (login, AB, audit, TOTP, OIDC, devices/cli, plugin-sign, …) is reachable through this path with zero per-route plumbing. Components ========== - libs/hbb_common (submodule, pro-features-httpproxy branch): backports HeaderEntry / HttpProxyRequest / HttpProxyResponse + union tags 27/28 from upstream @87b11a7 onto our pinned @83419b6. Proto-only — the rest of hbb_common is unchanged so we keep the tokio 1.x / axum 0.5 / pinned reqwest fork intact (a full submodule bump risked breaking those). - src/api/http_proxy.rs: the dispatch shim. Holds a `Mutex>` populated by `api::serve` before the HTTPS listener starts, builds an `http::Request` from the proto fields (sanitizing hop-by-hop headers, defaulting Content-Type: application/json), runs it through `router.oneshot(req)`, and serializes the response into HttpProxyResponse. Tower added as a direct dep with the `util` feature for ServiceExt. - src/api/mod.rs: pub mod http_proxy; install_router(app.clone()) before axum::Server::bind to share the router. - src/rendezvous_server.rs::handle_tcp: new match arm right before the catch-all that decodes HttpProxyRequest and replies with an HttpProxyResponse via the existing Sink::TcpStream(..., Encrypt) path. The reply is automatically secretbox-sealed by `send_to_sink`, so the end-to-end channel is encrypted symmetrically with secure_tcp. - examples/http_proxy_test.rs: end-to-end smoke test that opens a TCP connection, walks the secure_tcp handshake by hand (read server's signed box pubkey, derive symmetric key, send sealed reply), then ships an HttpProxyRequest GET /api/login-options and verifies the response is 200 + ["account"]. Used as the validation gate. New crate deps ============== - tower = "0.4" (features = ["util"]) — for ServiceExt::oneshot - http-body = "0.4" — for the Body trait import in dispatch Verification ============ 1. cargo build --release — clean. 2. examples/http_proxy_test against a fresh hbbs: [ok] secure_tcp handshake complete [ok] sent HttpProxyRequest GET /api/login-options [ok] response status = 200 [ok] response body = ["account"] [pass] full HTTP-over-rendezvous round trip verified 3. hbbs log confirms the secure_tcp handshake completed and the dispatch went through the standard axum router. Notes on cherry-pick vs submodule bump ====================================== The plan flagged the bump as the riskiest M4 item because newer hbb_common pulls newer tokio that breaks axum 0.5. The proto-only cherry pick keeps everything stable; the upstream-divergence cost is one extra commit in the hbb_common submodule that we own. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 2 + Cargo.toml | 2 + examples/http_proxy_test.rs | 126 ++++++++++++++++++++++++++++ libs/hbb_common | 2 +- src/api/http_proxy.rs | 162 ++++++++++++++++++++++++++++++++++++ src/api/mod.rs | 7 +- src/rendezvous_server.rs | 10 +++ 7 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 examples/http_proxy_test.rs create mode 100644 src/api/http_proxy.rs diff --git a/Cargo.lock b/Cargo.lock index 45ce31e..308e4af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,6 +1113,7 @@ dependencies = [ "hbb_common", "headers", "http", + "http-body", "ipnetwork", "jsonwebtoken", "lazy_static", @@ -1135,6 +1136,7 @@ dependencies = [ "tokio-tungstenite", "toml 0.7.8", "totp-rs", + "tower", "tower-http", "tungstenite", "uuid", diff --git a/Cargo.toml b/Cargo.toml index f5972f4..f4e9342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,7 +49,9 @@ tokio-tungstenite = "0.17" tungstenite = "0.17" regex = "1.4" tower-http = { version = "0.3", features = ["fs", "trace", "cors"] } +tower = { version = "0.4", features = ["util"] } http = "0.2" +http-body = "0.4" flexi_logger = { version = "0.22", features = ["async", "use_chrono_for_offset", "dont_minimize_extra_stacks"] } ipnetwork = "0.20" local-ip-address = "0.5.1" diff --git a/examples/http_proxy_test.rs b/examples/http_proxy_test.rs new file mode 100644 index 0000000..b80149b --- /dev/null +++ b/examples/http_proxy_test.rs @@ -0,0 +1,126 @@ +//! End-to-end smoke test for the HttpProxyRequest fallback. +//! +//! Mirrors what a logged-in client does when `OPTION_USE_RAW_TCP_FOR_API=Y`: +//! 1. Open TCP to hbbs's rendezvous port. +//! 2. Read the server-initiated `KeyExchange`. +//! 3. Verify the signature with the server's published Ed25519 pubkey. +//! 4. Reply with `KeyExchange { keys: [client_box_pk, sealed_sym_key] }`. +//! 5. Send `HttpProxyRequest { method, path, headers, body }`. +//! 6. Receive `HttpProxyResponse` and print status + body. +//! +//! Run from the same dir as hbbs's `id_ed25519.pub`: +//! cargo run --example http_proxy_test -- 127.0.0.1:21116 + +use hbb_common::bytes::Bytes; +use hbb_common::protobuf::Message as _; +use hbb_common::rendezvous_proto::{ + rendezvous_message, HttpProxyRequest, KeyExchange, RendezvousMessage, +}; +use hbb_common::tcp::FramedStream; +use hbb_common::tokio; +use sodiumoxide::crypto::{box_, secretbox, sign}; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let addr_arg = std::env::args().nth(1).unwrap_or_else(|| "127.0.0.1:21116".into()); + let pubkey_path = std::env::args() + .nth(2) + .unwrap_or_else(|| "id_ed25519.pub".into()); + + // 1. Connect. + let addr: std::net::SocketAddr = addr_arg.parse().expect("bad addr"); + let raw = tokio::net::TcpStream::connect(addr).await.expect("connect"); + let mut fs = FramedStream::from(raw, addr); + + // 2. Read the server-pushed KeyExchange. + let bytes = fs + .next() + .await + .expect("server closed") + .expect("read err"); + let msg = RendezvousMessage::parse_from_bytes(&bytes).expect("parse first frame"); + let kx_in = match msg.union { + Some(rendezvous_message::Union::KeyExchange(ex)) => ex, + other => panic!( + "expected KeyExchange as first frame, got {:?}", + other.map(|_| "") + ), + }; + assert_eq!( + kx_in.keys.len(), + 1, + "server KX must carry exactly one signed pubkey" + ); + + // 3. Verify the signature. + let pk_b64 = std::fs::read_to_string(&pubkey_path) + .expect("read pubkey") + .trim() + .to_string(); + let pk_bytes = base64::decode(&pk_b64).expect("base64 pubkey"); + assert_eq!(pk_bytes.len(), 32, "Ed25519 pubkey must be 32 bytes"); + let rs_pk = sign::PublicKey::from_slice(&pk_bytes).expect("pubkey"); + let their_box_pk_bytes = + sign::verify(&kx_in.keys[0], &rs_pk).expect("KX signature mismatch"); + assert_eq!(their_box_pk_bytes.len(), 32, "box pk must be 32 bytes"); + let their_box_pk = + box_::PublicKey::from_slice(&their_box_pk_bytes).expect("box pk shape"); + + // 4. Generate ephemeral keypair + sym key, seal the sym key with NaCl box, + // send back KX. + let (our_box_pk, our_box_sk) = box_::gen_keypair(); + let sym_key = secretbox::gen_key(); + let nonce = box_::Nonce([0u8; 24]); + let sealed = box_::seal(&sym_key.0, &nonce, &their_box_pk, &our_box_sk); + + let mut out = RendezvousMessage::new(); + out.set_key_exchange(KeyExchange { + keys: vec![Bytes::from(our_box_pk.0.to_vec()), Bytes::from(sealed)], + ..Default::default() + }); + fs.send(&out).await.expect("send KX"); + fs.set_key(sym_key); + println!("[ok] secure_tcp handshake complete"); + + // 5. HttpProxyRequest — exercise an unauthenticated route first. + let mut req_msg = RendezvousMessage::new(); + req_msg.set_http_proxy_request(HttpProxyRequest { + method: "GET".into(), + path: "/api/login-options".into(), + headers: vec![], + body: Bytes::new(), + ..Default::default() + }); + fs.send(&req_msg).await.expect("send HttpProxyRequest"); + println!("[ok] sent HttpProxyRequest GET /api/login-options"); + + // 6. Receive HttpProxyResponse. + let bytes = fs + .next() + .await + .expect("server closed mid-response") + .expect("read err"); + let resp_msg = + RendezvousMessage::parse_from_bytes(&bytes).expect("parse response"); + match resp_msg.union { + Some(rendezvous_message::Union::HttpProxyResponse(r)) => { + println!("[ok] response status = {}", r.status); + println!( + "[ok] response body = {}", + std::str::from_utf8(&r.body).unwrap_or("") + ); + for h in &r.headers { + println!(" {}: {}", h.name, h.value); + } + assert_eq!(r.status, 200, "expected HTTP 200 from /api/login-options"); + assert!( + std::str::from_utf8(&r.body) + .map(|s| s.contains("account")) + .unwrap_or(false), + "body should mention `account`" + ); + println!("[pass] full HTTP-over-rendezvous round trip verified"); + } + other => panic!("expected HttpProxyResponse, got {:?}", other.is_some()), + } +} diff --git a/libs/hbb_common b/libs/hbb_common index 83419b6..0c49f9a 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit 83419b6549636ee39dacef7776c473f5802e08d6 +Subproject commit 0c49f9a29ccab1f23d8c8f1209d887280ec0e687 diff --git a/src/api/http_proxy.rs b/src/api/http_proxy.rs new file mode 100644 index 0000000..f03c3ca --- /dev/null +++ b/src/api/http_proxy.rs @@ -0,0 +1,162 @@ +//! TCP-over-rendezvous HTTP fallback. The client wraps any `/api/*` request +//! in an `HttpProxyRequest` protobuf and ships it over the rendezvous TCP +//! connection (already encrypted via secure_tcp) when +//! `OPTION_USE_RAW_TCP_FOR_API=Y`. We dispatch the wrapped request through +//! the **same** axum `Router` the HTTPS listener uses, so every existing +//! handler — auth, AB, audit, OIDC, … — is reachable through this path +//! with zero per-route plumbing. + +use axum::body::Body; +use axum::Router; +use hbb_common::log; +use hbb_common::rendezvous_proto::{HeaderEntry, HttpProxyRequest, HttpProxyResponse}; +use http::header::{HeaderMap, HeaderName, HeaderValue}; +use http::{Method, Request}; +use once_cell::sync::Lazy; +use std::convert::TryFrom; +use std::sync::Mutex; +use tower::ServiceExt; + +/// Shared router. Populated by [`api::serve`] before the HTTPS listener +/// starts, so that the rendezvous TCP path can reach the same handlers. +/// `Mutex` because `Router` isn't `Sync` even though it is `Send + Clone`; +/// we never hold the lock across an await — we clone out, drop the guard, +/// and call `oneshot` on the clone. +static ROUTER: Lazy>> = Lazy::new(|| Mutex::new(None)); + +pub fn install_router(r: Router) { + *ROUTER.lock().unwrap() = Some(r); +} + +pub async fn dispatch(req: HttpProxyRequest) -> HttpProxyResponse { + let router = match ROUTER.lock().unwrap().as_ref() { + Some(r) => r.clone(), + None => return error_response(503, "router not initialized"), + }; + + let http_req = match build_request(&req) { + Ok(r) => r, + Err(msg) => return error_response(400, &msg), + }; + + let response = match router.oneshot(http_req).await { + Ok(r) => r, + Err(e) => { + log::warn!("http_proxy: router error: {}", e); + return error_response(500, &format!("router: {}", e)); + } + }; + + let status = response.status().as_u16() as i32; + let headers = serialize_headers(response.headers()); + let body = match collect_body(response.into_body()).await { + Ok(b) => b, + Err(msg) => return error_response(500, &msg), + }; + + HttpProxyResponse { + status, + headers, + body: body.into(), + error: String::new(), + ..Default::default() + } +} + +fn build_request(req: &HttpProxyRequest) -> Result, String> { + let method = if req.method.is_empty() { + Method::GET + } else { + Method::try_from(req.method.as_bytes()) + .map_err(|e| format!("invalid method {:?}: {}", req.method, e))? + }; + let uri = if req.path.is_empty() { + "/".to_string() + } else if req.path.starts_with('/') { + req.path.clone() + } else { + format!("/{}", req.path) + }; + let body_bytes: Vec = req.body.to_vec(); + let mut builder = Request::builder().method(method).uri(uri); + let headers_map = builder + .headers_mut() + .ok_or_else(|| "request builder produced no headers map".to_string())?; + let mut saw_content_type = false; + for h in &req.headers { + if h.name.is_empty() { + continue; + } + let lower = h.name.to_ascii_lowercase(); + // Drop hop-by-hop / framing headers we'll set ourselves to match + // the actual body length axum sees. + if matches!( + lower.as_str(), + "host" | "content-length" | "connection" | "transfer-encoding" + ) { + continue; + } + if lower == "content-type" { + saw_content_type = true; + } + let name = HeaderName::try_from(h.name.as_bytes()) + .map_err(|e| format!("bad header name {:?}: {}", h.name, e))?; + let value = HeaderValue::try_from(h.value.as_bytes()) + .map_err(|e| format!("bad header value for {:?}: {}", h.name, e))?; + headers_map.append(name, value); + } + // Default to JSON if the client forgot — every /api/* handler expects + // JSON unless the route reads `body` as raw bytes (only /api/record), + // which doesn't care about content-type. + if !saw_content_type { + headers_map.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/json"), + ); + } + builder + .body(Body::from(body_bytes)) + .map_err(|e| format!("build request: {}", e)) +} + +fn serialize_headers(map: &HeaderMap) -> Vec { + map.iter() + .map(|(k, v)| HeaderEntry { + name: k.as_str().to_string(), + value: v.to_str().unwrap_or("").to_string(), + ..Default::default() + }) + .collect() +} + +/// Collect any `http_body::Body` whose chunks are buffer-like into a `Vec`. +/// Works against both the request `Body` we build and axum's +/// `UnsyncBoxBody` response body. +async fn collect_body(mut body: B) -> Result, String> +where + B: http_body::Body + Unpin, + B::Data: hbb_common::bytes::Buf, + B::Error: std::fmt::Display, +{ + use hbb_common::bytes::Buf; + let mut buf = Vec::new(); + while let Some(chunk) = body.data().await { + let mut chunk = chunk.map_err(|e| format!("body read: {}", e))?; + while chunk.has_remaining() { + let s = chunk.chunk(); + buf.extend_from_slice(s); + let n = s.len(); + chunk.advance(n); + } + } + Ok(buf) +} + +fn error_response(status: i32, msg: &str) -> HttpProxyResponse { + HttpProxyResponse { + status, + body: msg.as_bytes().to_vec().into(), + error: msg.to_string(), + ..Default::default() + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 0948250..7e5b092 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -11,6 +11,7 @@ pub mod email; pub mod error; pub mod groups; pub mod heartbeat; +pub mod http_proxy; pub mod middleware; pub mod oidc; pub mod pagination; @@ -96,8 +97,12 @@ pub fn router(state: Arc) -> Router { pub async fn serve(addr: SocketAddr, state: Arc) -> ResultType<()> { log::info!("HTTP API listening on {}", addr); + let app = router(state); + // Share the same router with the rendezvous-TCP HttpProxyRequest path so + // both transports route through the exact same handlers. + http_proxy::install_router(app.clone()); axum::Server::bind(&addr) - .serve(router(state).into_make_service()) + .serve(app.into_make_service()) .await?; Ok(()) } diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index 83cadc4..ef53d58 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -626,6 +626,16 @@ impl RendezvousServer { }); Self::send_to_sink(sink, msg_out).await; } + // M4: HTTP-over-rendezvous fallback. The client uses this when + // OPTION_USE_RAW_TCP_FOR_API=Y (locked-down networks where + // direct HTTPS is blocked). We dispatch the wrapped request + // through the SAME axum router as the HTTP listener. + Some(rendezvous_message::Union::HttpProxyRequest(req)) => { + let resp = crate::api::http_proxy::dispatch(req).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_http_proxy_response(resp); + Self::send_to_sink(sink, msg_out).await; + } _ => {} } }