feat: HTTP-over-rendezvous fallback (HttpProxyRequest)
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<Option<Router>>` populated by `api::serve` before the HTTPS listener starts, builds an `http::Request<Body>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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(|_| "<some other variant>")
|
||||
),
|
||||
};
|
||||
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("<non-utf8>")
|
||||
);
|
||||
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()),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user