Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[build]
#rustc-wrapper = "sccache"
# Unlocks reqwest's experimental HTTP/3 client, used only by tuic-tests'
# `h3-masquerade-test` feature. Inert unless reqwest is built with its `http3`
# feature (which only that opt-in test feature enables), so default builds are
# unaffected — no extra deps, no http3 code.
rustflags = ["--cfg", "reqwest_unstable"]

[env]
RUSTC_BOOTSTRAP = { value = "1" }
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ jobs:
rust-toolchain: "stable"
packages: "wind,tuic-server,tuic-client"
target-config-file: ".github/target.toml"
rustflags: ""
# The reusable workflow exports this as the `RUSTFLAGS` env var, which
# overrides `.cargo/config.toml [build] rustflags`; mirror the committed
# `--cfg reqwest_unstable` here so it stays enabled in CI (inert unless a
# crate also turns on reqwest's `http3` feature).
rustflags: "--cfg reqwest_unstable"
enable-tmate: false
only-clippy-tests-on-pr: false
# GitHub caps the per-repo Actions cache at 10 GB and each matrix target
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@ target/
**/*.rs.bk
/target
config.toml
# ...but the workspace's `.cargo/config.toml` IS tracked: it carries
# `RUSTC_BOOTSTRAP=1` (needed for the repo's nightly rustfmt.toml options) and the
# `--cfg reqwest_unstable` flag that the `h3-masquerade-test` feature relies on.
!.cargo/config.toml
.claude
54 changes: 54 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions crates/tuic-server/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ pub struct Config {
pub users: HashMap<Uuid, String>,
pub tls: TlsConfig,

/// HTTP/3 masquerade: reverse-proxy non-TUIC (HTTP/3 probe) connections to
/// a real upstream site so the server is indistinguishable from a web
/// server.
#[serde(default)]
pub masquerade: MasqueradeConfig,

#[educe(Default = "")]
pub data_dir: PathBuf,

Expand Down Expand Up @@ -257,6 +263,23 @@ pub struct TlsConfig {
pub acme_staging: bool,
}

/// HTTP/3 masquerade configuration.
///
/// When `enabled`, a connection that isn't TUIC (its first stream byte isn't
/// the TUIC version `0x05` — i.e. an active prober speaking real HTTP/3) is
/// served as a reverse proxy to `upstream`, so the server is indistinguishable
/// from a normal HTTP/3 website instead of resetting the connection.
#[derive(Deserialize, Serialize, Educe)]
#[educe(Default)]
#[serde(default, deny_unknown_fields)]
pub struct MasqueradeConfig {
#[educe(Default(expression = false))]
pub enabled: bool,
/// Upstream site to reverse-proxy to, e.g. `https://example.com`.
#[educe(Default(expression = "https://example.com"))]
pub upstream: String,
}

/// Transport tuning for the quinn backend (`wind-tuic`).
#[derive(Deserialize, Serialize, Educe)]
#[educe(Default)]
Expand Down
6 changes: 6 additions & 0 deletions crates/tuic-server/src/wind_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ async fn create_quinn_inbound(ctx: &Arc<TuicAppContext>) -> eyre::Result<TuicInb
// so the configured controller and initial window flow straight through.
congestion_control: quinn.congestion_control.controller,
initial_window: quinn.congestion_control.initial_window,
masquerade: cfg.masquerade.enabled.then(|| wind_tuic::server::MasqueradeConfig {
upstream: cfg.masquerade.upstream.clone(),
}),
};
tracing::info!("Initializing quinn (wind-tuic) backend");
Ok(TuicInbound::new(wind_ctx, opts))
Expand Down Expand Up @@ -326,6 +329,9 @@ async fn create_quiche_inbound(ctx: &Arc<TuicAppContext>) -> eyre::Result<Tuiche
.connection_opts(opts)
.certificate_path(cert.clone())
.private_key_path(key.clone())
.masquerade(cfg.masquerade.enabled.then(|| wind_tuic::server::MasqueradeConfig {
upstream: cfg.masquerade.upstream.clone(),
}))
.cancel_token(ctx.cancel.child_token());
for (uuid, pwd) in &cfg.users {
builder = builder.user(*uuid, pwd.clone());
Expand Down
17 changes: 17 additions & 0 deletions crates/tuic-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ default = ["aws-lc-rs"]
ring = ["wind-tuic/ring", "rustls/ring", "tuic-client/ring", "tuic-server/ring"]
aws-lc-rs = ["wind-tuic/aws-lc-rs", "dep:aws-lc-rs", "rustls/aws-lc-rs", "tuic-client/aws-lc-rs", "tuic-server/aws-lc-rs"]

# End-to-end HTTP/3 masquerade test (tests/masquerade.rs). Opt-in because it
# pulls reqwest's experimental HTTP/3 client (a second quinn/h3 stack). The
# `--cfg reqwest_unstable` flag it needs is set by the workspace `.cargo/config.toml`,
# so just run:
# cargo test -p tuic-tests --features h3-masquerade-test
h3-masquerade-test = ["wind-tuic/masquerade", "dep:reqwest", "dep:http"]

[dependencies]
wind-core = { version = "0.1.1", path = "../wind-core" }
tuic-core = { path = "../tuic-core" }
Expand All @@ -34,6 +41,16 @@ aws-lc-rs = { version = "1", default-features = false, optional = true, features
uuid = "1"
tokio-util = { version = "0.7", features = ["codec"] }

# HTTP/3 client for the masquerade e2e test (gated behind `h3-masquerade-test`).
reqwest = { version = "0.12", default-features = false, features = ["http3", "rustls-tls"], optional = true }
http = { version = "1", optional = true }

# `reqwest_unstable` is a rustc cfg (set in the workspace `.cargo/config.toml`)
# that gates reqwest's HTTP/3 client and the masquerade test; declare it so it
# isn't flagged as an unknown cfg.
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(reqwest_unstable)'] }

# The quiche e2e tests only *run* on 64-bit (cross-emulated 32-bit test execution
# is unreliable for real sockets). On 64-bit we enable tuic-server's `quiche`
# feature (so the tests exercise it), add wind-tuic's `quiche` feature (for the
Expand Down
134 changes: 134 additions & 0 deletions crates/tuic-tests/tests/masquerade.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
//! End-to-end test for the HTTP/3 masquerade, using **reqwest's HTTP/3 client**
//! as the "prober".
//!
//! A real HTTP/3 GET against the (quinn) `tuic-server` must come back as the
//! reverse-proxied upstream response — proving a non-TUIC client is served like
//! a genuine web server rather than reset. Exercises the whole path: QUIC
//! handshake, first-byte classification (`0x05` vs not), the `h3::quic`
//! adapter, the `h3` server, and the reqwest reverse proxy to the upstream.
//!
//! Opt-in (pulls reqwest's experimental HTTP/3 stack; the `--cfg
//! reqwest_unstable` flag it needs is set by the workspace
//! `.cargo/config.toml`): cargo test -p tuic-tests --features
//! h3-masquerade-test
#![cfg(all(feature = "h3-masquerade-test", reqwest_unstable, target_pointer_width = "64"))]

use std::{collections::HashMap, net::SocketAddr, time::Duration};

use tokio::{
io::{AsyncReadExt as _, AsyncWriteExt as _},
net::TcpListener,
time::timeout,
};
use tuic_tests::install_crypto_provider;
use uuid::Uuid;

const UPSTREAM_BODY: &str = "wind masquerade upstream OK";

/// A trivial HTTP/1.1 upstream: answers every request with a fixed 200 body.
/// This is what the masquerade reverse-proxies to.
async fn start_upstream() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((mut sock, _)) = listener.accept().await {
tokio::spawn(async move {
// A probe GET has no body, so a single read drains the request line
// + headers; we don't need to parse it.
let mut buf = [0u8; 8192];
let _ = sock.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
UPSTREAM_BODY.len(),
UPSTREAM_BODY
);
let _ = sock.write_all(resp.as_bytes()).await;
let _ = sock.shutdown().await;
});
}
});
addr
}

#[tokio::test(flavor = "multi_thread")]
async fn masquerade_reverse_proxies_http3_probes() -> eyre::Result<()> {
install_crypto_provider();

let upstream = start_upstream().await;

// Fixed port (matches the repo's other e2e tests) so we can form the client
// URL before the server reports its address.
let server_addr: SocketAddr = "127.0.0.1:8471".parse().unwrap();
let uuid = Uuid::new_v4();

let cfg = tuic_server::Config {
log_level: tuic_server::config::LogLevel::Debug,
server: server_addr,
users: {
let mut users = HashMap::new();
users.insert(uuid, "pw".to_string());
users
},
tls: tuic_server::config::TlsConfig {
self_sign: true,
hostname: "localhost".to_string(),
// The server must advertise the `h3` ALPN for an HTTP/3 client to
// negotiate at all — this is also what TUIC uses to disguise itself.
alpn: vec!["h3".to_string()],
..Default::default()
},
masquerade: tuic_server::config::MasqueradeConfig {
enabled: true,
upstream: format!("http://{upstream}"),
},
data_dir: std::env::temp_dir().join("wind-masquerade-test"),
experimental: tuic_server::config::ExperimentalConfig {
drop_loopback: false,
drop_private: false,
},
..Default::default()
};

// `run` blocks forever on success; bound it so a hung server can't wedge the
// test runner, and treat the safety-timeout as "still serving".
let mut server = tokio::spawn(async move {
match timeout(Duration::from_secs(20), tuic_server::run(cfg)).await {
Ok(res) => res,
Err(_) => Ok(()),
}
});

// Wait for the server to bind. If it instead exits early (e.g. the fixed port
// is already in use), surface that real error now rather than letting the
// HTTP/3 request below fail with an opaque 10s timeout.
tokio::select! {
joined = &mut server => match joined {
Ok(Ok(())) => eyre::bail!("tuic-server exited before serving any request"),
Ok(Err(e)) => eyre::bail!("tuic-server failed to start: {e:?}"),
Err(e) => eyre::bail!("tuic-server task panicked: {e}"),
},
_ = tokio::time::sleep(Duration::from_secs(1)) => {}
}

// reqwest as a real HTTP/3 prober. `danger_accept_invalid_certs` because the
// server uses a self-signed cert; `http3_prior_knowledge` forces h3.
let client = reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.http3_prior_knowledge()
.build()?;

let url = format!("https://{server_addr}/some/secret/path?probe=1");
let res = timeout(
Duration::from_secs(10),
client.get(&url).version(http::Version::HTTP_3).send(),
)
.await
.map_err(|_| eyre::eyre!("HTTP/3 request to the masquerade timed out"))??;

assert_eq!(res.version(), http::Version::HTTP_3, "response must be HTTP/3");
assert_eq!(res.status(), 200, "masquerade should return the upstream's 200");
let body = res.text().await?;
assert_eq!(body, UPSTREAM_BODY, "masquerade must relay the upstream body");

Ok(())
}
Loading
Loading